diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index c9fa97e..6a01f6f 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -381,6 +381,9 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration set_directory_access_confirm_hook(None, instance=state_obj.instance_state) clear_process_access(instance=state_obj.instance_state) + from plyngent.cli.limits import reset_auto_continue + + reset_auto_continue() if state_obj.mcp_manager is not None: await state_obj.mcp_manager.aclose() await state_obj.instance_state.shutdown() diff --git a/src/plyngent/cli/retry.py b/src/plyngent/cli/retry.py index 4401900..8ef70fd 100644 --- a/src/plyngent/cli/retry.py +++ b/src/plyngent/cli/retry.py @@ -10,6 +10,7 @@ import click from plyngent.cli.display import render_events from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall +from plyngent.cli.limits import reset_auto_continue_turn if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Coroutine @@ -184,7 +185,23 @@ async def run_turn_with_retries( further attempts use ``agent.retry`` so the user message is not duplicated. Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``. + + The ``yyy`` limit auto-continue is turn-scoped: cleared before and after the + turn, so a retry of the same turn keeps it but the next user turn prompts. """ + reset_auto_continue_turn() + try: + return await _run_turn_with_retries(agent, starter=starter, delays=delays) + finally: + reset_auto_continue_turn() + + +async def _run_turn_with_retries( + agent: ChatAgent, + *, + starter: Callable[[], AsyncIterator[AgentEvent]], + delays: tuple[float, ...], +) -> bool: max_retries = len(delays) attempt = 0 current: Callable[[], AsyncIterator[AgentEvent]] = starter diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index 3ca184a..fde2eb5 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -1018,11 +1018,17 @@ def btw_cmd( else: await run_cancellable(side) + from plyngent.cli.limits import reset_auto_continue_turn + + # Aside is its own exchange: a ``yyy`` from the main turn must not leak in. + reset_auto_continue_turn() try: _await(_run()) except Exception as exc: # noqa: BLE001 — surface side-turn failures click.secho(f"error: btw failed: {exc}", fg="red") return + finally: + reset_auto_continue_turn() click.secho("(btw done — main session unchanged)", fg="bright_black") diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index d26cb79..5355694 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -108,6 +108,12 @@ class ReplState: set_verbose_tool_results(self.verbose) set_markdown_enabled(self.markdown_enabled) + def _reset_limit_auto_continue(self) -> None: + """Drop a ``yyy`` skip on session change (it is turn-scoped).""" + from plyngent.cli.limits import reset_auto_continue_turn + + reset_auto_continue_turn() + def _workspace_key(self) -> str: key = normalize_workspace(self.workspace) if key is None: @@ -658,6 +664,7 @@ class ReplState: return False async def new_session(self, name: str = "chat") -> None: + self._reset_limit_auto_continue() session = await self.memory.create_session( name=name, workspace=self.workspace, @@ -696,6 +703,7 @@ class ReplState: """Load a session; on workspace mismatch, prompt keep / rebind / abort.""" from plyngent.cli.limits import prompt_workspace_mismatch + self._reset_limit_auto_continue() row = await self.memory.get_session(session_id) if row is None: msg = f"session not found: {session_id}" @@ -733,6 +741,7 @@ class ReplState: async def resume_latest_or_new(self, name: str = "chat") -> str: """Resume most recently updated session for this workspace, or create one.""" + self._reset_limit_auto_continue() latest = await self.memory.get_latest_session(workspace=self.workspace) if latest is None: await self.new_session(name=name) diff --git a/tests/test_cli/test_retry.py b/tests/test_cli/test_retry.py index 354f0f0..7d12cef 100644 --- a/tests/test_cli/test_retry.py +++ b/tests/test_cli/test_retry.py @@ -30,6 +30,8 @@ from plyngent.memory import MemoryStore if TYPE_CHECKING: from collections.abc import AsyncIterator + from plyngent.agent import AgentEvent + def test_default_retry_delays_schedule() -> None: assert DEFAULT_MAX_AUTO_RETRIES == 10 @@ -296,6 +298,43 @@ async def test_second_ctrl_c_during_retry_announce_stays_benign( assert await _wait_for_retry(1, 3, 0.01) is True +async def test_turn_boundaries_reset_auto_continue() -> None: + from plyngent.cli.limits import ( + auto_continue_enabled, + prompt_continue_limit, + reset_auto_continue, + ) + from plyngent.prompting import temporary_backend + from tests.test_prompting import ScriptedBackend + + reset_auto_continue() + try: + with temporary_backend(ScriptedBackend(["yyy"])): + assert prompt_continue_limit("limit") is True + assert auto_continue_enabled() is True + + store = await MemoryStore.open(DatabaseConfig()) + try: + session = await store.create_session(name="t") + client = FlakyClient(fail_times=0) + agent = ChatAgent(client, model="m", memory=store, session_id=session.sid) + seen: list[bool] = [] + + async def starter() -> AsyncIterator[AgentEvent]: + seen.append(auto_continue_enabled()) + async for event in agent.run("hi"): + yield event + + ok = await run_turn_with_retries(agent, starter=starter, delays=()) + assert ok is True + assert seen == [False] # cleared before the turn starts + assert auto_continue_enabled() is False # cleared after the turn ends + finally: + await store.close() + finally: + reset_auto_continue() + + async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None: async def no_wait(_seconds: float) -> bool: return True