mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/cli: raise the wait signal from every confirm prompt
Every place the CLI blocks on the human now announces itself: the single and batched tool confirms, the command-denylist and `fetch_local` policies, a skill write, a directory-access request, the `ask_user_*` questions and the limit-continue prompt. `_setup_hooks` installs the notifier from `[notify]`, and only for an interactive run — a one-shot run never waits on a human. An answer that came back through the notification is read as if it had been typed: `allow` / `deny` map onto the `y` / `n` the prompts already accept, and the confirmation prompts switch from click's readline to the poll-based read only when the notification actually offered answers — otherwise the terminal experience is untouched. "Go to terminal" never reaches the prompt at all. The stdin read behind the timed prompts now watches in 100ms slices instead of one full-length `select`, which is what lets a notification answer end it; an answer still loses to a human typing at the terminal. A `None` timeout waits for whichever arrives first, and a non-pollable stdin still gives up at once. `prompt_wait` announces only the outermost prompt of a nested read: a confirm that asks a follow-up question while it is already on screen would otherwise buzz the human a second time.
This commit is contained in:
+13
-3
@@ -35,6 +35,7 @@ from plyngent.runtime import McpManager, ProviderNotSupportedError, create_clien
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from plyngent.config.models import NotifyConfig
|
||||||
from plyngent.config.store import ConfigStore
|
from plyngent.config.store import ConfigStore
|
||||||
|
|
||||||
_DEFAULT_DB_FILENAME = "chat.db"
|
_DEFAULT_DB_FILENAME = "chat.db"
|
||||||
@@ -128,13 +129,22 @@ 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, auto_continue: bool) -> None:
|
def _setup_hooks(*, interactive: bool, auto_continue: bool, notify: NotifyConfig) -> None:
|
||||||
"""Install limit hooks (workspace policy is set on ReplState).
|
"""Install limit hooks (workspace policy is set on ReplState).
|
||||||
|
|
||||||
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
|
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
|
||||||
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
|
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
|
||||||
without a prompt; otherwise non-interactive runs keep today's deny.
|
without a prompt; otherwise non-interactive runs keep today's deny. The
|
||||||
|
prompt-wait notifier only makes sense for a human at a terminal, so a
|
||||||
|
non-interactive run clears it whatever ``[notify]`` says.
|
||||||
"""
|
"""
|
||||||
|
from plyngent.cli.notify import install_notifier
|
||||||
|
from plyngent.prompting import set_prompt_wait_hook
|
||||||
|
|
||||||
|
if interactive:
|
||||||
|
_ = install_notifier(notify)
|
||||||
|
else:
|
||||||
|
set_prompt_wait_hook(None)
|
||||||
if interactive or auto_continue:
|
if interactive or auto_continue:
|
||||||
install_cli_limit_hooks()
|
install_cli_limit_hooks()
|
||||||
else:
|
else:
|
||||||
@@ -248,7 +258,7 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
|
|||||||
_warn_recoverable_providers(store.recoverable_providers)
|
_warn_recoverable_providers(store.recoverable_providers)
|
||||||
|
|
||||||
auto_effective = auto_continue or store.agent_config.auto_continue_limits
|
auto_effective = auto_continue or store.agent_config.auto_continue_limits
|
||||||
_setup_hooks(interactive=interactive, auto_continue=auto_effective)
|
_setup_hooks(interactive=interactive, auto_continue=auto_effective, notify=store.notify_config)
|
||||||
# --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
|
||||||
|
|
||||||
|
|||||||
+130
-28
@@ -4,6 +4,7 @@ import contextlib
|
|||||||
import selectors
|
import selectors
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
|
|||||||
from plyngent.prompting import (
|
from plyngent.prompting import (
|
||||||
ChoiceOption,
|
ChoiceOption,
|
||||||
NonInteractiveError,
|
NonInteractiveError,
|
||||||
|
PromptAnswerSource,
|
||||||
ask,
|
ask,
|
||||||
ask_async,
|
ask_async,
|
||||||
choose,
|
choose,
|
||||||
@@ -20,6 +22,7 @@ from plyngent.prompting import (
|
|||||||
configure_prompting,
|
configure_prompting,
|
||||||
confirm,
|
confirm,
|
||||||
get_prompt_backend,
|
get_prompt_backend,
|
||||||
|
prompt_wait,
|
||||||
set_prompt_output_hook,
|
set_prompt_output_hook,
|
||||||
)
|
)
|
||||||
from plyngent.tools.access import AccessDecision
|
from plyngent.tools.access import AccessDecision
|
||||||
@@ -30,12 +33,37 @@ from plyngent.tools.workspace import AccessMode, parse_access_mode
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
||||||
|
|
||||||
|
# Answers a notification offers (see `cli/notify.py`) mapped onto the keys the
|
||||||
|
# prompts below already accept, so one answer source fits every confirm.
|
||||||
|
_NOTIFY_ANSWERS = {"allow": "y", "deny": "n"}
|
||||||
|
|
||||||
_BOX_MIN_WIDTH = 40
|
_BOX_MIN_WIDTH = 40
|
||||||
_BOX_MAX_WIDTH = 100
|
_BOX_MAX_WIDTH = 100
|
||||||
_BOX_PAD = 2 # spaces inside left/right borders
|
_BOX_PAD = 2 # spaces inside left/right borders
|
||||||
|
|
||||||
|
# How long one poll slice waits before a pending notification answer is checked.
|
||||||
|
_ANSWER_POLL_SECONDS = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_source(wait: PromptAnswerSource | None) -> PromptAnswerSource | None:
|
||||||
|
"""The wait's answer source, but only when the prompt offered answers."""
|
||||||
|
return wait if wait is not None and wait.supports_actions else None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_answer(
|
||||||
|
timeout_seconds: float | None,
|
||||||
|
wait: PromptAnswerSource | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""One answer line, from stdin or the notification (``None`` on timeout)."""
|
||||||
|
raw = _read_yes_no_line_with_timeout(timeout_seconds, source=_answer_source(wait))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return _NOTIFY_ANSWERS.get(raw.strip().lower(), raw)
|
||||||
|
|
||||||
|
|
||||||
# Limit prompts accept ``yyy`` to stop asking for the rest of the turn.
|
# Limit prompts accept ``yyy`` to stop asking for the rest of the turn.
|
||||||
_CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this turn): "
|
_CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this turn): "
|
||||||
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
||||||
@@ -174,6 +202,7 @@ def _decide_continue_limit(reason: str) -> bool:
|
|||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return False
|
return False
|
||||||
|
with prompt_wait("limit", "limit reached — continue?", reason):
|
||||||
_echo_continue_limit(reason)
|
_echo_continue_limit(reason)
|
||||||
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
|
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
|
||||||
if token == _CONTINUE_AUTO_TOKEN:
|
if token == _CONTINUE_AUTO_TOKEN:
|
||||||
@@ -205,12 +234,33 @@ async def prompt_continue_limit_async(reason: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool:
|
||||||
|
"""Yes/no, also taking an answer that arrived through a notification."""
|
||||||
|
source = _answer_source(wait)
|
||||||
|
if source is None:
|
||||||
|
return confirm(prompt, default=default)
|
||||||
|
suffix = "[Y/n]" if default else "[y/N]"
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
_ = sys.stderr.write(f"{prompt} {suffix}: ")
|
||||||
|
_ = sys.stderr.flush()
|
||||||
|
try:
|
||||||
|
raw = _read_answer(None, wait)
|
||||||
|
except (KeyboardInterrupt, EOFError) as exc:
|
||||||
|
msg = "prompt cancelled"
|
||||||
|
raise NonInteractiveError(msg) from exc
|
||||||
|
token = (raw or "").strip().lower()
|
||||||
|
if not token:
|
||||||
|
return default
|
||||||
|
return token in {"y", "yes"}
|
||||||
|
|
||||||
|
|
||||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||||
"""True allow; False deny; non-empty str = deny with comment for the model."""
|
"""True allow; False deny; non-empty str = deny with comment for the model."""
|
||||||
del args
|
del args
|
||||||
try:
|
try:
|
||||||
|
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
|
||||||
_echo_tool_confirm(name, reason)
|
_echo_tool_confirm(name, reason)
|
||||||
allowed = confirm("Allow this tool call?", default=False)
|
allowed = _confirm_with(wait, "Allow this tool call?", default=False)
|
||||||
except NonInteractiveError:
|
except NonInteractiveError:
|
||||||
return False
|
return False
|
||||||
if allowed:
|
if allowed:
|
||||||
@@ -242,34 +292,64 @@ def _try_readline() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _read_yes_no_line_with_timeout(timeout_seconds: float) -> str | None:
|
def _stdin_selector() -> selectors.BaseSelector | None:
|
||||||
"""Read one line from stdin with *timeout_seconds*; ``None`` on timeout/EOF.
|
"""A selector watching process stdin, or ``None`` when it cannot be polled."""
|
||||||
|
|
||||||
Uses :mod:`selectors` on the process stdin FD. On Windows without selector
|
|
||||||
support, falls back to blocking readline (no true timeout).
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
if not sys.stdin.isatty():
|
|
||||||
return None
|
|
||||||
fd = sys.stdin.fileno()
|
fd = sys.stdin.fileno()
|
||||||
except OSError, ValueError, AttributeError:
|
|
||||||
return _try_readline()
|
|
||||||
|
|
||||||
try:
|
|
||||||
selector = selectors.DefaultSelector()
|
selector = selectors.DefaultSelector()
|
||||||
_ = selector.register(fd, selectors.EVENT_READ)
|
_ = selector.register(fd, selectors.EVENT_READ)
|
||||||
|
except OSError, ValueError, AttributeError:
|
||||||
|
return None
|
||||||
|
return selector
|
||||||
|
|
||||||
|
|
||||||
|
def _read_yes_no_line_with_timeout(
|
||||||
|
timeout_seconds: float | None,
|
||||||
|
*,
|
||||||
|
source: PromptAnswerSource | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Read one line from stdin with *timeout_seconds*; ``None`` on timeout/EOF.
|
||||||
|
|
||||||
|
Polls the process stdin FD in short slices, so a *source* (a notification
|
||||||
|
answer) can end the read too; stdin wins whenever it becomes readable. A
|
||||||
|
``None`` timeout waits for a line or an answer, whichever comes first; with
|
||||||
|
neither stdin nor a *source* pollable the read gives up at once.
|
||||||
|
On Windows without selector support, falls back to blocking readline (no
|
||||||
|
true timeout, and no answers from a *source*).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return None if source is None else source.take()
|
||||||
except OSError, ValueError, AttributeError:
|
except OSError, ValueError, AttributeError:
|
||||||
return _try_readline()
|
return _try_readline()
|
||||||
|
|
||||||
|
selector = _stdin_selector()
|
||||||
|
if selector is None:
|
||||||
|
return _try_readline()
|
||||||
|
|
||||||
|
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
|
||||||
|
result: str | None = None
|
||||||
try:
|
try:
|
||||||
events = selector.select(timeout_seconds)
|
while True:
|
||||||
if not events:
|
if deadline is None:
|
||||||
return None
|
remaining = _ANSWER_POLL_SECONDS
|
||||||
return sys.stdin.readline()
|
else:
|
||||||
except OSError, ValueError, EOFError:
|
remaining = deadline - time.monotonic()
|
||||||
return None
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
if selector.select(min(remaining, _ANSWER_POLL_SECONDS)):
|
||||||
|
try:
|
||||||
|
result = sys.stdin.readline()
|
||||||
|
except OSError, EOFError:
|
||||||
|
result = None
|
||||||
|
break
|
||||||
|
answer = None if source is None else source.take()
|
||||||
|
if answer is not None:
|
||||||
|
result = answer
|
||||||
|
break
|
||||||
finally:
|
finally:
|
||||||
selector.close()
|
selector.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def prompt_policy_command_confirm(
|
def prompt_policy_command_confirm(
|
||||||
@@ -290,7 +370,10 @@ def prompt_policy_command_confirm(
|
|||||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
|
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with pause_task_cancel_for_prompt():
|
with (
|
||||||
|
pause_task_cancel_for_prompt(),
|
||||||
|
prompt_wait("policy", f"allow command {basename!r}?", reason) as wait,
|
||||||
|
):
|
||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return False
|
return False
|
||||||
@@ -301,7 +384,7 @@ def prompt_policy_command_confirm(
|
|||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
_ = sys.stderr.write(prompt)
|
_ = sys.stderr.write(prompt)
|
||||||
_ = sys.stderr.flush()
|
_ = sys.stderr.flush()
|
||||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
raw = _read_answer(timeout_seconds, wait)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
with contextlib.suppress(OSError, NonInteractiveError):
|
with contextlib.suppress(OSError, NonInteractiveError):
|
||||||
backend.secho(
|
backend.secho(
|
||||||
@@ -334,7 +417,10 @@ def prompt_policy_fetch_confirm(
|
|||||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
|
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with pause_task_cancel_for_prompt():
|
with (
|
||||||
|
pause_task_cancel_for_prompt(),
|
||||||
|
prompt_wait("policy", f"allow fetch_local {host!r}?", reason) as wait,
|
||||||
|
):
|
||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return False
|
return False
|
||||||
@@ -346,7 +432,7 @@ def prompt_policy_fetch_confirm(
|
|||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
_ = sys.stderr.write(prompt)
|
_ = sys.stderr.write(prompt)
|
||||||
_ = sys.stderr.flush()
|
_ = sys.stderr.flush()
|
||||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
raw = _read_answer(timeout_seconds, wait)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
with contextlib.suppress(OSError, NonInteractiveError):
|
with contextlib.suppress(OSError, NonInteractiveError):
|
||||||
backend.secho(
|
backend.secho(
|
||||||
@@ -380,7 +466,10 @@ def prompt_skill_write_confirm(
|
|||||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)",
|
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)",
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
with pause_task_cancel_for_prompt():
|
with (
|
||||||
|
pause_task_cancel_for_prompt(),
|
||||||
|
prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail) as wait,
|
||||||
|
):
|
||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return SkillWriteDecision(allow=False)
|
return SkillWriteDecision(allow=False)
|
||||||
@@ -393,7 +482,7 @@ def prompt_skill_write_confirm(
|
|||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
_ = sys.stderr.write(prompt)
|
_ = sys.stderr.write(prompt)
|
||||||
_ = sys.stderr.flush()
|
_ = sys.stderr.flush()
|
||||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
raw = _read_answer(timeout_seconds, wait)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
with contextlib.suppress(OSError, NonInteractiveError):
|
with contextlib.suppress(OSError, NonInteractiveError):
|
||||||
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
||||||
@@ -428,7 +517,10 @@ def prompt_directory_access_confirm(
|
|||||||
lines.append(f"why: {reason}")
|
lines.append(f"why: {reason}")
|
||||||
lines.append(f"(timeout {timeout_seconds:g}s defaults to DENY)")
|
lines.append(f"(timeout {timeout_seconds:g}s defaults to DENY)")
|
||||||
try:
|
try:
|
||||||
with pause_task_cancel_for_prompt():
|
with (
|
||||||
|
pause_task_cancel_for_prompt(),
|
||||||
|
prompt_wait("access", f"allow {mode.name.lower()} access to {path}?", reason) as wait,
|
||||||
|
):
|
||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return None
|
return None
|
||||||
@@ -444,7 +536,7 @@ def prompt_directory_access_confirm(
|
|||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
_ = sys.stderr.write(prompt)
|
_ = sys.stderr.write(prompt)
|
||||||
_ = sys.stderr.flush()
|
_ = sys.stderr.flush()
|
||||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
raw = _read_answer(timeout_seconds, wait)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
with contextlib.suppress(OSError, NonInteractiveError):
|
with contextlib.suppress(OSError, NonInteractiveError):
|
||||||
backend.secho(
|
backend.secho(
|
||||||
@@ -470,8 +562,9 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
|
|||||||
try:
|
try:
|
||||||
# Box is printed inside the worker thread via confirm's backend.
|
# Box is printed inside the worker thread via confirm's backend.
|
||||||
def _run() -> bool:
|
def _run() -> bool:
|
||||||
|
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
|
||||||
_echo_tool_confirm(name, reason)
|
_echo_tool_confirm(name, reason)
|
||||||
return confirm("Allow this tool call?", default=False)
|
return _confirm_with(wait, "Allow this tool call?", default=False)
|
||||||
|
|
||||||
from plyngent.prompting import run_prompt_async
|
from plyngent.prompting import run_prompt_async
|
||||||
|
|
||||||
@@ -512,11 +605,15 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
|
|||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
# Non-interactive: deny, like the default of the single prompt.
|
# Non-interactive: deny, like the default of the single prompt.
|
||||||
return BatchConfirmDecision(allowed=False)
|
return BatchConfirmDecision(allowed=False)
|
||||||
|
names = ", ".join(request.name for request in requests)
|
||||||
|
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait:
|
||||||
backend.echo()
|
backend.echo()
|
||||||
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
||||||
backend.secho("m = review them one call at a time", fg="bright_black")
|
backend.secho("m = review them one call at a time", fg="bright_black")
|
||||||
backend.echo()
|
backend.echo()
|
||||||
|
source = _answer_source(wait)
|
||||||
try:
|
try:
|
||||||
|
if source is None:
|
||||||
token = (
|
token = (
|
||||||
backend.read_line(
|
backend.read_line(
|
||||||
f"Allow these {len(requests)} tool calls? [y/N/m]:",
|
f"Allow these {len(requests)} tool calls? [y/N/m]:",
|
||||||
@@ -525,6 +622,11 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
|
|||||||
.strip()
|
.strip()
|
||||||
.lower()
|
.lower()
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
_ = sys.stderr.write(f"Allow these {len(requests)} tool calls? [y/N]: ")
|
||||||
|
_ = sys.stderr.flush()
|
||||||
|
token = (_read_answer(None, wait) or "n").strip().lower()
|
||||||
except NonInteractiveError:
|
except NonInteractiveError:
|
||||||
return BatchConfirmDecision(allowed=False)
|
return BatchConfirmDecision(allowed=False)
|
||||||
if token in _BATCH_MANUAL_TOKENS:
|
if token in _BATCH_MANUAL_TOKENS:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import contextvars
|
||||||
import select
|
import select
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -154,6 +155,9 @@ type PromptWaitHook = Callable[[PromptWait], AbstractContextManager[PromptAnswer
|
|||||||
# switched to another window; without a hook, prompting behaves as before.
|
# switched to another window; without a hook, prompting behaves as before.
|
||||||
_wait_hook: PromptWaitHook | None = None
|
_wait_hook: PromptWaitHook | None = None
|
||||||
|
|
||||||
|
# Depth of the prompt_wait blocks currently entered (see prompt_wait).
|
||||||
|
_wait_depth: contextvars.ContextVar[int] = contextvars.ContextVar("prompt_wait_depth", default=0)
|
||||||
|
|
||||||
|
|
||||||
def set_prompt_wait_hook(hook: PromptWaitHook | None) -> None:
|
def set_prompt_wait_hook(hook: PromptWaitHook | None) -> None:
|
||||||
"""Install the hook run while a prompt blocks (None clears it)."""
|
"""Install the hook run while a prompt blocks (None clears it)."""
|
||||||
@@ -168,13 +172,22 @@ def get_prompt_wait_hook() -> PromptWaitHook | None:
|
|||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]:
|
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]:
|
||||||
"""Announce a blocking prompt to the host; yields its answer source (or None)."""
|
"""Announce a blocking prompt to the host; yields its answer source (or None).
|
||||||
|
|
||||||
|
Only the outermost prompt of a nested read is announced: a confirm that asks
|
||||||
|
a follow-up question while it is already on screen must not notify — and
|
||||||
|
interrupt — a second time.
|
||||||
|
"""
|
||||||
hook = _wait_hook
|
hook = _wait_hook
|
||||||
if hook is None:
|
if hook is None or _wait_depth.get() > 0:
|
||||||
yield None
|
yield None
|
||||||
return
|
return
|
||||||
|
token = _wait_depth.set(_wait_depth.get() + 1)
|
||||||
|
try:
|
||||||
with hook(PromptWait(kind, title, detail)) as source:
|
with hook(PromptWait(kind, title, detail)) as source:
|
||||||
yield source
|
yield source
|
||||||
|
finally:
|
||||||
|
_wait_depth.reset(token)
|
||||||
|
|
||||||
|
|
||||||
def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str:
|
def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,12 +16,18 @@ from plyngent.cli.limits import (
|
|||||||
reset_auto_continue_turn,
|
reset_auto_continue_turn,
|
||||||
set_auto_continue_default,
|
set_auto_continue_default,
|
||||||
)
|
)
|
||||||
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, get_prompt_output_hook, temporary_backend
|
from plyngent.prompting import (
|
||||||
|
NonInteractiveBackend,
|
||||||
|
get_prompt_backend,
|
||||||
|
get_prompt_output_hook,
|
||||||
|
get_prompt_wait_hook,
|
||||||
|
temporary_backend,
|
||||||
|
)
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
from tests.test_prompting import ScriptedBackend
|
from tests.test_prompting import ScriptedBackend
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Generator, Iterator
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -84,10 +91,11 @@ def test_prompt_continue_limit_noninteractive_denies() -> None:
|
|||||||
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
|
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
|
||||||
del clean_auto_continue
|
del clean_auto_continue
|
||||||
from plyngent.cli.app import _setup_hooks
|
from plyngent.cli.app import _setup_hooks
|
||||||
|
from plyngent.config.models import NotifyConfig
|
||||||
|
|
||||||
_setup_hooks(interactive=False, auto_continue=True)
|
_setup_hooks(interactive=False, auto_continue=True, notify=NotifyConfig())
|
||||||
assert PtyManager._limit_continue is not None
|
assert PtyManager._limit_continue is not None
|
||||||
_setup_hooks(interactive=False, auto_continue=False)
|
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
|
||||||
assert PtyManager._limit_continue is None
|
assert PtyManager._limit_continue is None
|
||||||
|
|
||||||
|
|
||||||
@@ -157,15 +165,134 @@ def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch)
|
|||||||
|
|
||||||
target = Path("/tmp")
|
target = Path("/tmp")
|
||||||
with temporary_backend(ScriptedBackend([])):
|
with temporary_backend(ScriptedBackend([])):
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "y\n")
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "y\n")
|
||||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
|
||||||
AccessMode.READ
|
AccessMode.READ
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "exec\n")
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "exec\n")
|
||||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
|
||||||
AccessMode.EXEC
|
AccessMode.EXEC
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
|
||||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
|
||||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
class _AnswerSource:
|
||||||
|
"""A host surface offering answers to a waiting prompt."""
|
||||||
|
|
||||||
|
supports_actions = True
|
||||||
|
|
||||||
|
def __init__(self, *answers: str) -> None:
|
||||||
|
self._answers = list(answers)
|
||||||
|
|
||||||
|
def take(self) -> str | None:
|
||||||
|
return self._answers.pop(0) if self._answers else None
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _answered(*answers: str) -> Generator[_AnswerSource]:
|
||||||
|
"""Install a wait hook whose prompt can be answered by the notification."""
|
||||||
|
from plyngent.prompting import set_prompt_wait_hook
|
||||||
|
|
||||||
|
source = _AnswerSource(*answers)
|
||||||
|
previous = get_prompt_wait_hook()
|
||||||
|
set_prompt_wait_hook(lambda _wait: contextlib.nullcontext(source))
|
||||||
|
try:
|
||||||
|
yield source
|
||||||
|
finally:
|
||||||
|
set_prompt_wait_hook(previous)
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_access_takes_a_notification_answer() -> None:
|
||||||
|
"""``allow`` from the notification approves the requested level."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.cli import limits
|
||||||
|
from plyngent.tools.access import AccessDecision
|
||||||
|
from plyngent.tools.workspace import AccessMode
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
||||||
|
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == AccessDecision(
|
||||||
|
AccessMode.READ
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_access_denied_by_the_notification() -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.cli import limits
|
||||||
|
from plyngent.tools.workspace import AccessMode
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("deny"):
|
||||||
|
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_confirm_takes_a_notification_answer() -> None:
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
||||||
|
assert prompt_confirm_tool("run_argv", {}, "runs a command") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_confirm_denied_by_the_notification() -> None:
|
||||||
|
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
|
||||||
|
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_confirm_takes_a_notification_answer() -> None:
|
||||||
|
from plyngent.agent import ToolConfirmRequest
|
||||||
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||||
|
|
||||||
|
requests = [
|
||||||
|
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
||||||
|
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
||||||
|
]
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
||||||
|
assert _prompt_confirm_batch_sync(requests).allowed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_confirm_denied_by_the_notification() -> None:
|
||||||
|
from plyngent.agent import ToolConfirmRequest
|
||||||
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||||
|
|
||||||
|
requests = [
|
||||||
|
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
||||||
|
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
||||||
|
]
|
||||||
|
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
|
||||||
|
assert _prompt_confirm_batch_sync(requests).allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_confirm_announces_itself() -> None:
|
||||||
|
"""Each blocking confirm reports its kind, so a host can pick a surface."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.agent import ToolConfirmRequest
|
||||||
|
from plyngent.cli import limits
|
||||||
|
from plyngent.tools.workspace import AccessMode
|
||||||
|
|
||||||
|
kinds: list[str] = []
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def hook(wait: object) -> Generator[None]:
|
||||||
|
kinds.append(wait.kind) # type: ignore[attr-defined]
|
||||||
|
yield None
|
||||||
|
|
||||||
|
from plyngent.prompting import set_prompt_wait_hook
|
||||||
|
|
||||||
|
previous = get_prompt_wait_hook()
|
||||||
|
set_prompt_wait_hook(hook)
|
||||||
|
try:
|
||||||
|
with temporary_backend(ScriptedBackend(["y", "y", "y"], confirms=[True])):
|
||||||
|
_ = prompt_confirm_tool("run_argv", {}, "reason")
|
||||||
|
_ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0)
|
||||||
|
_ = limits._prompt_confirm_batch_sync(
|
||||||
|
[
|
||||||
|
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
||||||
|
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_ = prompt_continue_limit("limit")
|
||||||
|
finally:
|
||||||
|
set_prompt_wait_hook(previous)
|
||||||
|
assert kinds == ["confirm", "access", "batch", "limit"]
|
||||||
|
|||||||
@@ -313,13 +313,13 @@ def test_prompt_skill_write_confirm_answers(monkeypatch: pytest.MonkeyPatch) ->
|
|||||||
|
|
||||||
target = Path("/skills/acme")
|
target = Path("/skills/acme")
|
||||||
with temporary_backend(ScriptedBackend([])):
|
with temporary_backend(ScriptedBackend([])):
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "o\n")
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "o\n")
|
||||||
once = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
once = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "s\n")
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "s\n")
|
||||||
session = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
session = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
|
||||||
denied = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
denied = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
|
||||||
timed_out = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
timed_out = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
assert (once.allow, once.session) == (True, False)
|
assert (once.allow, once.session) == (True, False)
|
||||||
assert (session.allow, session.session) == (True, True)
|
assert (session.allow, session.session) == (True, True)
|
||||||
|
|||||||
Reference in New Issue
Block a user