From cb35e3b105ea97de86e753da0835d1ea7924ceb2 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 19 Sep 2026 09:44:43 +0800 Subject: [PATCH] tools/chat: shape-check the wait duration and reason --- src/plyngent/tools/chat/shape.py | 18 +++++++++ src/plyngent/tools/chat/wait.py | 41 +++++++++++++------- tests/test_tools/test_chat_tools.py | 59 +++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/plyngent/tools/chat/shape.py b/src/plyngent/tools/chat/shape.py index b3b4d52..9955708 100644 --- a/src/plyngent/tools/chat/shape.py +++ b/src/plyngent/tools/chat/shape.py @@ -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}" diff --git a/src/plyngent/tools/chat/wait.py b/src/plyngent/tools/chat/wait.py index 14be151..bf29b35 100644 --- a/src/plyngent/tools/chat/wait.py +++ b/src/plyngent/tools/chat/wait.py @@ -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) diff --git a/tests/test_tools/test_chat_tools.py b/tests/test_tools/test_chat_tools.py index 0c15651..9fe8ad3 100644 --- a/tests/test_tools/test_chat_tools.py +++ b/tests/test_tools/test_chat_tools.py @@ -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.")