tools/chat: shape-check the wait duration and reason

This commit is contained in:
2026-09-19 09:44:43 +08:00
parent 9224241841
commit cb35e3b105
3 changed files with 104 additions and 14 deletions
+18
View File
@@ -54,3 +54,21 @@ def list_error(tool: str, field: str, value: object, *, items: str, example: str
if not isinstance(value, list):
return f"`{tool}` expects `{field}` as a JSON array of {items}, got {shape_of(value)}; pass e.g. {example}"
return None
def number_arg(tool: str, field: str, value: object, *, example: str) -> tuple[float | None, str | None]:
"""Coerce *value* to a float, accepting numeric strings; else ``(None, error)``.
Models often send numbers as strings, which is accepted here. Booleans are
not numbers for this purpose, and neither is non-numeric text.
"""
if isinstance(value, bool):
return None, f"`{tool}` expects `{field}` as a number, got a boolean; pass e.g. {example}"
if isinstance(value, (int, float)):
return float(value), None
if isinstance(value, str):
try:
return float(value.strip()), None
except ValueError:
return None, f"`{tool}` expects `{field}` as a number, got a non-numeric string; pass e.g. {example}"
return None, f"`{tool}` expects `{field}` as a number, got {shape_of(value)}; pass e.g. {example}"
+27 -14
View File
@@ -8,11 +8,12 @@ from click import style
from plyngent.agent import ToolTag, tool
from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async
from plyngent.tools.chat.shape import first_error, number_arg, string_error
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
def _wait_prompt(duration: int, *, reason: str | None) -> str:
def _wait_prompt(duration: float, *, reason: str | None) -> str:
"""Two-line wait prompt: status line, then the optional-reason input prompt.
``read_line_with_timeout`` writes the prompt verbatim to stdout (no click
@@ -20,7 +21,7 @@ def _wait_prompt(duration: int, *, reason: str | None) -> str:
is not a TTY. On a terminal: cyan status, dimmed detail, yellow input
prompt; plain text otherwise (tests, redirected output).
"""
status = style(f"Waiting for {duration}s", fg="cyan")
status = style(f"Waiting for {duration:g}s", fg="cyan")
if reason:
status += style(f" ({reason})", fg="bright_black")
status += style(". Press Enter to disturb.", fg="bright_black")
@@ -31,6 +32,19 @@ def _wait_prompt(duration: int, *, reason: str | None) -> str:
return rendered
async def _interactive_wait(seconds: float, reason: str | None) -> str:
"""Show the two-line wait prompt and report how the wait ended."""
label = f"{seconds:g}"
prompt = _wait_prompt(seconds, reason=reason)
line = await run_prompt_async(read_line_with_timeout, prompt, seconds)
if line is None:
return f"waited {label}s"
text = line.strip()
if text:
return f"disturbed by user: {text}"
return "disturbed by user (no reason)"
@tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def wait(duration: int, *, reason: str | None = None) -> str:
"""Wait ``duration`` seconds before continuing.
@@ -40,19 +54,18 @@ async def wait(duration: int, *, reason: str | None = None) -> str:
"disturbs" the wait so the turn continues immediately. Non-interactive runs
simply sleep the full duration.
"""
if duration < 0:
seconds, duration_error = number_arg("wait", "duration", duration, example="5")
reason_error = None if reason is None else string_error("wait", "reason", reason)
shape_error = first_error(duration_error, reason_error)
if shape_error is not None:
return f"error: {shape_error}"
assert seconds is not None # number_arg returns a value whenever it accepts
if seconds < 0:
return "error: duration must not be negative"
if duration == 0:
if seconds == 0:
return "waited 0s"
backend = get_prompt_backend()
if not backend.is_interactive():
await asyncio.sleep(duration)
return f"waited {duration}s"
prompt = _wait_prompt(duration, reason=reason)
line = await run_prompt_async(read_line_with_timeout, prompt, float(duration))
if line is None:
return f"waited {duration}s"
text = line.strip()
if text:
return f"disturbed by user: {text}"
return "disturbed by user (no reason)"
await asyncio.sleep(seconds)
return f"waited {seconds:g}s"
return await _interactive_wait(seconds, reason)