diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 6a01f6f..a178e25 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -124,9 +124,14 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None: return text or None -def _setup_hooks(*, interactive: bool) -> None: - """Install interactive limit hooks (workspace policy is set on ReplState).""" - if interactive: +def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None: + """Install limit hooks (workspace policy is set on ReplState). + + ``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so + ``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits + without a prompt; otherwise non-interactive runs keep today's deny. + """ + if interactive or auto_continue: install_cli_limit_hooks() else: from plyngent.tools.process.pty_session import PtyManager @@ -221,6 +226,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration prompt_text: str | None, stream: bool, yes: bool, + auto_continue: bool, quiet: bool, ) -> int: oneshot = prompt_text is not None @@ -236,7 +242,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration if store.recoverable_providers: _warn_recoverable_providers(store.recoverable_providers) - _setup_hooks(interactive=interactive) + auto_effective = auto_continue or store.agent_config.auto_continue_limits + _setup_hooks(interactive=interactive, auto_continue=auto_effective) # --yes forces sticky YOLO; else derive from config.confirm_destructive. from plyngent.cli.state import YoloMode @@ -315,6 +322,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration max_rounds=max_rounds, stream_enabled=stream, interactive_limits=interactive, + auto_continue_limits=auto_effective, yolo=yolo, mcp_manager=mcp_manager, ) @@ -480,6 +488,12 @@ def main(ctx: click.Context, log_level: str) -> None: default=False, help="Enable YOLO: skip destructive-tool confirms (sticky for this process).", ) +@click.option( + "--auto-continue", + is_flag=True, + default=False, + help="Auto-raise tool/PTY limits without prompting (like answering yyy).", +) @click.option( "--quiet", is_flag=True, @@ -498,6 +512,7 @@ def chat_cmd( prompt: str | None, stream: bool, # noqa: FBT001 yes: bool, # noqa: FBT001 + auto_continue: bool, # noqa: FBT001 quiet: bool, # noqa: FBT001 ) -> None: """Interactive chat REPL, or one-shot with ``-p`` / stdin. @@ -538,6 +553,7 @@ def chat_cmd( prompt_text=prompt_text, stream=stream, yes=yes, + auto_continue=auto_continue, quiet=quiet, ) ) diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index 3632430..6c5c0eb 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -36,14 +36,17 @@ def _echo_interrupted() -> None: async def run_repl(state: ReplState) -> None: """Interactive chat loop with readline editing, history, and Tab completion.""" setup_readline(state) + from plyngent.cli.limits import auto_continue_enabled + yolo = state.effective_yolo() yolo_part = f" yolo={yolo}" if yolo != "off" else "" + auto_part = " limit_auto=on" if auto_continue_enabled() else "" click.echo( f"plyngent chat provider={state.provider_name} model={state.model} " f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} " f"rounds={state.max_rounds} messages={len(state.agent.messages)} " f"stream={'on' if state.agent.stream else 'off'} " - f"verbose={'on' if state.verbose else 'off'}{yolo_part}" + f"verbose={'on' if state.verbose else 'off'}{yolo_part}{auto_part}" ) click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.') diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 5355694..3bae017 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -69,6 +69,8 @@ class ReplState: markdown_enabled: bool = True # One-shot / scripts: never prompt to raise tool-loop limits. interactive_limits: bool = True + # Auto-raise tool/PTY limits without prompting (--auto-continue / config). + auto_continue_limits: bool = False # Soft destructive-tool confirms: None → derive from config.confirm_destructive. # off = confirm; on = skip (sticky); once = skip next user turn then off. yolo: YoloMode | None = None @@ -97,6 +99,9 @@ class ReplState: self.instance_state.workspace_root = self.workspace self.instance_state.workspace.root = self.workspace self._install_config_access() + from plyngent.cli.limits import set_auto_continue_default + + set_auto_continue_default(enabled=self.auto_continue_limits) self.session_state = self._session_data_for_todo() self.agent = self._make_agent() self.sync_display_flags() @@ -340,7 +345,7 @@ class ReplState: return "\n\n".join(parts) def _make_agent(self) -> ChatAgent: - from plyngent.cli.limits import prompt_continue_limit_async + from plyngent.cli.limits import auto_continue_enabled, prompt_continue_limit_async from plyngent.config import compose_agent_system_content agent_cfg = self.config.agent_config @@ -350,7 +355,7 @@ class ReplState: agent_cfg.tool_directives, mcp_text, ) - on_limit = prompt_continue_limit_async if self.interactive_limits else None + on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None peak = 0 band = 0 last_req = None diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 397791c..e98f037 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -117,6 +117,9 @@ class AgentConfig(Struct, omit_defaults=True): # Static directory pre-allow (resolved path → mode); never prompts. # Denylist still applies, and the path must exist when chat starts. allow_paths: dict[str, AllowPathMode] = field(default_factory=dict) + # Auto-raise tool/PTY limits without prompting (same as answering ``yyy``); + # process-wide, so every turn skips the limit confirm. + auto_continue_limits: bool = False max_context_tokens: int = 200_000 # How to inject todo stack nags into model context (see agent/todo_nag.py). diff --git a/tests/test_cli/test_limits.py b/tests/test_cli/test_limits.py index 2c33582..05faa73 100644 --- a/tests/test_cli/test_limits.py +++ b/tests/test_cli/test_limits.py @@ -80,6 +80,16 @@ def test_prompt_continue_limit_noninteractive_denies() -> None: assert prompt_continue_limit("limit") is False +def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None: + del clean_auto_continue + from plyngent.cli.app import _setup_hooks + + _setup_hooks(interactive=False, auto_continue=True) + assert PtyManager._limit_continue is not None + _setup_hooks(interactive=False, auto_continue=False) + assert PtyManager._limit_continue is None + + def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None: del clean_auto_continue PtyManager.set_limit_continue_hook(prompt_continue_limit) diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index a603fcf..73e9968 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -94,6 +94,32 @@ async def test_help_and_clear(state: ReplState) -> None: assert state.agent.messages == [] +async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None: + from plyngent.cli.limits import auto_continue_enabled, reset_auto_continue + + memory = await MemoryStore.open(DatabaseConfig()) + provider = OpenAIProvider(access_key_or_token="sk-test") + config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document()) + config.providers = {"local": provider} + try: + st = ReplState( + config=config, + memory=memory, + workspace=tmp_path, + provider_name="local", + provider=provider, + model="gpt-test", + tools_enabled=False, + interactive_limits=False, + auto_continue_limits=True, + ) + assert auto_continue_enabled() is True + assert st.agent.on_limit is not None + finally: + reset_auto_continue() + await memory.close() + + async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: assert await handle_slash(state, "/help compact") is True out = capsys.readouterr().out diff --git a/tests/test_config/test_agent_section.py b/tests/test_config/test_agent_section.py index bd8c9c7..1237f58 100644 --- a/tests/test_config/test_agent_section.py +++ b/tests/test_config/test_agent_section.py @@ -27,6 +27,7 @@ def test_agent_section_defaults(tmp_path: Path) -> None: assert store.agent_config.confirm_destructive is True assert store.agent_config.path_denylist == [] assert store.agent_config.allow_paths == {} + assert store.agent_config.auto_continue_limits is False assert store.agent_config.max_context_tokens == 200_000 assert store.plugins_config.enable == [] assert store.plugins_config.disable == [] @@ -83,6 +84,7 @@ parallel_tools = false confirm_destructive = false path_denylist = ["/secrets/", ".ssh/"] max_context_tokens = 5000 +auto_continue_limits = true """, encoding="utf-8", ) @@ -94,6 +96,7 @@ max_context_tokens = 5000 assert store.agent_config.confirm_destructive is False assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"] assert store.agent_config.max_context_tokens == 5000 + assert store.agent_config.auto_continue_limits is True composed = compose_agent_system_content( store.agent_config.system_prompt, store.agent_config.tool_directives,