fix(agent): wait for exec sessions without polling (#5526)

This commit is contained in:
chengyongru
2026-08-25 14:46:02 +08:00
committed by GitHub
parent e723ea6b7e
commit 5cf78540a4
13 changed files with 576 additions and 317 deletions
+95 -101
View File
@@ -22,7 +22,8 @@ from nanobot.agent.tools.schema import (
DEFAULT_YIELD_MS = 1000 DEFAULT_YIELD_MS = 1000
MAX_YIELD_MS = 30_000 MAX_YIELD_MS = 30_000
DEFAULT_WAIT_FOR_MS = 10_000 DEFAULT_WAIT_FOR_MS = 10_000
MAX_WAIT_FOR_MS = 120_000 DEFAULT_UNTIL_EXIT_MS = 600_000
MAX_WAIT_FOR_MS = 600_000
DEFAULT_MAX_OUTPUT_CHARS = 10_000 DEFAULT_MAX_OUTPUT_CHARS = 10_000
MAX_OUTPUT_CHARS = 50_000 MAX_OUTPUT_CHARS = 50_000
OUTPUT_DRAIN_GRACE_S = 0.1 OUTPUT_DRAIN_GRACE_S = 0.1
@@ -495,51 +496,39 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."), session_id=StringSchema("Session ID returned by exec."),
chars=StringSchema( input=StringSchema(
"Bytes/text to write to stdin. Omit or pass an empty string to only poll recent output.", "Text to send to stdin; omit to poll output.",
nullable=True, nullable=True,
), ),
close_stdin=BooleanSchema( close_stdin=BooleanSchema(
description="Close stdin after writing chars. Useful for commands waiting for EOF.", description="Close stdin after sending input.",
default=False, default=False,
), ),
terminate=BooleanSchema( terminate=BooleanSchema(
description="Terminate the running exec session.", description="Terminate the session; use alone.",
default=False, default=False,
), ),
yield_time_ms=IntegerSchema(
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
minimum=0,
maximum=MAX_YIELD_MS,
),
wait_for=StringSchema( wait_for=StringSchema(
"Optional text to wait for in output before returning. " "Return when this text appears in output.",
"Useful for interactive commands and dev servers.", min_length=1,
nullable=True, nullable=True,
), ),
wait_timeout_ms=IntegerSchema( until_exit=BooleanSchema(
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).", description="Wait for the process to exit.",
default=False,
),
timeout_ms=IntegerSchema(
description="Maximum wait: 1s normally, 10s for wait_for, 10m for until_exit.",
minimum=0, minimum=0,
maximum=MAX_WAIT_FOR_MS, maximum=MAX_WAIT_FOR_MS,
nullable=True, nullable=True,
), ),
max_output_chars=IntegerSchema(
description="Maximum output characters to return from this poll (default 10000, max 50000).",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
),
max_output_tokens=IntegerSchema(
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
required=["session_id"], required=["session_id"],
) )
) )
class WriteStdinTool(Tool): class ExecSessionTool(Tool):
"""Write to or poll a running exec session.""" """Interact with or wait for a running exec session."""
_scopes = {"core", "subagent"} _scopes = {"core", "subagent"}
config_key = "exec" config_key = "exec"
@@ -571,98 +560,103 @@ class WriteStdinTool(Tool):
@property @property
def name(self) -> str: def name(self) -> str:
return "write_stdin" return "exec_session"
@property @property
def description(self) -> str: def description(self) -> str:
return ( return "Manage a session returned by exec."
"Interact with a running exec session created by exec with "
"yield_time_ms. Use chars='' to poll without writing, chars to send "
"stdin, close_stdin=true to send EOF, or terminate=true to stop the "
"process. Use wait_for with wait_timeout_ms for dev servers, test "
"watchers, and prompts where you need to wait for expected output. "
"Do not use this to start new commands; start them with exec."
)
async def execute( # pyright: ignore[reportIncompatibleMethodOverride] async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
self, self,
session_id: str, session_id: str,
chars: str | None = None, input: str | None = None,
close_stdin: bool = False, close_stdin: bool = False,
terminate: bool = False, terminate: bool = False,
yield_time_ms: int | None = None,
wait_for: str | None = None, wait_for: str | None = None,
wait_timeout_ms: int | None = None, until_exit: bool = False,
max_output_chars: int | None = None, timeout_ms: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
try: try:
if max_output_chars is None: if wait_for == "":
max_output_chars = max_output_tokens return ToolResult.error("Error: wait_for must not be empty.")
output_limit = clamp_session_int( if wait_for is not None and until_exit:
max_output_chars, return ToolResult.error(
DEFAULT_MAX_OUTPUT_CHARS, "Error: wait_for and until_exit are mutually exclusive."
1000,
MAX_OUTPUT_CHARS,
)
if wait_for:
return await self._wait_for_output(
session_id=session_id,
chars=chars,
close_stdin=close_stdin,
terminate=terminate,
wait_for=wait_for,
wait_timeout_ms=clamp_session_int(
wait_timeout_ms,
DEFAULT_WAIT_FOR_MS,
0,
MAX_WAIT_FOR_MS,
),
max_output_chars=output_limit,
) )
poll = await self._manager.write( if terminate:
if any(
(
input is not None,
close_stdin,
wait_for is not None,
until_exit,
timeout_ms is not None,
)
):
return ToolResult.error("Error: terminate must be used alone.")
poll = await self._manager.write(
session_id=session_id,
chars=None,
close_stdin=False,
terminate=True,
yield_time_ms=0,
max_output_chars=DEFAULT_MAX_OUTPUT_CHARS,
owner_session_key=current_request_session_key(),
)
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
default_timeout_ms = (
DEFAULT_UNTIL_EXIT_MS
if until_exit
else DEFAULT_WAIT_FOR_MS
if wait_for is not None
else DEFAULT_YIELD_MS
)
return await self._wait(
session_id=session_id, session_id=session_id,
chars=chars, input=input,
close_stdin=close_stdin, close_stdin=close_stdin,
terminate=terminate, wait_for=wait_for,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS), until_exit=until_exit,
max_output_chars=output_limit, timeout_ms=clamp_session_int(
owner_session_key=current_request_session_key(), timeout_ms,
default_timeout_ms,
0,
MAX_WAIT_FOR_MS,
),
) )
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
except KeyError: except KeyError:
return ToolResult.error(f"Error: exec session not found: {session_id!r}") return ToolResult.error(f"Error: exec session not found: {session_id!r}")
except Exception as exc: except Exception as exc:
return ToolResult.error(f"Error writing to exec session: {exc}") return ToolResult.error(f"Error managing exec session: {exc}")
async def _wait_for_output( async def _wait(
self, self,
*, *,
session_id: str, session_id: str,
chars: str | None, input: str | None,
close_stdin: bool, close_stdin: bool,
terminate: bool, wait_for: str | None,
wait_for: str, until_exit: bool,
wait_timeout_ms: int, timeout_ms: int,
max_output_chars: int,
) -> str: ) -> str:
deadline = time.monotonic() + (wait_timeout_ms / 1000) deadline = time.monotonic() + (timeout_ms / 1000)
aggregate = _BoundedOutputBuffer(max_output_chars) aggregate = _BoundedOutputBuffer(DEFAULT_MAX_OUTPUT_CHARS)
upstream_truncated = 0 upstream_truncated = 0
search_overlap = "" search_overlap = ""
first = True first = True
poll: _SessionPoll | None = None matched = False
while True: while True:
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
step_ms = min(500, remaining_ms) step_ms = min(MAX_YIELD_MS if until_exit else 500, remaining_ms)
poll = await self._manager.write( poll = await self._manager.write(
session_id=session_id, session_id=session_id,
chars=chars if first else None, chars=input if first else None,
close_stdin=close_stdin if first else False, close_stdin=close_stdin if first else False,
terminate=terminate if first else False, terminate=False,
yield_time_ms=step_ms, yield_time_ms=step_ms,
max_output_chars=MAX_OUTPUT_CHARS, max_output_chars=MAX_OUTPUT_CHARS,
owner_session_key=current_request_session_key(), owner_session_key=current_request_session_key(),
@@ -671,20 +665,25 @@ class WriteStdinTool(Tool):
upstream_truncated += poll.truncated_chars upstream_truncated += poll.truncated_chars
if poll.output: if poll.output:
aggregate.append(poll.output) aggregate.append(poll.output)
searchable = search_overlap + poll.output if wait_for is not None:
if wait_for in searchable: searchable = search_overlap + poll.output
poll.output, aggregate_truncated = aggregate.drain() matched = wait_for in searchable
poll.truncated_chars = upstream_truncated + aggregate_truncated overlap_chars = len(wait_for) - 1
result = format_session_poll(session_id, poll) search_overlap = searchable[-overlap_chars:] if overlap_chars else ""
return ToolResult.error(result) if poll.timed_out else result
overlap_chars = max(0, len(wait_for) - 1) expired = time.monotonic() >= deadline
search_overlap = searchable[-overlap_chars:] if overlap_chars else "" has_activity = wait_for is None and not until_exit and bool(poll.output)
if poll.done or remaining_ms <= 0: if poll.done or matched or has_activity or expired:
poll.output, aggregate_truncated = aggregate.drain() poll.output, aggregate_truncated = aggregate.drain()
poll.truncated_chars = upstream_truncated + aggregate_truncated poll.truncated_chars = upstream_truncated + aggregate_truncated
result = format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
if wait_for not in poll.output: if wait_for is not None and not matched:
result += f"\nWait target not observed: {wait_for!r}" result += f"\nWait target not observed: {wait_for!r}"
elif until_exit and not poll.done:
result += (
f"\nWait timed out after {timeout_ms / 1000:g}s; "
"session remains active."
)
return ToolResult.error(result) if poll.timed_out else result return ToolResult.error(result) if poll.timed_out else result
@@ -722,12 +721,7 @@ class ListExecSessionsTool(Tool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return "List active exec sessions."
"List active long-running exec sessions, including session_id, cwd, "
"elapsed time, idle time, remaining timeout, and command preview. "
"Use this to recover a session_id after context shifts before "
"polling, writing stdin, or terminating with write_stdin."
)
@property @property
def read_only(self) -> bool: def read_only(self) -> bool:
+3 -3
View File
@@ -70,7 +70,7 @@ class ToolRegistry:
def has(self, name: str) -> bool: def has(self, name: str) -> bool:
"""Check if a tool is registered.""" """Check if a tool is registered."""
return name in self._tools return self.get(name) is not None
@staticmethod @staticmethod
def _schema_name(schema: dict[str, Any]) -> str: def _schema_name(schema: dict[str, Any]) -> str:
@@ -113,7 +113,7 @@ class ToolRegistry:
params: Any, params: Any,
) -> tuple[Tool | None, Any, str | None]: ) -> tuple[Tool | None, Any, str | None]:
"""Resolve, cast, and validate one tool call.""" """Resolve, cast, and validate one tool call."""
tool = self._tools.get(name) tool = self.get(name)
if not tool: if not tool:
suggestion = self._suggest_name(str(name)) suggestion = self._suggest_name(str(name))
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else "" hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
@@ -209,4 +209,4 @@ class ToolRegistry:
return len(self._tools) return len(self._tools)
def __contains__(self, name: str) -> bool: def __contains__(self, name: str) -> bool:
return name in self._tools return self.has(name)
+8 -45
View File
@@ -122,55 +122,37 @@ class _PreparedCommand:
working_dir=StringSchema("Optional working directory for the command"), working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"), workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema( timeout=IntegerSchema(
description=( description="Hard timeout in seconds (default 60, max 600).",
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
),
minimum=1, minimum=1,
maximum=600, maximum=600,
), ),
shell=StringSchema( shell=StringSchema(
( (
"Override the Windows shell only when needed. Omit to use " "Shell override; omit for PowerShell, or pass 'cmd' for cmd.exe."
"PowerShell by default (pwsh when available, else powershell). "
"Pass 'cmd' only for cmd.exe syntax or cmd built-ins."
if _IS_WINDOWS if _IS_WINDOWS
else "Override the Unix shell only when needed. Omit to use " else "Shell override; omit for bash, or pass 'sh' or 'zsh'."
"bash by default. Pass 'sh' for POSIX sh or 'zsh' for "
"zsh-specific syntax."
), ),
nullable=True, nullable=True,
), ),
login=BooleanSchema( login=BooleanSchema(
description="Whether to run bash/zsh with login shell semantics (default false).", description="Run bash/zsh as a login shell.",
default=False, default=False,
nullable=True, nullable=True,
), ),
yield_time_ms=IntegerSchema( yield_time_ms=IntegerSchema(
description=( description="Return after this many milliseconds if still running; omit to wait for exit.",
"Optional milliseconds to wait before returning output. "
"When set, a still-running command returns a session_id that "
"can be polled or written to with write_stdin. Omit this field "
"to keep one-shot exec behavior."
),
minimum=0, minimum=0,
maximum=MAX_YIELD_MS, maximum=MAX_YIELD_MS,
nullable=True, nullable=True,
), ),
max_output_chars=IntegerSchema( max_output_chars=IntegerSchema(
description=( description="Session output limit in characters (default 10000, max 50000).",
"Maximum output characters to return when yield_time_ms is used "
"(default 10000, max 50000)."
),
minimum=1000, minimum=1000,
maximum=MAX_OUTPUT_CHARS, maximum=MAX_OUTPUT_CHARS,
nullable=True, nullable=True,
), ),
max_output_tokens=IntegerSchema( max_output_tokens=IntegerSchema(
description=( description="Compatibility alias for max_output_chars.",
"Compatibility alias for max_output_chars. The current runtime "
"uses a character budget."
),
minimum=1000, minimum=1000,
maximum=MAX_OUTPUT_CHARS, maximum=MAX_OUTPUT_CHARS,
nullable=True, nullable=True,
@@ -283,26 +265,7 @@ class ExecTool(Tool):
@property @property
def description(self) -> str: def description(self) -> str:
platform_note = ( return "Execute a shell command."
"On Windows, use PowerShell syntax by default; pass shell='cmd' "
"only for cmd-specific commands. "
if _IS_WINDOWS
else "On Unix, commands run through bash by default; pass shell='sh' "
"or shell='zsh' when needed. "
)
return (
"Execute a shell command and return its output. "
"Use this for tests, builds, package commands, git commands, and "
"other process execution. Prefer read_file/find_files/grep for "
"inspection and apply_patch/write_file/edit_file for file changes "
"instead of cat, shell find/grep, echo, or sed. "
"Use -y or --yes flags to avoid interactive prompts. "
f"{platform_note}"
"For long-running or interactive commands, pass yield_time_ms; "
"if the command keeps running, exec returns a session_id that can "
"be polled or written to with write_stdin. Output is truncated at "
"10 000 chars; timeout defaults to 60s."
)
@property @property
def exclusive(self) -> bool: def exclusive(self) -> bool:
+110
View File
@@ -82,6 +82,111 @@ def _json_object(value: object) -> dict[str, Any]:
return cast(dict[str, Any], value) return cast(dict[str, Any], value)
# TODO(0.3.2): Remove the write_stdin replay migration after 0.3.1.
def _migrate_legacy_exec_arguments(container: dict[str, Any]) -> bool:
raw_arguments = cast(object, container.get("arguments"))
encoded = isinstance(raw_arguments, str)
if encoded:
try:
decoded: object = json.loads(raw_arguments)
except json.JSONDecodeError:
return False
else:
decoded = raw_arguments
if not isinstance(decoded, dict):
return False
arguments = cast(dict[str, Any], decoded)
changed = False
if "chars" in arguments:
if "input" not in arguments:
arguments["input"] = arguments["chars"]
arguments.pop("chars")
changed = True
wait_key = (
"wait_timeout_ms"
if arguments.get("wait_for") or arguments.get("until_exit")
else "yield_time_ms"
)
if "timeout_ms" not in arguments and wait_key in arguments:
arguments["timeout_ms"] = arguments[wait_key]
for key in ("yield_time_ms", "wait_timeout_ms", "max_output_chars", "max_output_tokens"):
if key in arguments:
arguments.pop(key)
changed = True
if changed:
container["arguments"] = (
json.dumps(arguments, ensure_ascii=False, separators=(",", ":"))
if encoded
else arguments
)
return changed
def _migrate_legacy_exec_tool_call(value: object) -> bool:
if not isinstance(value, dict):
return False
tool_call = cast(dict[str, Any], value)
function_value = cast(object, tool_call.get("function"))
function = (
cast(dict[str, Any], function_value)
if isinstance(function_value, dict)
else tool_call
)
name = function.get("name")
if name not in {"write_stdin", "exec_session"}:
return False
changed = name == "write_stdin"
if changed:
function["name"] = "exec_session"
return _migrate_legacy_exec_arguments(function) or changed
def _migrate_legacy_exec_message(message: dict[str, Any]) -> bool:
changed = False
if message.get("name") == "write_stdin":
message["name"] = "exec_session"
changed = True
tool_calls = cast(object, message.get("tool_calls"))
if isinstance(tool_calls, list):
for tool_call in cast(list[object], tool_calls):
changed = _migrate_legacy_exec_tool_call(tool_call) or changed
return changed
def _migrate_legacy_exec_session_records(
messages: list[dict[str, Any]],
metadata: dict[str, Any],
) -> bool:
changed = False
for message in messages:
changed = _migrate_legacy_exec_message(message) or changed
checkpoint_value = cast(object, metadata.get(_RUNTIME_CHECKPOINT_KEY))
if not isinstance(checkpoint_value, dict):
return changed
checkpoint = cast(dict[str, Any], checkpoint_value)
assistant = cast(object, checkpoint.get("assistant_message"))
if isinstance(assistant, dict):
changed = _migrate_legacy_exec_message(cast(dict[str, Any], assistant)) or changed
pending = cast(object, checkpoint.get("pending_tool_calls"))
if isinstance(pending, list):
for tool_call in cast(list[object], pending):
changed = _migrate_legacy_exec_tool_call(tool_call) or changed
completed = cast(object, checkpoint.get("completed_tool_results"))
if isinstance(completed, list):
for result in cast(list[object], completed):
if isinstance(result, dict):
result_data = cast(dict[str, Any], result)
if result_data.get("name") == "write_stdin":
result_data["name"] = "exec_session"
changed = True
return changed
def _is_provider_state_record_line(line: str) -> bool: def _is_provider_state_record_line(line: str) -> bool:
"""Recognize the canonical private record without decoding its opaque payload.""" """Recognize the canonical private record without decoding its opaque payload."""
return _PROVIDER_STATE_RECORD_PREFIX_RE.match(line) is not None return _PROVIDER_STATE_RECORD_PREFIX_RE.match(line) is not None
@@ -1085,6 +1190,8 @@ class JsonlSessionStore:
provider_state=provider_state, provider_state=provider_state,
) )
self._overlay_runtime_checkpoint_unlocked(session, path) self._overlay_runtime_checkpoint_unlocked(session, path)
if _migrate_legacy_exec_session_records(session.messages, session.metadata):
session.provider_state = None
return session return session
except _SESSION_DATA_ERRORS as e: except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to load session {}: {}", key, e) logger.warning("Failed to load session {}: {}", key, e)
@@ -1180,6 +1287,8 @@ class JsonlSessionStore:
provider_state=provider_state, provider_state=provider_state,
) )
self._overlay_runtime_checkpoint_unlocked(session, path) self._overlay_runtime_checkpoint_unlocked(session, path)
if _migrate_legacy_exec_session_records(session.messages, session.metadata):
session.provider_state = None
return session return session
except _SESSION_DATA_ERRORS as e: except _SESSION_DATA_ERRORS as e:
logger.warning("Repair failed for session {}: {}", key, e) logger.warning("Repair failed for session {}: {}", key, e)
@@ -1454,6 +1563,7 @@ class JsonlSessionStore:
continue continue
else: else:
messages.append(data) messages.append(data)
_migrate_legacy_exec_session_records(messages, metadata)
return { return {
"key": stored_key or key, "key": stored_key or key,
"created_at": created_at, "created_at": created_at,
+3 -7
View File
@@ -48,13 +48,9 @@
## Process Execution ## Process Execution
- Use `exec` for tests, builds, package commands, git commands, and other process execution. - Use `exec` for processes, not file inspection or editing.
- Prefer dedicated file/search tools over `cat`, shell `find`, shell `grep`, `sed`, or `echo` for ordinary workspace inspection and edits. - For interaction or early output, set `yield_time_ms` and continue with `exec_session` (`until_exit=true` when no further input is needed).
- Use non-interactive flags such as `-y` or `--yes` when available. - Use `list_exec_sessions` to recover session IDs.
- Commands have a configurable timeout (default 60s), dangerous commands are blocked, and output is truncated.
- For long-running or interactive commands, pass `yield_time_ms`; if the process keeps running, continue with `write_stdin`.
- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session.
- Use `list_exec_sessions` to recover active session IDs after context shifts.
## CLI App Attachments ## CLI App Attachments
+1 -1
View File
@@ -29,7 +29,7 @@ def test_agent_loops_do_not_share_exec_session_managers(tmp_path):
) )
exec_a = loop_a.tools.get("exec") exec_a = loop_a.tools.get("exec")
stdin_a = loop_a.tools.get("write_stdin") stdin_a = loop_a.tools.get("exec_session")
list_a = loop_a.tools.get("list_exec_sessions") list_a = loop_a.tools.get("list_exec_sessions")
exec_b = loop_b.tools.get("exec") exec_b = loop_b.tools.get("exec")
+103
View File
@@ -1,3 +1,4 @@
import json
from unittest.mock import MagicMock from unittest.mock import MagicMock
import nanobot.session as session_api import nanobot.session as session_api
@@ -164,6 +165,108 @@ def test_runtime_checkpoint_does_not_rewrite_long_session(tmp_path) -> None:
assert restored.provider_state.payload == {"response_id": "private-response"} assert restored.provider_state.payload == {"response_id": "private-response"}
def test_load_migrates_legacy_write_stdin_history(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:legacy-exec")
session.messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {
"name": "write_stdin",
"arguments": json.dumps(
{
"session_id": "abc",
"chars": "yes\n",
"wait_for": "ready",
"wait_timeout_ms": 5000,
"yield_time_ms": 0,
"max_output_tokens": 1000,
}
),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call-1",
"name": "write_stdin",
"content": "ready",
},
]
session.provider_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"response_id": "legacy-response"},
)
manager.save(session)
restored = SessionManager(tmp_path).get_or_create(session.key)
function = restored.messages[0]["tool_calls"][0]["function"]
arguments = json.loads(function["arguments"])
assert function["name"] == "exec_session"
assert arguments == {
"session_id": "abc",
"wait_for": "ready",
"input": "yes\n",
"timeout_ms": 5000,
}
assert restored.messages[1]["name"] == "exec_session"
assert restored.provider_state is None
def test_load_migrates_legacy_write_stdin_runtime_checkpoint(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:legacy-checkpoint")
session.add_message("user", "continue")
manager.save(session)
legacy_call = {
"id": "call-1",
"type": "function",
"function": {
"name": "write_stdin",
"arguments": {"session_id": "abc", "chars": "", "yield_time_ms": 1000},
},
}
session.metadata["runtime_checkpoint"] = {
"phase": "awaiting_tools",
"assistant_message": {
"role": "assistant",
"content": "",
"tool_calls": [legacy_call],
},
"completed_tool_results": [],
"pending_tool_calls": [legacy_call],
}
session.provider_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"response_id": "legacy-response"},
)
manager.save_runtime_checkpoint(session)
restored = SessionManager(tmp_path).get_or_create(session.key)
checkpoint = restored.metadata["runtime_checkpoint"]
pending_function = checkpoint["pending_tool_calls"][0]["function"]
assert pending_function == {
"name": "exec_session",
"arguments": {"session_id": "abc", "input": "", "timeout_ms": 1000},
}
assert checkpoint["assistant_message"]["tool_calls"][0]["function"] == pending_function
assert restored.provider_state is None
def test_completed_session_supersedes_stale_checkpoint(tmp_path) -> None: def test_completed_session_supersedes_stale_checkpoint(tmp_path) -> None:
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:completed") session = manager.get_or_create("websocket:completed")
+7 -7
View File
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from nanobot.agent.tools.exec_session import ExecSessionManager, WriteStdinTool from nanobot.agent.tools.exec_session import ExecSessionManager, ExecSessionTool
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
_WINDOWS_ENV_KEYS = { _WINDOWS_ENV_KEYS = {
@@ -905,18 +905,18 @@ class TestWindowsRealExec:
if "session_id:" in result: if "session_id:" in result:
session_id = result.split("session_id:", 1)[1].splitlines()[0].strip() session_id = result.split("session_id:", 1)[1].splitlines()[0].strip()
poll_result = await WriteStdinTool(manager=manager).execute( poll_result = await ExecSessionTool(manager=manager).execute(
session_id=session_id, session_id=session_id,
chars="", input="",
wait_for="café λ 你好", wait_for="café λ 你好",
wait_timeout_ms=120_000, timeout_ms=120_000,
) )
result += "\n" + poll_result result += "\n" + poll_result
if "Process running." in poll_result: if "Process running." in poll_result:
final_result = await WriteStdinTool(manager=manager).execute( final_result = await ExecSessionTool(manager=manager).execute(
session_id=session_id, session_id=session_id,
chars="", input="",
yield_time_ms=30_000, timeout_ms=30_000,
) )
result += "\n" + final_result result += "\n" + final_result
assert "Process running." not in final_result assert "Process running." not in final_result
+202 -118
View File
@@ -17,8 +17,8 @@ from nanobot.agent.tools.context import RequestContext, bind_request_context, re
from nanobot.agent.tools.exec_session import ( from nanobot.agent.tools.exec_session import (
MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS,
ExecSessionManager, ExecSessionManager,
ExecSessionTool,
ListExecSessionsTool, ListExecSessionsTool,
WriteStdinTool,
_BoundedOutputBuffer, _BoundedOutputBuffer,
_SessionPoll, _SessionPoll,
_truncate_output, _truncate_output,
@@ -65,18 +65,16 @@ def _session_id(output: str) -> str:
async def _poll_if_running( async def _poll_if_running(
initial: str, initial: str,
tool: WriteStdinTool, tool: ExecSessionTool,
*, *,
yield_time_ms: int = 2000, timeout_ms: int = 2000,
max_output_tokens: int | None = None,
) -> tuple[str, str]: ) -> tuple[str, str]:
if "session_id:" not in initial: if "session_id:" not in initial:
return initial, initial return initial, initial
final = await tool.execute( final = await tool.execute(
session_id=_session_id(initial), session_id=_session_id(initial),
chars="", input="",
yield_time_ms=yield_time_ms, timeout_ms=timeout_ms,
max_output_tokens=max_output_tokens,
) )
return f"{initial}\n{final}", final return f"{initial}\n{final}", final
@@ -111,7 +109,7 @@ def test_exec_returns_completed_session_output_when_yield_time_ms_is_used(tmp_pa
async def run() -> tuple[str, str]: async def run() -> tuple[str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
initial = await tool.execute(command="echo hello", yield_time_ms=1000) initial = await tool.execute(command="echo hello", yield_time_ms=1000)
return await _poll_if_running(initial, stdin_tool) return await _poll_if_running(initial, stdin_tool)
@@ -127,7 +125,7 @@ def test_exec_session_yield_returns_when_process_finishes_early(tmp_path):
async def run() -> tuple[str, str, float]: async def run() -> tuple[str, str, float]:
manager = ExecSessionManager() manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _python_command("import time; time.sleep(0.1); print('done')") command = _python_command("import time; time.sleep(0.1); print('done')")
started = time.monotonic() started = time.monotonic()
initial = await tool.execute(command=command, yield_time_ms=1200) initial = await tool.execute(command=command, yield_time_ms=1200)
@@ -142,29 +140,6 @@ def test_exec_session_yield_returns_when_process_finishes_early(tmp_path):
assert elapsed < 4.0 assert elapsed < 4.0
def test_exec_session_accepts_max_output_tokens_alias(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command("print('A' * 2000)")
initial = await tool.execute(
command=command,
yield_time_ms=1000,
max_output_tokens=1000,
)
return await _poll_if_running(
initial,
stdin_tool,
max_output_tokens=1000,
)
result, final = asyncio.run(run())
assert "chars truncated" in result
assert "Exit code: 0" in final
def test_bounded_output_buffer_keeps_head_tail_and_exact_drop_count(): def test_bounded_output_buffer_keeps_head_tail_and_exact_drop_count():
buffer = _BoundedOutputBuffer(10) buffer = _BoundedOutputBuffer(10)
@@ -219,37 +194,36 @@ def test_exec_session_bounds_unpolled_stdout_and_stderr(tmp_path):
assert truncated_chars > 390000 assert truncated_chars > 390000
def test_write_stdin_wait_for_keeps_aggregate_within_output_budget(): def test_exec_session_wait_for_keeps_aggregate_within_output_budget():
async def run() -> str: async def run() -> str:
manager = SimpleNamespace( manager = SimpleNamespace(
write=AsyncMock(side_effect=[ write=AsyncMock(side_effect=[
_SessionPoll(output="HEAD" + "a" * 596, done=False, exit_code=None), _SessionPoll(output="HEAD" + "a" * 5996, done=False, exit_code=None),
_SessionPoll(output="b" * 600, done=False, exit_code=None), _SessionPoll(output="b" * 6000, done=False, exit_code=None),
_SessionPoll(output="c" * 590 + "TARGET", done=False, exit_code=None), _SessionPoll(output="c" * 5994 + "TARGET", done=False, exit_code=None),
]) ])
) )
tool = WriteStdinTool(manager=manager) tool = ExecSessionTool(manager=manager)
return await tool._wait_for_output( return await tool._wait(
session_id="session", session_id="session",
chars=None, input=None,
close_stdin=False, close_stdin=False,
terminate=False,
wait_for="TARGET", wait_for="TARGET",
wait_timeout_ms=1000, until_exit=False,
max_output_chars=1000, timeout_ms=1000,
) )
result = asyncio.run(run()) result = asyncio.run(run())
assert result.startswith("HEAD") assert result.startswith("HEAD")
assert "TARGET" in result assert "Wait target not observed" not in result
assert "(796 chars truncated from output)" in result assert "(8,000 chars truncated from output)" in result
assert len(result) < 1100 assert len(result) < 10100
def test_write_stdin_wait_for_searches_before_response_truncation(): def test_exec_session_wait_for_searches_before_response_truncation():
async def run() -> tuple[str, list[int]]: async def run() -> tuple[str, list[int]]:
output = "A" * 1500 + "TARGET" + "B" * 1500 output = "A" * 15000 + "TARGET" + "B" * 15000
observed_limits: list[int] = [] observed_limits: list[int] = []
async def write( async def write(
@@ -273,15 +247,14 @@ def test_write_stdin_wait_for_searches_before_response_truncation():
) )
manager = SimpleNamespace(write=AsyncMock(side_effect=write)) manager = SimpleNamespace(write=AsyncMock(side_effect=write))
tool = WriteStdinTool(manager=manager) tool = ExecSessionTool(manager=manager)
result = await tool._wait_for_output( result = await tool._wait(
session_id="session", session_id="session",
chars=None, input=None,
close_stdin=False, close_stdin=False,
terminate=False,
wait_for="TARGET", wait_for="TARGET",
wait_timeout_ms=1000, until_exit=False,
max_output_chars=1000, timeout_ms=1000,
) )
return result, observed_limits return result, observed_limits
@@ -289,8 +262,8 @@ def test_write_stdin_wait_for_searches_before_response_truncation():
assert observed_limits == [MAX_OUTPUT_CHARS] assert observed_limits == [MAX_OUTPUT_CHARS]
assert "Wait target not observed" not in result assert "Wait target not observed" not in result
assert "(2,006 chars truncated from output)" in result assert "(20,006 chars truncated from output)" in result
assert len(result) < 1100 assert len(result) < 10100
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path): def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
@@ -334,7 +307,7 @@ def test_exec_can_continue_with_stdin(tmp_path):
async def run() -> tuple[str, str, str]: async def run() -> tuple[str, str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _python_command( command = _python_command(
"import sys; print('ready', flush=True); " "import sys; print('ready', flush=True); "
"line=sys.stdin.readline(); print('got:' + line.strip(), flush=True)" "line=sys.stdin.readline(); print('got:' + line.strip(), flush=True)"
@@ -343,7 +316,7 @@ def test_exec_can_continue_with_stdin(tmp_path):
try: try:
initial = await exec_tool.execute(command=command, yield_time_ms=500) initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial) sid = _session_id(initial)
result = await stdin_tool.execute(session_id=sid, chars="ping\n", yield_time_ms=1000) result = await stdin_tool.execute(session_id=sid, input="ping\n", timeout_ms=1000)
observed, final = await _poll_if_running(result, stdin_tool) observed, final = await _poll_if_running(result, stdin_tool)
return initial, observed, final return initial, observed, final
finally: finally:
@@ -358,11 +331,11 @@ def test_exec_can_continue_with_stdin(tmp_path):
assert "Elapsed:" in result assert "Elapsed:" in result
def test_write_stdin_can_close_stdin(tmp_path): def test_exec_session_can_close_stdin(tmp_path):
async def run() -> tuple[str, str]: async def run() -> tuple[str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _python_command( command = _python_command(
"import sys; print('ready', flush=True); " "import sys; print('ready', flush=True); "
"data=sys.stdin.read(); print('got:' + data, flush=True)" "data=sys.stdin.read(); print('got:' + data, flush=True)"
@@ -372,9 +345,9 @@ def test_write_stdin_can_close_stdin(tmp_path):
sid = _session_id(initial) sid = _session_id(initial)
result = await stdin_tool.execute( result = await stdin_tool.execute(
session_id=sid, session_id=sid,
chars="payload", input="payload",
close_stdin=True, close_stdin=True,
yield_time_ms=1500, timeout_ms=1500,
) )
return initial, result return initial, result
@@ -385,11 +358,11 @@ def test_write_stdin_can_close_stdin(tmp_path):
assert "Exit code: 0" in result assert "Exit code: 0" in result
def test_write_stdin_can_terminate_session(tmp_path): def test_exec_session_can_terminate_session(tmp_path):
async def run() -> tuple[str, str]: async def run() -> tuple[str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _waiting_shell_command("ready") command = _waiting_shell_command("ready")
initial = await exec_tool.execute(command=command, yield_time_ms=100) initial = await exec_tool.execute(command=command, yield_time_ms=100)
@@ -397,13 +370,11 @@ def test_write_stdin_can_terminate_session(tmp_path):
waited = await stdin_tool.execute( waited = await stdin_tool.execute(
session_id=sid, session_id=sid,
wait_for="ready", wait_for="ready",
wait_timeout_ms=10000, timeout_ms=10000,
yield_time_ms=0,
) )
result = await stdin_tool.execute( result = await stdin_tool.execute(
session_id=sid, session_id=sid,
terminate=True, terminate=True,
yield_time_ms=0,
) )
return initial + waited, result return initial + waited, result
@@ -413,35 +384,11 @@ def test_write_stdin_can_terminate_session(tmp_path):
assert "Exit code:" in result assert "Exit code:" in result
def test_write_stdin_accepts_max_output_tokens_alias(tmp_path): def test_exec_session_preserves_completed_session_output_until_polled(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _waiting_shell_command("A" * 2000)
initial = await exec_tool.execute(command=command, yield_time_ms=0)
sid = _session_id(initial)
poll = await stdin_tool.execute(
session_id=sid,
wait_for="\n",
wait_timeout_ms=10000,
max_output_tokens=1000,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, poll, cleanup
initial, poll, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "chars truncated" in poll
assert "Session terminated." in cleanup
def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
async def run() -> tuple[str, str]: async def run() -> tuple[str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _python_command( command = _python_command(
"import time; print('ready', flush=True); " "import time; print('ready', flush=True); "
"time.sleep(0.1); print('done', flush=True)" "time.sleep(0.1); print('done', flush=True)"
@@ -450,7 +397,7 @@ def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
initial = await exec_tool.execute(command=command, yield_time_ms=50) initial = await exec_tool.execute(command=command, yield_time_ms=50)
sid = _session_id(initial) sid = _session_id(initial)
await asyncio.wait_for(manager._sessions[sid].process.wait(), timeout=2) await asyncio.wait_for(manager._sessions[sid].process.wait(), timeout=2)
final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0) final = await stdin_tool.execute(session_id=sid, input="", timeout_ms=0)
return initial, final return initial, final
initial, final = asyncio.run(run()) initial, final = asyncio.run(run())
@@ -460,23 +407,162 @@ def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
assert "Exit code: 0" in final assert "Exit code: 0" in final
def test_write_stdin_can_wait_for_expected_output(tmp_path): def test_exec_session_until_exit_waits_for_silent_process(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
session_tool = ExecSessionTool(manager=manager)
command = _python_command("import time; time.sleep(0.2); print('done', flush=True)")
initial = await exec_tool.execute(command=command, yield_time_ms=0)
final = await session_tool.execute(
session_id=_session_id(initial),
until_exit=True,
timeout_ms=2000,
)
return initial, final
initial, final = asyncio.run(run())
assert "Process running" in initial
assert "done" in final
assert "Exit code: 0" in final
assert "Process running" not in final
def test_exec_session_until_exit_aggregates_output_and_reports_nonzero_exit(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
session_tool = ExecSessionTool(manager=manager)
command = _python_command(
"import sys,time; print('first', flush=True); time.sleep(0.1); "
"print('second', flush=True); time.sleep(0.1); sys.exit(7)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=0)
final = await session_tool.execute(
session_id=_session_id(initial),
until_exit=True,
timeout_ms=2000,
)
return initial, final
initial, final = asyncio.run(run())
output = initial + final
assert "first" in output
assert "second" in output
assert "Exit code: 7" in final
def test_exec_session_until_exit_timeout_keeps_session_active(tmp_path):
async def run() -> tuple[str, str, str]: async def run() -> tuple[str, str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) session_tool = ExecSessionTool(manager=manager)
initial = await exec_tool.execute(
command=_python_command("import time; time.sleep(0.3); print('done', flush=True)"),
yield_time_ms=0,
)
sid = _session_id(initial)
timed_wait = await session_tool.execute(
session_id=sid,
until_exit=True,
timeout_ms=20,
)
final = await session_tool.execute(
session_id=sid,
until_exit=True,
timeout_ms=2000,
)
return initial, timed_wait, final
initial, timed_wait, final = asyncio.run(run())
assert "Process running" in initial
assert "Process running" in timed_wait
assert "Wait timed out after 0.02s; session remains active." in timed_wait
assert "done" in final
assert "Exit code: 0" in final
def test_exec_session_until_exit_can_be_cancelled_without_losing_session(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
session_tool = ExecSessionTool(manager=manager)
list_tool = ListExecSessionsTool(manager=manager)
initial = await exec_tool.execute(
command=_python_command("import time; time.sleep(5)"),
yield_time_ms=0,
)
sid = _session_id(initial)
wait_task = asyncio.create_task(
session_tool.execute(
session_id=sid,
until_exit=True,
timeout_ms=2000,
)
)
await asyncio.sleep(0.05)
wait_task.cancel()
with pytest.raises(asyncio.CancelledError):
await wait_task
listing = await list_tool.execute()
cleanup = await session_tool.execute(session_id=sid, terminate=True)
return listing, cleanup
listing, cleanup = asyncio.run(run())
assert "running" in listing
assert "Session terminated." in cleanup
def test_exec_session_rejects_conflicting_wait_conditions():
async def run() -> str:
return await ExecSessionTool().execute(
session_id="unused",
wait_for="ready",
until_exit=True,
)
result = asyncio.run(run())
assert result == "Error: wait_for and until_exit are mutually exclusive."
assert is_tool_error_result(result)
def test_exec_session_rejects_terminate_with_other_actions():
async def run() -> str:
return await ExecSessionTool().execute(
session_id="unused",
input="quit\n",
terminate=True,
)
result = asyncio.run(run())
assert result == "Error: terminate must be used alone."
assert is_tool_error_result(result)
def test_exec_session_can_wait_for_expected_output(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = ExecSessionTool(manager=manager)
command = _waiting_shell_command("booting", delayed="ready") command = _waiting_shell_command("booting", delayed="ready")
initial = await exec_tool.execute(command=command, yield_time_ms=100) initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial) sid = _session_id(initial)
waited = await stdin_tool.execute( waited = await stdin_tool.execute(
session_id=sid, session_id=sid,
chars="\n", input="\n",
wait_for="ready", wait_for="ready",
wait_timeout_ms=1000, timeout_ms=1000,
yield_time_ms=0,
) )
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) cleanup = await stdin_tool.execute(session_id=sid, terminate=True)
return initial, waited, cleanup return initial, waited, cleanup
initial, waited, cleanup = asyncio.run(run()) initial, waited, cleanup = asyncio.run(run())
@@ -488,11 +574,11 @@ def test_write_stdin_can_wait_for_expected_output(tmp_path):
assert "Session terminated." in cleanup assert "Session terminated." in cleanup
def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path): def test_exec_session_wait_for_reports_timeout_without_killing_session(tmp_path):
async def run() -> tuple[str, str, str, str]: async def run() -> tuple[str, str, str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _waiting_shell_command("booting", delayed="ready") command = _waiting_shell_command("booting", delayed="ready")
initial = await exec_tool.execute(command=command, yield_time_ms=0) initial = await exec_tool.execute(command=command, yield_time_ms=0)
@@ -500,18 +586,16 @@ def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path):
# Synchronize on an stdin-gated marker before exercising the immediate timeout below. # Synchronize on an stdin-gated marker before exercising the immediate timeout below.
ready = await stdin_tool.execute( ready = await stdin_tool.execute(
session_id=sid, session_id=sid,
chars="\n", input="\n",
wait_for="ready", wait_for="ready",
wait_timeout_ms=10000, timeout_ms=10000,
yield_time_ms=0,
) )
waited = await stdin_tool.execute( waited = await stdin_tool.execute(
session_id=sid, session_id=sid,
wait_for="never-ready", wait_for="never-ready",
wait_timeout_ms=0, timeout_ms=0,
yield_time_ms=0,
) )
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) cleanup = await stdin_tool.execute(session_id=sid, terminate=True)
return initial, ready, waited, cleanup return initial, ready, waited, cleanup
initial, ready, waited, cleanup = asyncio.run(run()) initial, ready, waited, cleanup = asyncio.run(run())
@@ -538,11 +622,11 @@ def test_exec_session_mode_reuses_exec_safety_guard(tmp_path):
assert "blocked by deny pattern" in result assert "blocked by deny pattern" in result
def test_write_stdin_reports_missing_session(tmp_path): def test_exec_session_reports_missing_session(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
tool = WriteStdinTool(manager=manager) tool = ExecSessionTool(manager=manager)
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars="")) result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", input=""))
assert result == "Error: exec session not found: 'missing\\nExit code: 0'" assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
assert is_tool_error_result(result) assert is_tool_error_result(result)
@@ -553,13 +637,13 @@ def test_list_exec_sessions_reports_running_commands(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
list_tool = ListExecSessionsTool(manager=manager) list_tool = ListExecSessionsTool(manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _waiting_shell_command("ready") command = _waiting_shell_command("ready")
initial = await exec_tool.execute(command=command, yield_time_ms=500) initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial) sid = _session_id(initial)
listing = await list_tool.execute() listing = await list_tool.execute()
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) cleanup = await stdin_tool.execute(session_id=sid, terminate=True)
return sid, listing, cleanup return sid, listing, cleanup
sid, listing, cleanup = asyncio.run(run()) sid, listing, cleanup = asyncio.run(run())
@@ -577,7 +661,7 @@ def test_exec_sessions_are_scoped_to_request_session_key(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
list_tool = ListExecSessionsTool(manager=manager) list_tool = ListExecSessionsTool(manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
command = _python_command( command = _python_command(
"import time; print('ready', flush=True); time.sleep(5)" "import time; print('ready', flush=True); time.sleep(5)"
) )
@@ -599,7 +683,7 @@ def test_exec_sessions_are_scoped_to_request_session_key(tmp_path):
) )
try: try:
other_listing = await list_tool.execute() other_listing = await list_tool.execute()
other_write = await stdin_tool.execute(session_id=sid, yield_time_ms=0) other_write = await stdin_tool.execute(session_id=sid, timeout_ms=0)
finally: finally:
reset_request_context(token_b) reset_request_context(token_b)
@@ -607,7 +691,7 @@ def test_exec_sessions_are_scoped_to_request_session_key(tmp_path):
RequestContext(channel="cli", chat_id="a", session_key="cli:a") RequestContext(channel="cli", chat_id="a", session_key="cli:a")
) )
try: try:
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) cleanup = await stdin_tool.execute(session_id=sid, terminate=True)
finally: finally:
reset_request_context(token_a) reset_request_context(token_a)
@@ -666,7 +750,7 @@ def test_exec_session_manager_shutdown_terminates_child_processes(tmp_path):
) )
manager = ExecSessionManager() manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager) tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = ExecSessionTool(manager=manager)
initial = await tool.execute(command=_python_command(parent_code), yield_time_ms=500) initial = await tool.execute(command=_python_command(parent_code), yield_time_ms=500)
observed = current = initial observed = current = initial
deadline = time.monotonic() + 5 deadline = time.monotonic() + 5
@@ -675,8 +759,8 @@ def test_exec_session_manager_shutdown_terminates_child_processes(tmp_path):
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
current = await stdin_tool.execute( current = await stdin_tool.execute(
session_id=_session_id(initial), session_id=_session_id(initial),
chars="", input="",
yield_time_ms=0, timeout_ms=0,
) )
observed += f"\n{current}" observed += f"\n{current}"
assert "ready" in observed assert "ready" in observed
+28 -31
View File
@@ -1,8 +1,7 @@
import sys import sys
from unittest.mock import patch
from nanobot.agent.tools.apply_patch import ApplyPatchTool from nanobot.agent.tools.apply_patch import ApplyPatchTool
from nanobot.agent.tools.exec_session import ListExecSessionsTool, WriteStdinTool from nanobot.agent.tools.exec_session import ExecSessionTool, ListExecSessionsTool
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.search import FindFilesTool, GrepTool from nanobot.agent.tools.search import FindFilesTool, GrepTool
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
@@ -26,13 +25,10 @@ def test_coding_tool_descriptions_steer_editing_priority() -> None:
assert "prefer apply_patch" in write_file assert "prefer apply_patch" in write_file
def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None: def test_coding_tool_descriptions_steer_discovery() -> None:
read_file = ReadFileTool().description.lower() read_file = ReadFileTool().description.lower()
find_files = FindFilesTool().description.lower() find_files = FindFilesTool().description.lower()
grep = GrepTool().description.lower() grep = GrepTool().description.lower()
exec_tool = ExecTool().description.lower()
write_stdin = WriteStdinTool().description.lower()
list_sessions = ListExecSessionsTool().description.lower()
assert "find_files/list_dir first" in read_file assert "find_files/list_dir first" in read_file
assert "before editing" in read_file assert "before editing" in read_file
@@ -41,38 +37,39 @@ def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None:
assert "prefer it over shell find/ls" in find_files assert "prefer it over shell find/ls" in find_files
assert "prefer this over shell grep" in grep assert "prefer this over shell grep" in grep
assert "tests, builds" in exec_tool
assert "prefer read_file/find_files/grep" in exec_tool
assert "apply_patch/write_file/edit_file" in exec_tool
assert "yield_time_ms" in exec_tool
assert "do not use this to start new commands" in write_stdin def test_exec_tool_descriptions_are_concise() -> None:
assert "wait_for" in write_stdin assert ExecTool().description == "Execute a shell command."
assert "recover a session_id" in list_sessions assert ExecSessionTool().description == "Manage a session returned by exec."
assert ListExecSessionsTool().description == "List active exec sessions."
exec_parameters = ExecTool().parameters["properties"]
assert "omit to wait for exit" in exec_parameters["yield_time_ms"]["description"]
session_parameters = ExecSessionTool().parameters["properties"]
assert set(session_parameters) == {
"session_id",
"input",
"close_stdin",
"terminate",
"wait_for",
"until_exit",
"timeout_ms",
}
assert session_parameters["until_exit"]["description"] == "Wait for the process to exit."
assert "wait_for" in session_parameters["timeout_ms"]["description"]
assert "until_exit" in session_parameters["timeout_ms"]["description"]
def test_exec_tool_shell_guidance_matches_platform() -> None: def test_exec_shell_parameter_guidance_matches_platform() -> None:
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
unix_description = ExecTool().description.lower()
assert "on unix" in unix_description
assert "powershell" not in unix_description
assert "cmd-specific" not in unix_description
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
windows_description = ExecTool().description.lower()
assert "powershell syntax" in windows_description
assert "shell='cmd'" in windows_description
shell_parameter = ExecTool().parameters["properties"]["shell"]["description"].lower() shell_parameter = ExecTool().parameters["properties"]["shell"]["description"].lower()
if sys.platform == "win32": if sys.platform == "win32":
assert "override the windows shell only when needed" in shell_parameter assert "omit for powershell" in shell_parameter
assert "omit to use powershell by default" in shell_parameter
assert "powershell" in shell_parameter assert "powershell" in shell_parameter
assert "cmd" in shell_parameter assert "cmd" in shell_parameter
assert "unix" not in shell_parameter assert "bash" not in shell_parameter
else: else:
assert "override the unix shell only when needed" in shell_parameter assert "omit for bash" in shell_parameter
assert "omit to use bash by default" in shell_parameter assert "zsh" in shell_parameter
assert "unix" in shell_parameter
assert "powershell" not in shell_parameter assert "powershell" not in shell_parameter
assert "cmd" not in shell_parameter assert "cmd" not in shell_parameter
+10 -3
View File
@@ -92,7 +92,7 @@ def test_discover_finds_concrete_tools():
assert "CliAppsTool" in class_names assert "CliAppsTool" in class_names
assert "MessageTool" in class_names assert "MessageTool" in class_names
assert "SpawnTool" in class_names assert "SpawnTool" in class_names
assert "WriteStdinTool" in class_names assert "ExecSessionTool" in class_names
def test_discover_excludes_abstract_and_mcp(): def test_discover_excludes_abstract_and_mcp():
@@ -160,8 +160,15 @@ def test_loader_wires_shared_exec_session_manager(tmp_path):
ToolLoader().load(ctx, registry) ToolLoader().load(ctx, registry)
assert registry.get("exec")._session_manager is manager assert registry.get("exec")._session_manager is manager
assert registry.get("write_stdin")._manager is manager assert registry.get("exec_session")._manager is manager
assert registry.get("write_stdin") is None
assert registry.get("list_exec_sessions")._manager is manager assert registry.get("list_exec_sessions")._manager is manager
definition_names = {
definition["function"]["name"]
for definition in registry.get_definitions()
}
assert "exec_session" in definition_names
assert "write_stdin" not in definition_names
# --- Task 4: _FsTool.create() --- # --- Task 4: _FsTool.create() ---
@@ -419,7 +426,7 @@ def test_loader_registers_same_tools_as_old_hardcoded():
expected = { expected = {
"read_file", "write_file", "edit_file", "list_dir", "read_file", "write_file", "edit_file", "list_dir",
"find_files", "grep", "exec", "write_stdin", "list_exec_sessions", "find_files", "grep", "exec", "exec_session", "list_exec_sessions",
"web_search", "web_fetch", "web_search", "web_fetch",
"message", "spawn", "cron", "message", "spawn", "cron",
} }
@@ -240,6 +240,8 @@ function activityLabel(
return statusCopy(status, "Starting long task", "Started long task", "Could not start long task"); return statusCopy(status, "Starting long task", "Started long task", "Could not start long task");
case "update_goal": case "update_goal":
return statusCopy(status, "Updating long task", "Updated long task", "Could not update long task"); return statusCopy(status, "Updating long task", "Updated long task", "Could not update long task");
// TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1.
case "exec_session":
case "write_stdin": case "write_stdin":
return statusCopy(status, "Continuing command", "Continued command", "Could not continue command"); return statusCopy(status, "Continuing command", "Continued command", "Could not continue command");
case "list_exec_sessions": case "list_exec_sessions":
@@ -291,6 +293,8 @@ function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: s
return safeText(fieldValue(trace, "ui_summary")); return safeText(fieldValue(trace, "ui_summary"));
case "update_goal": case "update_goal":
return safeText(fieldValue(trace, "action")); return safeText(fieldValue(trace, "action"));
// TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1.
case "exec_session":
case "write_stdin": case "write_stdin":
return compactIdentifier(fieldValue(trace, "session_id")); return compactIdentifier(fieldValue(trace, "session_id"));
case "screenshot": case "screenshot":
+2 -1
View File
@@ -29,7 +29,8 @@ describe("generic tool activity semantics", () => {
['cron({"action":"remove","name":"Daily digest"})', "Removed automation", "Daily digest"], ['cron({"action":"remove","name":"Daily digest"})', "Removed automation", "Daily digest"],
['create_goal({"objective":"private objective","ui_summary":"Benchmark memory"})', "Started long task", "Benchmark memory"], ['create_goal({"objective":"private objective","ui_summary":"Benchmark memory"})', "Started long task", "Benchmark memory"],
['update_goal({"action":"complete","recap":"private recap"})', "Updated long task", "complete"], ['update_goal({"action":"complete","recap":"private recap"})', "Updated long task", "complete"],
['write_stdin({"session_id":"session-1234567890-secret","chars":"private input"})', "Continued command", "session…ecret"], ['exec_session({"session_id":"session-1234567890-secret","until_exit":true})', "Continued command", "session…ecret"],
['write_stdin({"session_id":"legacy-1234567890-secret","chars":"private input"})', "Continued command", "legacy-…ecret"],
['list_exec_sessions({})', "Checked running commands", ""], ['list_exec_sessions({})', "Checked running commands", ""],
['screenshot({"path":"artifacts/home.png"})', "Captured screenshot", ""], ['screenshot({"path":"artifacts/home.png"})', "Captured screenshot", ""],
['third_party_sync({"token":"secret","payload":"private payload"})', "Completed Third party sync", ""], ['third_party_sync({"token":"secret","payload":"private payload"})', "Completed Third party sync", ""],