mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(agent): wait for exec sessions without polling (#5526)
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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", ""],
|
||||||
|
|||||||
Reference in New Issue
Block a user