core/tools: add wait tool (Enter to disturb)

This commit is contained in:
2026-08-11 14:46:58 +08:00
parent fe7b75aad8
commit d3a5200e0f
6 changed files with 105 additions and 3 deletions
@@ -23,6 +23,7 @@ use `run_argv_batch` for ordered pipelines.
`read_file(with_lineno=true)`, and line numbers reset after any file write. `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). - 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. - 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. - Todo stack: open items mean unfinished work.
""" """
+1
View File
@@ -49,6 +49,7 @@ otherwise a small default is used. Never rely on shell to spoof identity.
### Humans & safety ### Humans & safety
- Prefer `ask_user_line` / `ask_user_choice` / `ask_user_form` over waiting for the next free-form turn. - 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. - Overwrites, deletes, shells, and risky ops may require human confirm; hard denylists are not skipped by YOLO.
- Temporary scratch: `new_temporary_workspace`. - Temporary scratch: `new_temporary_workspace`.
+1
View File
@@ -8,6 +8,7 @@ from .chat import CHAT_TOOLS as CHAT_TOOLS
from .chat import ask_user as ask_user from .chat import ask_user as ask_user
from .chat import choose_user as choose_user from .chat import choose_user as choose_user
from .chat import form_user as form_user from .chat import form_user as form_user
from .chat import wait as wait
from .context import InstanceState as InstanceState from .context import InstanceState as InstanceState
from .context import SessionState as SessionState from .context import SessionState as SessionState
from .context import bind_instance as bind_instance from .context import bind_instance as bind_instance
+2 -1
View File
@@ -1,5 +1,6 @@
from .ask import ask_user as ask_user from .ask import ask_user as ask_user
from .choose import choose_user as choose_user from .choose import choose_user as choose_user
from .form import form_user as form_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]
+39
View File
@@ -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)"
+61 -2
View File
@@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
import importlib
import json import json
from plyngent.agent import ToolRegistry from plyngent.agent import ToolRegistry
from plyngent.prompting import NonInteractiveBackend, temporary_backend 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 from tests.test_prompting import ScriptedBackend
wait_module = importlib.import_module("plyngent.tools.chat.wait")
async def test_ask_user_tool() -> None: async def test_ask_user_tool() -> None:
backend = ScriptedBackend(["42"]) backend = ScriptedBackend(["42"])
@@ -77,7 +80,7 @@ async def test_form_user_tool() -> None:
async def test_chat_tools_in_default_list() -> None: async def test_chat_tools_in_default_list() -> None:
names = {t.name for t in CHAT_TOOLS} 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: 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]) registry = ToolRegistry([ask_user])
out = await registry.execute("ask_user_line", '{"question": "hi"}') out = await registry.execute("ask_user_line", '{"question": "hi"}')
assert out.startswith("error:") 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"