mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
tools/chat: shape-check the wait duration and reason
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -274,3 +274,62 @@ async def test_form_user_rejects_non_object_field() -> None:
|
||||
json.dumps({"title": "Setup", "fields": ["user"]}),
|
||||
)
|
||||
assert out == "error: each field must be an object with name/prompt"
|
||||
|
||||
|
||||
async def test_wait_accepts_numeric_string_duration(monkeypatch) -> None:
|
||||
"""A model sending "5" instead of 5 still waits."""
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
|
||||
monkeypatch.setattr(wait_module.asyncio, "sleep", fake_sleep)
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", json.dumps({"duration": "5"}))
|
||||
assert out == "waited 5s"
|
||||
assert slept == [5.0]
|
||||
|
||||
|
||||
async def test_wait_accepts_fractional_duration(monkeypatch) -> None:
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
|
||||
monkeypatch.setattr(wait_module.asyncio, "sleep", fake_sleep)
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", json.dumps({"duration": 2.5}))
|
||||
assert out == "waited 2.5s"
|
||||
assert slept == [2.5]
|
||||
|
||||
|
||||
async def test_wait_rejects_bad_duration_shapes() -> None:
|
||||
cases: list[tuple[object, str]] = [
|
||||
("soon", "non-numeric string"),
|
||||
([5], "an array"),
|
||||
({"seconds": 5}, "an object"),
|
||||
(True, "a boolean"),
|
||||
]
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
for value, expected in cases:
|
||||
out = await registry.execute("wait", json.dumps({"duration": value}))
|
||||
assert out.startswith("error:"), f"{value!r} was accepted: {out!r}"
|
||||
assert "`duration`" in out
|
||||
assert expected in out, f"{value!r}: {out!r}"
|
||||
|
||||
|
||||
async def test_wait_rejects_non_string_reason() -> None:
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", json.dumps({"duration": 0, "reason": ["why"]}))
|
||||
assert out.startswith("error:")
|
||||
assert "`reason`" in out
|
||||
|
||||
|
||||
def test_wait_prompt_renders_fractional_seconds() -> None:
|
||||
prompt = wait_module._wait_prompt(2.5, reason=None)
|
||||
assert prompt.startswith("Waiting for 2.5s.")
|
||||
assert wait_module._wait_prompt(5, reason=None).startswith("Waiting for 5s.")
|
||||
|
||||
Reference in New Issue
Block a user