diff --git a/src/plyngent/agent/directive_checkpoint.py b/src/plyngent/agent/directive_checkpoint.py index 047e5a9..22c5e77 100644 --- a/src/plyngent/agent/directive_checkpoint.py +++ b/src/plyngent/agent/directive_checkpoint.py @@ -23,6 +23,7 @@ use `run_argv_batch` for ordered pipelines. `read_file(with_lineno=true)`, and line numbers reset after any file write. - Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO). - PTY secrets only via `ask_into_pty`; denylists and confirms still apply. +- `wait` pauses; pressing Enter disturbs early (optional reason). - Todo stack: open items mean unfinished work. """ diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index a8aa937..ced3f89 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -49,6 +49,7 @@ otherwise a small default is used. Never rely on shell to spoof identity. ### Humans & safety - Prefer `ask_user_line` / `ask_user_choice` / `ask_user_form` over waiting for the next free-form turn. +- Use `wait` to pause; pressing Enter during the wait disturbs it (reason optional; turn continues immediately). - Overwrites, deletes, shells, and risky ops may require human confirm; hard denylists are not skipped by YOLO. - Temporary scratch: `new_temporary_workspace`. diff --git a/src/plyngent/tools/__init__.py b/src/plyngent/tools/__init__.py index a5ef65c..b5f8a07 100644 --- a/src/plyngent/tools/__init__.py +++ b/src/plyngent/tools/__init__.py @@ -8,6 +8,7 @@ from .chat import CHAT_TOOLS as CHAT_TOOLS from .chat import ask_user as ask_user from .chat import choose_user as choose_user from .chat import form_user as form_user +from .chat import wait as wait from .context import InstanceState as InstanceState from .context import SessionState as SessionState from .context import bind_instance as bind_instance diff --git a/src/plyngent/tools/chat/__init__.py b/src/plyngent/tools/chat/__init__.py index fdcaba2..5b47cfb 100644 --- a/src/plyngent/tools/chat/__init__.py +++ b/src/plyngent/tools/chat/__init__.py @@ -1,5 +1,6 @@ from .ask import ask_user as ask_user from .choose import choose_user as choose_user from .form import form_user as form_user +from .wait import wait as wait -CHAT_TOOLS = [ask_user, choose_user, form_user] +CHAT_TOOLS = [ask_user, choose_user, form_user, wait] diff --git a/src/plyngent/tools/chat/wait.py b/src/plyngent/tools/chat/wait.py new file mode 100644 index 0000000..3d17c2a --- /dev/null +++ b/src/plyngent/tools/chat/wait.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import asyncio + +from plyngent.agent import ToolTag, tool +from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async + +WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. " + + +@tool(name="wait", tags=ToolTag.LOCAL) +async def wait(duration: int, *, reason: str | None = None) -> str: + """Wait ``duration`` seconds before continuing. + + Interactive sessions show a line prompt: pressing Enter (optionally after + typing a reason) "disturbs" the wait so the turn continues immediately. + Non-interactive runs simply sleep the full duration. + """ + if duration < 0: + return "error: duration must not be negative" + if duration == 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.format(duration=duration) + if reason: + prompt = ( + f"Waiting for {duration}s (reason: {reason}). Press Enter to disturb. " + "Feel free to ship any reason before pressing Enter. " + ) + 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)" diff --git a/tests/test_tools/test_chat_tools.py b/tests/test_tools/test_chat_tools.py index 8255c68..071b4ba 100644 --- a/tests/test_tools/test_chat_tools.py +++ b/tests/test_tools/test_chat_tools.py @@ -1,12 +1,15 @@ from __future__ import annotations +import importlib import json from plyngent.agent import ToolRegistry from plyngent.prompting import NonInteractiveBackend, temporary_backend -from plyngent.tools.chat import CHAT_TOOLS, ask_user, choose_user, form_user +from plyngent.tools.chat import CHAT_TOOLS, ask_user, choose_user, form_user, wait from tests.test_prompting import ScriptedBackend +wait_module = importlib.import_module("plyngent.tools.chat.wait") + async def test_ask_user_tool() -> None: backend = ScriptedBackend(["42"]) @@ -77,7 +80,7 @@ async def test_form_user_tool() -> None: async def test_chat_tools_in_default_list() -> None: names = {t.name for t in CHAT_TOOLS} - assert names == {"ask_user_line", "ask_user_choice", "ask_user_form"} + assert names == {"ask_user_line", "ask_user_choice", "ask_user_form", "wait"} async def test_ask_user_non_interactive_error() -> None: @@ -85,3 +88,59 @@ async def test_ask_user_non_interactive_error() -> None: registry = ToolRegistry([ask_user]) out = await registry.execute("ask_user_line", '{"question": "hi"}') assert out.startswith("error:") + + +async def test_wait_tool_zero_seconds(monkeypatch) -> None: + with temporary_backend(NonInteractiveBackend()): + registry = ToolRegistry([wait]) + out = await registry.execute("wait", '{"duration": 0}') + assert out == "waited 0s" + + +async def test_wait_tool_negative_duration() -> None: + with temporary_backend(NonInteractiveBackend()): + registry = ToolRegistry([wait]) + out = await registry.execute("wait", '{"duration": -1}') + assert out.startswith("error:") + + +async def test_wait_tool_noninteractive_sleeps(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", '{"duration": 5}') + assert out == "waited 5s" + assert slept == [5] + + +async def test_wait_tool_disturbed_with_reason(monkeypatch) -> None: + monkeypatch.setattr( + wait_module, + "read_line_with_timeout", + lambda prompt, timeout: "tests are slow", + ) + with temporary_backend(ScriptedBackend([])): + registry = ToolRegistry([wait]) + out = await registry.execute("wait", '{"duration": 3}') + assert out == "disturbed by user: tests are slow" + + +async def test_wait_tool_disturbed_empty_enter(monkeypatch) -> None: + monkeypatch.setattr(wait_module, "read_line_with_timeout", lambda prompt, timeout: "") + with temporary_backend(ScriptedBackend([])): + registry = ToolRegistry([wait]) + out = await registry.execute("wait", '{"duration": 3}') + assert out == "disturbed by user (no reason)" + + +async def test_wait_tool_times_out(monkeypatch) -> None: + monkeypatch.setattr(wait_module, "read_line_with_timeout", lambda prompt, timeout: None) + with temporary_backend(ScriptedBackend([])): + registry = ToolRegistry([wait]) + out = await registry.execute("wait", '{"duration": 3}') + assert out == "waited 3s"