cli/config: --auto-continue flag and [agent] auto_continue_limits

This commit is contained in:
2026-09-09 22:47:39 +08:00
parent aba3765def
commit 18cbcf6835
7 changed files with 73 additions and 7 deletions
+20 -4
View File
@@ -124,9 +124,14 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None return text or None
def _setup_hooks(*, interactive: bool) -> None: def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None:
"""Install interactive limit hooks (workspace policy is set on ReplState).""" """Install limit hooks (workspace policy is set on ReplState).
if interactive:
``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() install_cli_limit_hooks()
else: else:
from plyngent.tools.process.pty_session import PtyManager 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, prompt_text: str | None,
stream: bool, stream: bool,
yes: bool, yes: bool,
auto_continue: bool,
quiet: bool, quiet: bool,
) -> int: ) -> int:
oneshot = prompt_text is not None oneshot = prompt_text is not None
@@ -236,7 +242,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if store.recoverable_providers: if store.recoverable_providers:
_warn_recoverable_providers(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. # --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode from plyngent.cli.state import YoloMode
@@ -315,6 +322,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
max_rounds=max_rounds, max_rounds=max_rounds,
stream_enabled=stream, stream_enabled=stream,
interactive_limits=interactive, interactive_limits=interactive,
auto_continue_limits=auto_effective,
yolo=yolo, yolo=yolo,
mcp_manager=mcp_manager, mcp_manager=mcp_manager,
) )
@@ -480,6 +488,12 @@ def main(ctx: click.Context, log_level: str) -> None:
default=False, default=False,
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).", 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( @click.option(
"--quiet", "--quiet",
is_flag=True, is_flag=True,
@@ -498,6 +512,7 @@ def chat_cmd(
prompt: str | None, prompt: str | None,
stream: bool, # noqa: FBT001 stream: bool, # noqa: FBT001
yes: bool, # noqa: FBT001 yes: bool, # noqa: FBT001
auto_continue: bool, # noqa: FBT001
quiet: bool, # noqa: FBT001 quiet: bool, # noqa: FBT001
) -> None: ) -> None:
"""Interactive chat REPL, or one-shot with ``-p`` / stdin. """Interactive chat REPL, or one-shot with ``-p`` / stdin.
@@ -538,6 +553,7 @@ def chat_cmd(
prompt_text=prompt_text, prompt_text=prompt_text,
stream=stream, stream=stream,
yes=yes, yes=yes,
auto_continue=auto_continue,
quiet=quiet, quiet=quiet,
) )
) )
+4 -1
View File
@@ -36,14 +36,17 @@ def _echo_interrupted() -> None:
async def run_repl(state: ReplState) -> None: async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion.""" """Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state) setup_readline(state)
from plyngent.cli.limits import auto_continue_enabled
yolo = state.effective_yolo() yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else "" yolo_part = f" yolo={yolo}" if yolo != "off" else ""
auto_part = " limit_auto=on" if auto_continue_enabled() else ""
click.echo( click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} " f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} " f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)} " f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
f"stream={'on' if state.agent.stream else 'off'} " 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.') click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
+7 -2
View File
@@ -69,6 +69,8 @@ class ReplState:
markdown_enabled: bool = True markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits. # One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True 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. # Soft destructive-tool confirms: None → derive from config.confirm_destructive.
# off = confirm; on = skip (sticky); once = skip next user turn then off. # off = confirm; on = skip (sticky); once = skip next user turn then off.
yolo: YoloMode | None = None 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.instance_state.workspace.root = self.workspace self.instance_state.workspace.root = self.workspace
self._install_config_access() 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.session_state = self._session_data_for_todo()
self.agent = self._make_agent() self.agent = self._make_agent()
self.sync_display_flags() self.sync_display_flags()
@@ -340,7 +345,7 @@ class ReplState:
return "\n\n".join(parts) return "\n\n".join(parts)
def _make_agent(self) -> ChatAgent: 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 from plyngent.config import compose_agent_system_content
agent_cfg = self.config.agent_config agent_cfg = self.config.agent_config
@@ -350,7 +355,7 @@ class ReplState:
agent_cfg.tool_directives, agent_cfg.tool_directives,
mcp_text, 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 peak = 0
band = 0 band = 0
last_req = None last_req = None
+3
View File
@@ -117,6 +117,9 @@ class AgentConfig(Struct, omit_defaults=True):
# Static directory pre-allow (resolved path → mode); never prompts. # Static directory pre-allow (resolved path → mode); never prompts.
# Denylist still applies, and the path must exist when chat starts. # Denylist still applies, and the path must exist when chat starts.
allow_paths: dict[str, AllowPathMode] = field(default_factory=dict) 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 max_context_tokens: int = 200_000
# How to inject todo stack nags into model context (see agent/todo_nag.py). # How to inject todo stack nags into model context (see agent/todo_nag.py).
+10
View File
@@ -80,6 +80,16 @@ def test_prompt_continue_limit_noninteractive_denies() -> None:
assert prompt_continue_limit("limit") is False 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: def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue del clean_auto_continue
PtyManager.set_limit_continue_hook(prompt_continue_limit) PtyManager.set_limit_continue_hook(prompt_continue_limit)
+26
View File
@@ -94,6 +94,32 @@ async def test_help_and_clear(state: ReplState) -> None:
assert state.agent.messages == [] 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: async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/help compact") is True assert await handle_slash(state, "/help compact") is True
out = capsys.readouterr().out out = capsys.readouterr().out
+3
View File
@@ -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.confirm_destructive is True
assert store.agent_config.path_denylist == [] assert store.agent_config.path_denylist == []
assert store.agent_config.allow_paths == {} 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.agent_config.max_context_tokens == 200_000
assert store.plugins_config.enable == [] assert store.plugins_config.enable == []
assert store.plugins_config.disable == [] assert store.plugins_config.disable == []
@@ -83,6 +84,7 @@ parallel_tools = false
confirm_destructive = false confirm_destructive = false
path_denylist = ["/secrets/", ".ssh/"] path_denylist = ["/secrets/", ".ssh/"]
max_context_tokens = 5000 max_context_tokens = 5000
auto_continue_limits = true
""", """,
encoding="utf-8", encoding="utf-8",
) )
@@ -94,6 +96,7 @@ max_context_tokens = 5000
assert store.agent_config.confirm_destructive is False assert store.agent_config.confirm_destructive is False
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"] assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
assert store.agent_config.max_context_tokens == 5000 assert store.agent_config.max_context_tokens == 5000
assert store.agent_config.auto_continue_limits is True
composed = compose_agent_system_content( composed = compose_agent_system_content(
store.agent_config.system_prompt, store.agent_config.system_prompt,
store.agent_config.tool_directives, store.agent_config.tool_directives,