mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/cli: prompt for a round's tool calls once
prompt_confirm_batch_async answers a ToolBatchConfirmHook with one box that numbers every call the round wants to make (continuation lines stay under their entry). The prompt is the plain ``Allow these N tool calls?`` confirm: a deny offers the same one comment for all of them, a non-interactive host denies, and the next round asks again. The ReplState registry installs the hook next to the per-call one, and format_tool_confirm_box now shares the box drawing with the batch header.
This commit is contained in:
@@ -7,6 +7,7 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
|
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
|
||||||
from plyngent.cli.display import close_open_pretty_line
|
from plyngent.cli.display import close_open_pretty_line
|
||||||
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
|
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
|
||||||
from plyngent.prompting import (
|
from plyngent.prompting import (
|
||||||
@@ -40,6 +41,11 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
|
|||||||
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
||||||
_CONTINUE_AUTO_TOKEN = "yyy"
|
_CONTINUE_AUTO_TOKEN = "yyy"
|
||||||
|
|
||||||
|
# Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
|
||||||
|
# at a time through the per-call prompt.
|
||||||
|
_BATCH_YES_TOKENS = frozenset({"y", "yes"})
|
||||||
|
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"})
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _AutoContinue:
|
class _AutoContinue:
|
||||||
@@ -105,18 +111,13 @@ def _wrap_line(text: str, width: int) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def format_tool_confirm_box(name: str, reason: str) -> str:
|
def _box(header: str, lines: Sequence[str]) -> str:
|
||||||
"""Multi-line boxed confirm body (header + reason lines).
|
"""Rounded box: a header row, a rule, then the wrapped body lines."""
|
||||||
|
|
||||||
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
|
|
||||||
so terminals keep newlines (unlike a single jammed readline line).
|
|
||||||
"""
|
|
||||||
term = _terminal_width()
|
term = _terminal_width()
|
||||||
inner = max(20, term - 4) # room for ``│ `` + `` │``
|
inner = max(20, term - 4) # room for ``│ `` + `` │``
|
||||||
header = f"confirm · tool {name!r}"
|
|
||||||
body_lines: list[str] = []
|
body_lines: list[str] = []
|
||||||
for raw in reason.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
for raw in lines:
|
||||||
body_lines.extend(_wrap_line(raw, inner - _BOX_PAD))
|
body_lines.extend(_wrap_line(raw.replace("\r\n", "\n").replace("\r", "\n"), inner - _BOX_PAD))
|
||||||
content = [header, "─" * min(inner, max(len(header), 12)), *body_lines]
|
content = [header, "─" * min(inner, max(len(header), 12)), *body_lines]
|
||||||
width = min(inner, max(len(line) for line in content) + _BOX_PAD)
|
width = min(inner, max(len(line) for line in content) + _BOX_PAD)
|
||||||
width = max(width, min(inner, len(header) + _BOX_PAD))
|
width = max(width, min(inner, len(header) + _BOX_PAD))
|
||||||
@@ -134,6 +135,15 @@ def format_tool_confirm_box(name: str, reason: str) -> str:
|
|||||||
return "\n".join(rows)
|
return "\n".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def format_tool_confirm_box(name: str, reason: str) -> str:
|
||||||
|
"""Multi-line boxed confirm body for one tool call.
|
||||||
|
|
||||||
|
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
|
||||||
|
so terminals keep newlines (unlike a single jammed readline line).
|
||||||
|
"""
|
||||||
|
return _box(f"confirm · tool {name!r}", reason.split("\n"))
|
||||||
|
|
||||||
|
|
||||||
def _echo_tool_confirm(name: str, reason: str) -> None:
|
def _echo_tool_confirm(name: str, reason: str) -> None:
|
||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
backend.echo()
|
backend.echo()
|
||||||
@@ -482,6 +492,65 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
|
|||||||
return comment or False
|
return comment or False
|
||||||
|
|
||||||
|
|
||||||
|
async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||||
|
"""One confirm for one round's soft-confirm requests (``y`` / ``N`` / ``m``).
|
||||||
|
|
||||||
|
The answer covers exactly those calls — the next round asks again; ``m``
|
||||||
|
asks them one at a time instead, and a deny carries one comment shared by
|
||||||
|
every one of them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from plyngent.prompting import run_prompt_async
|
||||||
|
|
||||||
|
return await run_prompt_async(lambda: _prompt_confirm_batch_sync(requests))
|
||||||
|
except NonInteractiveError:
|
||||||
|
return BatchConfirmDecision(allowed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||||
|
backend = get_prompt_backend()
|
||||||
|
if not backend.is_interactive():
|
||||||
|
# Non-interactive: deny, like the default of the single prompt.
|
||||||
|
return BatchConfirmDecision(allowed=False)
|
||||||
|
backend.echo()
|
||||||
|
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
||||||
|
backend.secho("m = review them one call at a time", fg="bright_black")
|
||||||
|
backend.echo()
|
||||||
|
try:
|
||||||
|
token = (
|
||||||
|
backend.read_line(
|
||||||
|
f"Allow these {len(requests)} tool calls? [y/N/m]:",
|
||||||
|
default="n",
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
except NonInteractiveError:
|
||||||
|
return BatchConfirmDecision(allowed=False)
|
||||||
|
if token in _BATCH_MANUAL_TOKENS:
|
||||||
|
return BatchConfirmDecision(allowed=False, each=True)
|
||||||
|
if token in _BATCH_YES_TOKENS:
|
||||||
|
return BatchConfirmDecision(allowed=True)
|
||||||
|
try:
|
||||||
|
comment = ask(
|
||||||
|
"Optional comment for the agent (why denied; empty to skip):",
|
||||||
|
default="",
|
||||||
|
).strip()
|
||||||
|
except NonInteractiveError:
|
||||||
|
comment = ""
|
||||||
|
return BatchConfirmDecision(allowed=False, comment=comment or None)
|
||||||
|
|
||||||
|
|
||||||
|
def format_batch_confirm_box(requests: Sequence[ToolConfirmRequest]) -> str:
|
||||||
|
"""Boxed body listing every call a round wants to make, one numbered block each."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for index, request in enumerate(requests, start=1):
|
||||||
|
reason_lines = request.reason.split("\n")
|
||||||
|
lines.append(f"{index}. [{request.name}] {reason_lines[0]}")
|
||||||
|
lines.extend(f" {line}" for line in reason_lines[1:])
|
||||||
|
return _box(f"confirm · {len(requests)} tool calls", lines)
|
||||||
|
|
||||||
|
|
||||||
def _prompt_workspace_mismatch_sync(
|
def _prompt_workspace_mismatch_sync(
|
||||||
session_id: int,
|
session_id: int,
|
||||||
session_workspace: str,
|
session_workspace: str,
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ class ReplState:
|
|||||||
def _tool_registry(self) -> ToolRegistry | None:
|
def _tool_registry(self) -> ToolRegistry | None:
|
||||||
if not self.tools_enabled:
|
if not self.tools_enabled:
|
||||||
return None
|
return None
|
||||||
from plyngent.cli.limits import prompt_confirm_tool_async
|
from plyngent.cli.limits import prompt_confirm_batch_async, prompt_confirm_tool_async
|
||||||
from plyngent.tools.catalog import register_builtin_tools
|
from plyngent.tools.catalog import register_builtin_tools
|
||||||
from plyngent.tools.danger import classify_danger
|
from plyngent.tools.danger import classify_danger
|
||||||
from plyngent.tools.plugins import load_plugin_tools
|
from plyngent.tools.plugins import load_plugin_tools
|
||||||
@@ -375,6 +375,7 @@ class ReplState:
|
|||||||
tools,
|
tools,
|
||||||
danger=classify_danger,
|
danger=classify_danger,
|
||||||
on_confirm=prompt_confirm_tool_async,
|
on_confirm=prompt_confirm_tool_async,
|
||||||
|
on_confirm_batch=prompt_confirm_batch_async,
|
||||||
yolo=yolo,
|
yolo=yolo,
|
||||||
auto_bind_state=True,
|
auto_bind_state=True,
|
||||||
instance_state=self.instance_state,
|
instance_state=self.instance_state,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Aggregated soft-confirm prompt: one box for a round's tool calls."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
|
||||||
|
from plyngent.cli.limits import (
|
||||||
|
_prompt_confirm_batch_sync,
|
||||||
|
format_batch_confirm_box,
|
||||||
|
format_tool_confirm_box,
|
||||||
|
prompt_confirm_batch_async,
|
||||||
|
)
|
||||||
|
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||||
|
from tests.test_prompting import ScriptedBackend
|
||||||
|
|
||||||
|
|
||||||
|
def _requests() -> list[ToolConfirmRequest]:
|
||||||
|
return [
|
||||||
|
ToolConfirmRequest(name="delete_path", args={"path": "a.txt"}, reason="delete path 'a.txt'"),
|
||||||
|
ToolConfirmRequest(name="move_path", args={"src": "b", "dst": "c"}, reason="move 'b' → 'c'"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_box_numbers_every_call() -> None:
|
||||||
|
box = format_batch_confirm_box(_requests())
|
||||||
|
lines = box.splitlines()
|
||||||
|
assert "confirm · 2 tool calls" in lines[1]
|
||||||
|
assert any("[delete_path] delete path 'a.txt'" in line for line in lines)
|
||||||
|
assert any("[move_path] move 'b' → 'c'" in line for line in lines)
|
||||||
|
# Numbering keeps the listed order visible when the user answers.
|
||||||
|
assert any(line.startswith("│ 1. ") for line in lines)
|
||||||
|
assert any(line.startswith("│ 2. ") for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_box_indents_continuation_lines() -> None:
|
||||||
|
box = format_batch_confirm_box(
|
||||||
|
[
|
||||||
|
ToolConfirmRequest(name="run_argv", args={"argv": ["python", "-c", "x"]}, reason="argv\n body"),
|
||||||
|
ToolConfirmRequest(name="delete_path", args={"path": "a"}, reason="delete path 'a'"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert any(line.startswith("│ ") and "body" in line for line in box.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_call_box_is_unchanged() -> None:
|
||||||
|
box = format_tool_confirm_box("delete_path", "delete path 'a.txt'")
|
||||||
|
assert "confirm · tool 'delete_path'" in box.splitlines()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_prompt_allows_every_call_on_yes() -> None:
|
||||||
|
with temporary_backend(ScriptedBackend(["y"])):
|
||||||
|
decision = _prompt_confirm_batch_sync(_requests())
|
||||||
|
assert decision == BatchConfirmDecision(allowed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_prompt_hands_the_round_to_the_per_call_prompt_on_manual() -> None:
|
||||||
|
with temporary_backend(ScriptedBackend(["m"])):
|
||||||
|
decision = _prompt_confirm_batch_sync(_requests())
|
||||||
|
assert decision.each is True
|
||||||
|
assert decision.allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_prompt_denies_with_a_comment() -> None:
|
||||||
|
with temporary_backend(ScriptedBackend(["", "keep those files"])):
|
||||||
|
decision = _prompt_confirm_batch_sync(_requests())
|
||||||
|
assert decision.allowed is False
|
||||||
|
assert decision.comment == "keep those files"
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_prompt_defaults_to_deny() -> None:
|
||||||
|
with temporary_backend(ScriptedBackend([])):
|
||||||
|
decision = _prompt_confirm_batch_sync(_requests())
|
||||||
|
assert decision == BatchConfirmDecision(allowed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_prompt_denies_when_not_interactive() -> None:
|
||||||
|
with temporary_backend(NonInteractiveBackend()):
|
||||||
|
decision = _prompt_confirm_batch_sync(_requests())
|
||||||
|
assert decision == BatchConfirmDecision(allowed=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_batch_prompt_async_allows() -> None:
|
||||||
|
with temporary_backend(ScriptedBackend(["yes"])):
|
||||||
|
decision = await prompt_confirm_batch_async(_requests())
|
||||||
|
assert decision == BatchConfirmDecision(allowed=True)
|
||||||
Reference in New Issue
Block a user