chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import Agent, CustomTool, RunHooks
|
||||
from agents.editor import ApplyPatchOperation, ApplyPatchResult
|
||||
from agents.items import ToolApprovalItem, ToolCallOutputItem
|
||||
from agents.models.openai_responses import Converter
|
||||
from agents.run import RunConfig
|
||||
from agents.run_context import RunContextWrapper
|
||||
from agents.run_internal.run_steps import ToolRunCustom
|
||||
from agents.run_internal.tool_actions import CustomToolAction
|
||||
from agents.sandbox.capabilities.tools import SandboxApplyPatchTool
|
||||
from agents.sandbox.types import User
|
||||
from tests.sandbox._apply_patch_test_session import (
|
||||
ApplyPatchSession,
|
||||
UserRecordingApplyPatchSession,
|
||||
)
|
||||
from tests.utils.hitl import make_context_wrapper
|
||||
|
||||
|
||||
class TestSandboxApplyPatchTool:
|
||||
def test_exposes_custom_apply_patch_tool(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
|
||||
assert isinstance(tool, CustomTool)
|
||||
assert tool.name == "apply_patch"
|
||||
assert tool.tool_config["type"] == "custom"
|
||||
assert tool.tool_config["name"] == "apply_patch"
|
||||
assert tool.tool_config["format"]["type"] == "grammar"
|
||||
assert tool.tool_config["format"]["syntax"] == "lark"
|
||||
|
||||
def test_converter_uses_sandbox_custom_apply_patch_tool_config(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
|
||||
converted = Converter.convert_tools([tool], handoffs=[])
|
||||
|
||||
assert converted.tools[0]["type"] == "custom"
|
||||
assert converted.tools[0]["name"] == "apply_patch"
|
||||
description = converted.tools[0]["description"]
|
||||
assert isinstance(description, str)
|
||||
assert "This is a FREEFORM tool" in description
|
||||
assert "A full patch can combine several operations" in description
|
||||
tool_format = cast(dict[str, Any], converted.tools[0]["format"])
|
||||
assert tool_format["syntax"] == "lark"
|
||||
|
||||
def test_needs_approval_exposes_operation_typed_setting(self) -> None:
|
||||
async def needs_approval(
|
||||
_ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type != "create_file"
|
||||
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=needs_approval)
|
||||
|
||||
assert cast(object, tool.needs_approval) is needs_approval
|
||||
assert cast(object, tool.operation_needs_approval) is needs_approval
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_needs_approval_assignment_drives_runtime_approval(self) -> None:
|
||||
async def needs_approval(
|
||||
_ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type == "delete_file"
|
||||
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
tool.needs_approval = needs_approval
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=make_context_wrapper(),
|
||||
raw_input="*** Begin Patch\n*** Delete File: notes.txt\n*** End Patch\n",
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolApprovalItem)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_patch_input_surfaces_tool_error_after_approval_precheck(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=True)
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=make_context_wrapper(),
|
||||
raw_input="not a valid patch",
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolCallOutputItem)
|
||||
assert "apply_patch input must start with '*** Begin Patch'" in result.output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_create_update_delete_round_trip(self) -> None:
|
||||
session = ApplyPatchSession()
|
||||
tool = SandboxApplyPatchTool(session=session)
|
||||
|
||||
create_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.create_file(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="notes.txt",
|
||||
diff="+hello\n+world\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(create_result, ApplyPatchResult)
|
||||
assert create_result.output == "Created notes.txt"
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hello\nworld"
|
||||
|
||||
update_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.update_file(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="notes.txt",
|
||||
diff="@@\n-hello\n+hi\n world\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(update_result, ApplyPatchResult)
|
||||
assert update_result.output == "Updated notes.txt"
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hi\nworld"
|
||||
|
||||
delete_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.delete_file(
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="notes.txt",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(delete_result, ApplyPatchResult)
|
||||
assert delete_result.output == "Deleted notes.txt"
|
||||
assert Path("/workspace/notes.txt") not in session.files
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_runs_file_operations_as_bound_user(self) -> None:
|
||||
session = UserRecordingApplyPatchSession()
|
||||
session.files[Path("/workspace/existing.txt")] = b"old\n"
|
||||
tool = SandboxApplyPatchTool(session=session, user=User(name="sandbox-user"))
|
||||
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.update_file(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="existing.txt",
|
||||
diff="@@\n-old\n+new\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.create_file(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="created.txt",
|
||||
diff="+created\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.delete_file(
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="existing.txt",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert session.read_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.mkdir_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.write_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.rm_users == ["sandbox-user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_tool_input_create_update_move_delete(self) -> None:
|
||||
session = ApplyPatchSession()
|
||||
tool = SandboxApplyPatchTool(session=session)
|
||||
context_wrapper = make_context_wrapper()
|
||||
|
||||
await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input=("*** Begin Patch\n*** Add File: notes.txt\n+hello\n+world\n*** End Patch\n"),
|
||||
)
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hello\nworld"
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input=(
|
||||
"*** Begin Patch\n"
|
||||
"*** Update File: notes.txt\n"
|
||||
"*** Move to: moved.txt\n"
|
||||
"@@\n"
|
||||
"-hello\n"
|
||||
"+hi\n"
|
||||
" world\n"
|
||||
"*** End Patch\n"
|
||||
),
|
||||
)
|
||||
assert "Updated notes.txt" in result.output
|
||||
assert "Moved notes.txt to moved.txt" in result.output
|
||||
assert Path("/workspace/notes.txt") not in session.files
|
||||
assert session.files[Path("/workspace/moved.txt")] == b"hi\nworld"
|
||||
|
||||
await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input="*** Begin Patch\n*** Delete File: moved.txt\n*** End Patch\n",
|
||||
)
|
||||
assert Path("/workspace/moved.txt") not in session.files
|
||||
|
||||
|
||||
async def _execute_custom_tool_call(
|
||||
tool: SandboxApplyPatchTool,
|
||||
*,
|
||||
context_wrapper: RunContextWrapper[Any],
|
||||
raw_input: str,
|
||||
) -> Any:
|
||||
result = await CustomToolAction.execute(
|
||||
agent=Agent(name="patcher", tools=[tool]),
|
||||
call=ToolRunCustom(
|
||||
custom_tool=tool,
|
||||
tool_call={
|
||||
"type": "custom_tool_call",
|
||||
"name": "apply_patch",
|
||||
"call_id": "call_apply",
|
||||
"input": raw_input,
|
||||
},
|
||||
),
|
||||
hooks=RunHooks[Any](),
|
||||
context_wrapper=context_wrapper,
|
||||
config=RunConfig(),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.sandbox.capabilities import Compaction, StaticCompactionPolicy
|
||||
|
||||
|
||||
class TestCompactionCapability:
|
||||
def test_sampling_params_uses_static_threshold(self) -> None:
|
||||
"""Tests compaction emits Responses API context management settings."""
|
||||
|
||||
capability = Compaction(policy=StaticCompactionPolicy(threshold=123))
|
||||
|
||||
sampling_params = capability.sampling_params({})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 123,
|
||||
}
|
||||
]
|
||||
}
|
||||
assert isinstance(capability.policy, StaticCompactionPolicy)
|
||||
|
||||
def test_sampling_params_infers_hyphenated_model_threshold(self) -> None:
|
||||
capability = Compaction()
|
||||
|
||||
sampling_params = capability.sampling_params({"model": "gpt-5-2"})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 360_000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def test_sampling_params_infers_gpt_5_6_sol_dynamic_threshold(self) -> None:
|
||||
capability = Compaction()
|
||||
|
||||
sampling_params = capability.sampling_params({"model": "gpt-5.6-sol"})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 942_818,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def test_sampling_params_falls_back_for_unknown_model(self) -> None:
|
||||
capability = Compaction()
|
||||
|
||||
sampling_params = capability.sampling_params({"model": "azure-prod-deployment"})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 240_000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def test_process_context_keeps_items_from_last_compaction(self) -> None:
|
||||
"""Tests compaction truncates history to the last compaction item, inclusive."""
|
||||
|
||||
capability = Compaction()
|
||||
context: list[TResponseInputItem] = [
|
||||
{"type": "message", "role": "user", "content": "old-1"},
|
||||
cast(TResponseInputItem, {"type": "compaction", "summary": "first"}),
|
||||
{"type": "message", "role": "assistant", "content": "between"},
|
||||
cast(TResponseInputItem, {"type": "compaction", "summary": "second"}),
|
||||
{"type": "message", "role": "assistant", "content": "latest"},
|
||||
]
|
||||
|
||||
processed = capability.process_context(context)
|
||||
|
||||
assert processed == context[3:]
|
||||
|
||||
def test_process_context_returns_original_when_no_compaction(self) -> None:
|
||||
"""Tests compaction leaves context unchanged when no compaction item exists."""
|
||||
|
||||
capability = Compaction()
|
||||
context: list[TResponseInputItem] = [
|
||||
{"type": "message", "role": "user", "content": "hello"},
|
||||
{"type": "message", "role": "assistant", "content": "world"},
|
||||
]
|
||||
|
||||
processed = capability.process_context(context)
|
||||
|
||||
assert processed == context
|
||||
|
||||
def test_rejects_unsupported_policy_type(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported compaction policy type: 'unknown'"):
|
||||
Compaction.model_validate({"policy": {"type": "unknown"}})
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.editor import ApplyPatchOperation
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.capabilities import Filesystem, FilesystemToolSet
|
||||
from agents.sandbox.capabilities.tools import SandboxApplyPatchTool, ViewImageTool
|
||||
from agents.sandbox.sandboxes.unix_local import (
|
||||
UnixLocalSandboxSession,
|
||||
UnixLocalSandboxSessionState,
|
||||
)
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import User
|
||||
from agents.tool import CustomTool, FunctionTool
|
||||
|
||||
|
||||
def _make_session(tmp_path: Path) -> UnixLocalSandboxSession:
|
||||
return UnixLocalSandboxSession(
|
||||
state=UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(root=str(tmp_path / "workspace")),
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
workspace_root_owned=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestFilesystemCapability:
|
||||
def test_tools_requires_bound_session(self) -> None:
|
||||
capability = Filesystem()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Filesystem capability is not bound to a SandboxSession",
|
||||
):
|
||||
capability.tools()
|
||||
|
||||
def test_tools_exposes_view_image_and_apply_patch_after_bind(self, tmp_path: Path) -> None:
|
||||
capability = Filesystem()
|
||||
capability.bind(_make_session(tmp_path))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
assert isinstance(tools[0], ViewImageTool)
|
||||
assert isinstance(tools[1], SandboxApplyPatchTool)
|
||||
assert isinstance(tools[0], FunctionTool)
|
||||
assert isinstance(tools[1], CustomTool)
|
||||
assert tools[0].name == "view_image"
|
||||
assert tools[1].name == "apply_patch"
|
||||
|
||||
def test_configure_tools_can_customize_approvals_after_clone(self, tmp_path: Path) -> None:
|
||||
async def view_image_needs_approval(
|
||||
_ctx: Any, params: dict[str, Any], _call_id: str
|
||||
) -> bool:
|
||||
return str(params["path"]).startswith("sensitive/")
|
||||
|
||||
async def apply_patch_needs_approval(
|
||||
_ctx: Any, operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type != "create_file"
|
||||
|
||||
def configure_tools(toolset: FilesystemToolSet) -> None:
|
||||
toolset.view_image.needs_approval = view_image_needs_approval
|
||||
toolset.apply_patch.needs_approval = apply_patch_needs_approval
|
||||
|
||||
capability = Filesystem(configure_tools=configure_tools).clone()
|
||||
capability.bind(_make_session(tmp_path))
|
||||
|
||||
tools = capability.tools()
|
||||
view_image_tool = cast(ViewImageTool, tools[0])
|
||||
apply_patch_tool = cast(SandboxApplyPatchTool, tools[1])
|
||||
|
||||
assert isinstance(view_image_tool, ViewImageTool)
|
||||
assert isinstance(apply_patch_tool, SandboxApplyPatchTool)
|
||||
assert cast(object, view_image_tool.needs_approval) is view_image_needs_approval
|
||||
assert cast(object, apply_patch_tool.needs_approval) is apply_patch_needs_approval
|
||||
|
||||
def test_configure_tools_can_replace_tool_instances(self, tmp_path: Path) -> None:
|
||||
replacement_view_image: ViewImageTool | None = None
|
||||
|
||||
def configure_tools(toolset: FilesystemToolSet) -> None:
|
||||
nonlocal replacement_view_image
|
||||
replacement_view_image = ViewImageTool(
|
||||
session=toolset.view_image.session,
|
||||
needs_approval=True,
|
||||
)
|
||||
toolset.view_image = replacement_view_image
|
||||
|
||||
capability = Filesystem(configure_tools=configure_tools)
|
||||
capability.bind(_make_session(tmp_path))
|
||||
|
||||
tools = capability.tools()
|
||||
view_image_tool = cast(ViewImageTool, tools[0])
|
||||
|
||||
assert replacement_view_image is not None
|
||||
assert view_image_tool is replacement_view_image
|
||||
assert view_image_tool.needs_approval is True
|
||||
assert isinstance(tools[1], SandboxApplyPatchTool)
|
||||
|
||||
def test_tools_passes_bound_run_as_to_file_tools(self, tmp_path: Path) -> None:
|
||||
run_as = User(name="sandbox-user")
|
||||
capability = Filesystem()
|
||||
capability.bind(_make_session(tmp_path))
|
||||
capability.bind_run_as(run_as)
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert isinstance(tools[0], ViewImageTool)
|
||||
assert isinstance(tools[1], SandboxApplyPatchTool)
|
||||
assert tools[0].user == run_as
|
||||
assert tools[1].editor.user == run_as
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_default_to_none(self) -> None:
|
||||
capability = Filesystem()
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is None
|
||||
@@ -0,0 +1,891 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox import Manifest, SandboxPathGrant
|
||||
from agents.sandbox.capabilities import Shell, ShellToolSet
|
||||
from agents.sandbox.capabilities.tools import (
|
||||
ExecCommandArgs,
|
||||
ExecCommandTool,
|
||||
WriteStdinArgs,
|
||||
WriteStdinTool,
|
||||
)
|
||||
from agents.sandbox.capabilities.tools.shell_tool import _resolve_shell
|
||||
from agents.sandbox.errors import ExecTimeoutError, ExecTransportError, PtySessionNotFoundError
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.session.pty_types import PtyExecUpdate
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
from agents.tool import FunctionTool
|
||||
from agents.tool_context import ToolContext
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
class _ShellSession(BaseSandboxSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest,
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
)
|
||||
self.exec_calls: list[tuple[str, float | None, bool | list[str]]] = []
|
||||
self.exec_users: list[str | None] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
return None
|
||||
|
||||
async def stop(self) -> None:
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return None
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def read(self, path: Path, *, user: object = None) -> io.BytesIO:
|
||||
_ = (path, user)
|
||||
raise AssertionError("read() should not be called")
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
_ = (path, data, user)
|
||||
raise AssertionError("write() should not be called")
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = command
|
||||
_ = timeout
|
||||
raise AssertionError("_exec_internal() should not be called directly")
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
self.exec_users.append(user.name if isinstance(user, User) else user)
|
||||
rendered_command = " ".join(str(part) for part in command)
|
||||
self.exec_calls.append((rendered_command, timeout, shell))
|
||||
return ExecResult(
|
||||
stdout=f"stdout: {rendered_command}".encode(),
|
||||
stderr=f"stderr: {rendered_command}".encode(),
|
||||
exit_code=7,
|
||||
)
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
|
||||
class _TimeoutShellSession(_ShellSession):
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
_ = (command, user, shell)
|
||||
raise ExecTimeoutError(command=("sleep 30",), timeout_s=timeout)
|
||||
|
||||
|
||||
class _OutputShellSession(_ShellSession):
|
||||
def __init__(
|
||||
self,
|
||||
manifest: Manifest,
|
||||
*,
|
||||
stdout: bytes,
|
||||
stderr: bytes,
|
||||
exit_code: int = 7,
|
||||
) -> None:
|
||||
super().__init__(manifest)
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.exit_code = exit_code
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
self.exec_users.append(user.name if isinstance(user, User) else user)
|
||||
rendered_command = " ".join(str(part) for part in command)
|
||||
self.exec_calls.append((rendered_command, timeout, shell))
|
||||
return ExecResult(stdout=self.stdout, stderr=self.stderr, exit_code=self.exit_code)
|
||||
|
||||
|
||||
class _PtyShellSession(_ShellSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
super().__init__(manifest)
|
||||
self._next_session_id = 1337
|
||||
self._live_sessions: set[int] = set()
|
||||
self.last_exec_yield_time_s: float | None = None
|
||||
self.last_exec_user: str | None = None
|
||||
self.last_write_yield_time_s: float | None = None
|
||||
|
||||
def supports_pty(self) -> bool:
|
||||
return True
|
||||
|
||||
async def pty_exec_start(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
shell: bool | list[str] = True,
|
||||
user: str | User | None = None,
|
||||
tty: bool = False,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (command, timeout, shell, tty, max_output_tokens)
|
||||
self.last_exec_user = user.name if isinstance(user, User) else user
|
||||
self.last_exec_yield_time_s = yield_time_s
|
||||
session_id = self._next_session_id
|
||||
self._next_session_id += 1
|
||||
self._live_sessions.add(session_id)
|
||||
return PtyExecUpdate(
|
||||
process_id=session_id,
|
||||
output=b"",
|
||||
exit_code=None,
|
||||
original_token_count=None,
|
||||
)
|
||||
|
||||
async def pty_write_stdin(
|
||||
self,
|
||||
*,
|
||||
session_id: int,
|
||||
chars: str,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = max_output_tokens
|
||||
self.last_write_yield_time_s = yield_time_s
|
||||
if session_id not in self._live_sessions:
|
||||
raise PtySessionNotFoundError(session_id=session_id)
|
||||
|
||||
self._live_sessions.discard(session_id)
|
||||
return PtyExecUpdate(
|
||||
process_id=None,
|
||||
output=chars.encode("utf-8", errors="replace"),
|
||||
exit_code=0,
|
||||
original_token_count=None,
|
||||
)
|
||||
|
||||
|
||||
class _PtyNoStdinShellSession(_PtyShellSession):
|
||||
async def pty_write_stdin(
|
||||
self,
|
||||
*,
|
||||
session_id: int,
|
||||
chars: str,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (chars, yield_time_s, max_output_tokens)
|
||||
if session_id not in self._live_sessions:
|
||||
raise PtySessionNotFoundError(session_id=session_id)
|
||||
raise RuntimeError("stdin is not available for this process")
|
||||
|
||||
|
||||
class _PtyUnexpectedStdinErrorShellSession(_PtyShellSession):
|
||||
async def pty_write_stdin(
|
||||
self,
|
||||
*,
|
||||
session_id: int,
|
||||
chars: str,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (session_id, chars, yield_time_s, max_output_tokens)
|
||||
raise RuntimeError("unexpected stdin failure")
|
||||
|
||||
|
||||
class _PtyTransportFailingShellSession(_OutputShellSession):
|
||||
def __init__(
|
||||
self,
|
||||
manifest: Manifest,
|
||||
*,
|
||||
stdout: bytes = b"",
|
||||
stderr: bytes = b"",
|
||||
exit_code: int = 0,
|
||||
transport_context: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
super().__init__(manifest, stdout=stdout, stderr=stderr, exit_code=exit_code)
|
||||
self.transport_context = transport_context or {}
|
||||
self.exec_call_count = 0
|
||||
|
||||
def supports_pty(self) -> bool:
|
||||
return True
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
self.exec_call_count += 1
|
||||
return await super().exec(*command, timeout=timeout, user=user, shell=shell)
|
||||
|
||||
async def pty_exec_start(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
shell: bool | list[str] = True,
|
||||
user: str | User | None = None,
|
||||
tty: bool = False,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (timeout, shell, user, tty, yield_time_s, max_output_tokens)
|
||||
raise ExecTransportError(
|
||||
command=command,
|
||||
context=self.transport_context,
|
||||
cause=RuntimeError("connection closed while reading HTTP status line"),
|
||||
)
|
||||
|
||||
|
||||
def _patch_shell_tool_clock(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
chunk_id: str,
|
||||
start: float,
|
||||
end: float,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
|
||||
lambda: uuid.UUID(chunk_id),
|
||||
)
|
||||
times = iter([start, end])
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
|
||||
lambda: next(times),
|
||||
)
|
||||
|
||||
|
||||
class TestShellCapability:
|
||||
def test_resolve_shell_uses_plain_sh_when_login_is_false(self) -> None:
|
||||
assert _resolve_shell(None, login=False) == ["sh", "-c"]
|
||||
|
||||
def test_tools_requires_bound_session(self) -> None:
|
||||
capability = Shell()
|
||||
|
||||
with pytest.raises(ValueError, match="Shell capability is not bound to a SandboxSession"):
|
||||
capability.tools()
|
||||
|
||||
def test_tools_exposes_exec_command_function_tool_after_bind(self) -> None:
|
||||
capability = Shell()
|
||||
capability.bind(_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 1
|
||||
assert isinstance(tools[0], ExecCommandTool)
|
||||
assert isinstance(tools[0], FunctionTool)
|
||||
assert tools[0].name == "exec_command"
|
||||
|
||||
def test_tools_exposes_write_stdin_for_pty_sessions(self) -> None:
|
||||
capability = Shell()
|
||||
capability.bind(_PtyShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
assert isinstance(tools[0], ExecCommandTool)
|
||||
assert isinstance(tools[1], WriteStdinTool)
|
||||
assert tools[0].name == "exec_command"
|
||||
assert tools[1].name == "write_stdin"
|
||||
|
||||
def test_configure_tools_can_customize_shell_approvals_after_clone(self) -> None:
|
||||
async def exec_command_needs_approval(
|
||||
_ctx: Any, params: dict[str, Any], _call_id: str
|
||||
) -> bool:
|
||||
return str(params["cmd"]).startswith("rm ")
|
||||
|
||||
async def write_stdin_needs_approval(
|
||||
_ctx: Any, params: dict[str, Any], _call_id: str
|
||||
) -> bool:
|
||||
return str(params["chars"]) == "\u0003"
|
||||
|
||||
def configure_tools(toolset: ShellToolSet) -> None:
|
||||
toolset.exec_command.needs_approval = exec_command_needs_approval
|
||||
assert toolset.write_stdin is not None
|
||||
toolset.write_stdin.needs_approval = write_stdin_needs_approval
|
||||
|
||||
capability = Shell(configure_tools=configure_tools).clone()
|
||||
capability.bind(_PtyShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
exec_command_tool = cast(ExecCommandTool, tools[0])
|
||||
write_stdin_tool = cast(WriteStdinTool, tools[1])
|
||||
|
||||
assert cast(object, exec_command_tool.needs_approval) is exec_command_needs_approval
|
||||
assert cast(object, write_stdin_tool.needs_approval) is write_stdin_needs_approval
|
||||
|
||||
def test_configure_tools_can_observe_missing_write_stdin_on_non_pty_session(self) -> None:
|
||||
saw_missing_write_stdin = False
|
||||
|
||||
def configure_tools(toolset: ShellToolSet) -> None:
|
||||
nonlocal saw_missing_write_stdin
|
||||
saw_missing_write_stdin = toolset.write_stdin is None
|
||||
|
||||
capability = Shell(configure_tools=configure_tools)
|
||||
capability.bind(_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert saw_missing_write_stdin is True
|
||||
assert len(tools) == 1
|
||||
assert isinstance(tools[0], ExecCommandTool)
|
||||
|
||||
def test_configure_tools_can_replace_exec_command_tool(self) -> None:
|
||||
replacement_exec_command: ExecCommandTool | None = None
|
||||
|
||||
def configure_tools(toolset: ShellToolSet) -> None:
|
||||
nonlocal replacement_exec_command
|
||||
replacement_exec_command = ExecCommandTool(
|
||||
session=toolset.exec_command.session,
|
||||
needs_approval=True,
|
||||
)
|
||||
toolset.exec_command = replacement_exec_command
|
||||
|
||||
capability = Shell(configure_tools=configure_tools)
|
||||
capability.bind(_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
exec_command_tool = cast(ExecCommandTool, tools[0])
|
||||
|
||||
assert replacement_exec_command is not None
|
||||
assert exec_command_tool is replacement_exec_command
|
||||
assert exec_command_tool.needs_approval is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_match_sandbox_shell_guidance(self) -> None:
|
||||
capability = Shell()
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert (
|
||||
instructions == "When using the shell:\n"
|
||||
"- Use `exec_command` for shell execution.\n"
|
||||
"- If available, use `write_stdin` to interact with or poll running sessions.\n"
|
||||
"- To interrupt a long-running process via `write_stdin`, start it with "
|
||||
"`tty=true` and send Ctrl-C (`\\u0003`).\n"
|
||||
"- Prefer `rg` and `rg --files` for text/file discovery when available.\n"
|
||||
"- Avoid using Python scripts just to print large file chunks."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_runs_commands_with_source_output_format(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
uuids = iter([uuid.UUID("12345678123456781234567812345678")])
|
||||
times = iter([100.0, 100.25])
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
|
||||
lambda: next(uuids),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
|
||||
lambda: next(times),
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=1500).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_calls == [("pwd", 1.5, True)]
|
||||
assert (
|
||||
output == "Chunk ID: 123456\n"
|
||||
"Wall time: 0.2500 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout: pwd\n"
|
||||
"stderr: pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_runs_as_bound_user(self) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
capability.bind_run_as(User(name="sandbox-user"))
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_users == ["sandbox-user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_includes_original_token_count_when_truncating(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
uuids = iter([uuid.UUID("12345678123456781234567812345678")])
|
||||
times = iter([200.0, 200.5])
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
|
||||
lambda: next(uuids),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
|
||||
lambda: next(times),
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=1500, max_output_tokens=2).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 123456\n"
|
||||
"Wall time: 0.5000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Original token count: 6\n"
|
||||
"Output:\n"
|
||||
"Total output lines: 2\n\n"
|
||||
"stdo…4 tokens truncated… pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="87654321876543218765432187654321",
|
||||
start=300.0,
|
||||
end=300.125,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(
|
||||
cmd="pwd",
|
||||
workdir="src/project",
|
||||
shell="/bin/bash",
|
||||
login=False,
|
||||
).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_calls == [
|
||||
("cd /workspace/src/project && pwd", 10.0, ["/bin/bash", "-c"])
|
||||
]
|
||||
assert (
|
||||
output == "Chunk ID: 876543\n"
|
||||
"Wall time: 0.1250 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout: cd /workspace/src/project && pwd\n"
|
||||
"stderr: cd /workspace/src/project && pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_allows_extra_path_grant_workdir(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(
|
||||
Manifest(
|
||||
root="/workspace",
|
||||
extra_path_grants=(SandboxPathGrant(path="/tmp", read_only=True),),
|
||||
)
|
||||
)
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="11111111111111111111111111111111",
|
||||
start=310.0,
|
||||
end=310.25,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(
|
||||
cmd="pwd",
|
||||
workdir="/tmp",
|
||||
shell="/bin/bash",
|
||||
login=False,
|
||||
).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_calls == [("cd /tmp && pwd", 10.0, ["/bin/bash", "-c"])]
|
||||
assert (
|
||||
output == "Chunk ID: 111111\n"
|
||||
"Wall time: 0.2500 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout: cd /tmp && pwd\n"
|
||||
"stderr: cd /tmp && pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_uses_pty_when_supported(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _PtyShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="abcdef12abcdef12abcdef12abcdef12",
|
||||
start=400.0,
|
||||
end=400.05,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.last_exec_yield_time_s == 0.0
|
||||
assert (
|
||||
output == "Chunk ID: abcdef\n"
|
||||
"Wall time: 0.0500 seconds\n"
|
||||
"Process running with session ID 1337\n"
|
||||
"Output:\n"
|
||||
""
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_starts_pty_as_bound_user(self) -> None:
|
||||
capability = Shell()
|
||||
session = _PtyShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
capability.bind_run_as(User(name="sandbox-user"))
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.last_exec_user == "sandbox-user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_formats_timeout_without_exit_code(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _TimeoutShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="fedcba98fedcba98fedcba98fedcba98",
|
||||
start=500.0,
|
||||
end=500.005,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="sleep 30", yield_time_ms=5).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: fedcba\n"
|
||||
"Wall time: 0.0050 seconds\n"
|
||||
"Output:\n"
|
||||
"Command timed out after 0.005 seconds."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_transport_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_PtyTransportFailingShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"fallback ok",
|
||||
transport_context={"stage": "open_pipe", "retry_safe": True},
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="44444444444444444444444444444444",
|
||||
start=510.0,
|
||||
end=510.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert "PTY transport failed before the interactive session opened" in output
|
||||
assert "Process exited with code 0" in output
|
||||
assert "Process running with session ID" not in output
|
||||
assert "fallback ok" in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_does_not_fall_back_for_tty_sessions(self) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_PtyTransportFailingShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
transport_context={"stage": "open_pipe", "retry_safe": True, "tty": True},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ExecTransportError):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", tty=True).model_dump_json(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_does_not_fall_back_for_non_retry_safe_transport_errors(
|
||||
self,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_PtyTransportFailingShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
transport_context={"stage": "open_pipe"},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ExecTransportError):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_uses_stdout_only_when_stderr_is_empty(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_OutputShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"stdout only\n",
|
||||
stderr=b"",
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="11111111111111111111111111111111",
|
||||
start=600.0,
|
||||
end=600.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 111111\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout only\n"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_uses_stderr_only_when_stdout_is_empty(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_OutputShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"",
|
||||
stderr=b"stderr only\n",
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="22222222222222222222222222222222",
|
||||
start=700.0,
|
||||
end=700.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 222222\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stderr only\n"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_does_not_insert_extra_newline_when_stdout_already_has_one(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_OutputShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"stdout line\n",
|
||||
stderr=b"stderr line\n",
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="33333333333333333333333333333333",
|
||||
start=800.0,
|
||||
end=800.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 333333\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout line\n"
|
||||
"stderr line\n"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_writes_and_finishes_session(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _PtyShellSession(Manifest(root="/workspace"))
|
||||
session._live_sessions.add(1337)
|
||||
tool = WriteStdinTool(session=session)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="55555555555555555555555555555555",
|
||||
start=900.0,
|
||||
end=900.2,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337, chars="hello").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 555555\n"
|
||||
"Wall time: 0.2000 seconds\n"
|
||||
"Process exited with code 0\n"
|
||||
"Output:\n"
|
||||
"hello"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_rejects_non_pty_sessions(self) -> None:
|
||||
tool = WriteStdinTool(session=_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="write_stdin is not available for non-PTY sandboxes"
|
||||
):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337).model_dump_json(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_formats_unknown_session_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = WriteStdinTool(session=_PtyShellSession(Manifest(root="/workspace")))
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="66666666666666666666666666666666",
|
||||
start=910.0,
|
||||
end=910.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=9999).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 666666\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 1\n"
|
||||
"Output:\n"
|
||||
"write_stdin failed: PTY session not found: 9999"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_formats_missing_stdin_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _PtyNoStdinShellSession(Manifest(root="/workspace"))
|
||||
session._live_sessions.add(1337)
|
||||
tool = WriteStdinTool(session=session)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="77777777777777777777777777777777",
|
||||
start=920.0,
|
||||
end=920.05,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 777777\n"
|
||||
"Wall time: 0.0500 seconds\n"
|
||||
"Process exited with code 1\n"
|
||||
"Output:\n"
|
||||
"stdin is not available for this process. Start the command with `tty=true` in "
|
||||
"`exec_command` before using `write_stdin`."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_reraises_unexpected_runtime_error(self) -> None:
|
||||
tool = WriteStdinTool(
|
||||
session=_PtyUnexpectedStdinErrorShellSession(Manifest(root="/workspace"))
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected stdin failure"):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337).model_dump_json(),
|
||||
)
|
||||
@@ -0,0 +1,688 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox import Manifest, SandboxPathGrant
|
||||
from agents.sandbox.capabilities import LocalDirLazySkillSource, Skill, Skills
|
||||
from agents.sandbox.entries import Dir, File, LocalDir
|
||||
from agents.sandbox.errors import SkillsConfigError
|
||||
from agents.sandbox.files import EntryKind, FileEntry
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, Permissions, User
|
||||
from agents.sandbox.workspace_paths import coerce_posix_path
|
||||
from agents.tool import FunctionTool
|
||||
from agents.tool_context import ToolContext
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
def _children_keys(entry: Dir) -> set[str]:
|
||||
return {coerce_posix_path(key).as_posix() for key in entry.children}
|
||||
|
||||
|
||||
def _source_granted_manifest(root: str | Path = "/workspace", *, source: Path) -> Manifest:
|
||||
return Manifest(root=str(root), extra_path_grants=(SandboxPathGrant(path=str(source)),))
|
||||
|
||||
|
||||
def _user_name(user: object) -> str | None:
|
||||
if user is None:
|
||||
return None
|
||||
if isinstance(user, User):
|
||||
return user.name
|
||||
if isinstance(user, str):
|
||||
return user
|
||||
return str(user)
|
||||
|
||||
|
||||
class _SkillsSession(BaseSandboxSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest,
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
)
|
||||
self.read_users: list[str | None] = []
|
||||
self.write_users: list[str | None] = []
|
||||
self.mkdir_users: list[str | None] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
return None
|
||||
|
||||
async def stop(self) -> None:
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return None
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def read(self, path: Path, *, user: object = None) -> io.BytesIO:
|
||||
self.read_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
return io.BytesIO(normalized.read_bytes())
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
self.write_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
normalized.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = data.read()
|
||||
if isinstance(payload, str):
|
||||
normalized.write_text(payload, encoding="utf-8")
|
||||
else:
|
||||
normalized.write_bytes(bytes(payload))
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = (command, timeout)
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
async def mkdir(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
parents: bool = False,
|
||||
user: object = None,
|
||||
) -> None:
|
||||
self.mkdir_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
normalized.mkdir(parents=parents, exist_ok=True)
|
||||
|
||||
async def ls(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
user: object = None,
|
||||
) -> list[FileEntry]:
|
||||
_ = user
|
||||
normalized = self.normalize_path(path)
|
||||
if not normalized.exists():
|
||||
raise FileNotFoundError(normalized)
|
||||
entries: list[FileEntry] = []
|
||||
for child in sorted(normalized.iterdir(), key=lambda entry: entry.name):
|
||||
stat_result = child.stat()
|
||||
entries.append(
|
||||
FileEntry(
|
||||
path=str(child),
|
||||
permissions=Permissions.from_mode(stat_result.st_mode),
|
||||
owner="owner",
|
||||
group="group",
|
||||
size=stat_result.st_size,
|
||||
kind=EntryKind.DIRECTORY if child.is_dir() else EntryKind.FILE,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
class TestSkillValidation:
|
||||
def test_rejects_directory_content_artifact(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skill(name="my-skill", description="desc", content=Dir())
|
||||
|
||||
def test_rejects_duplicate_script_paths_after_normalization(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="literal",
|
||||
scripts={
|
||||
"run.sh": File(content=b"echo one"),
|
||||
Path("run.sh"): File(content=b"echo two"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsValidation:
|
||||
def test_requires_at_least_one_source(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills()
|
||||
|
||||
def test_rejects_non_directory_from_artifact(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(from_=File(content=b"not-a-dir"))
|
||||
|
||||
def test_rejects_duplicate_skill_names(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[
|
||||
Skill(name="dup", description="first", content="a"),
|
||||
Skill(name="dup", description="second", content="b"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_rejects_combining_literal_and_from_sources(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
from_=Dir(
|
||||
children={"my-skill": Dir(children={"SKILL.md": File(content=b"imported")})}
|
||||
),
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
)
|
||||
|
||||
def test_rejects_combining_literal_and_lazy_sources(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=Path("skills"))),
|
||||
)
|
||||
|
||||
def test_rejects_absolute_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="/skills",
|
||||
)
|
||||
|
||||
def test_rejects_windows_drive_absolute_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError) as exc_info:
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="C:\\skills",
|
||||
)
|
||||
|
||||
assert exc_info.value.context == {
|
||||
"field": "skills_path",
|
||||
"path": "C:/skills",
|
||||
"reason": "absolute",
|
||||
}
|
||||
|
||||
def test_rejects_escape_root_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="../skills",
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsManifest:
|
||||
def test_literals_materialize_full_skill_structure(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="Use this skill.",
|
||||
scripts={"run.sh": File(content=b"echo run")},
|
||||
references={"docs/readme.md": File(content=b"ref")},
|
||||
assets={"images/icon.txt": File(content=b"asset")},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
skill_entry = processed.entries[Path(".agents/my-skill")]
|
||||
assert isinstance(skill_entry, Dir)
|
||||
assert _children_keys(skill_entry) == {"SKILL.md", "assets", "references", "scripts"}
|
||||
|
||||
scripts = skill_entry.children["scripts"]
|
||||
assert isinstance(scripts, Dir)
|
||||
assert _children_keys(scripts) == {"run.sh"}
|
||||
|
||||
references = skill_entry.children["references"]
|
||||
assert isinstance(references, Dir)
|
||||
assert _children_keys(references) == {"docs/readme.md"}
|
||||
|
||||
assets = skill_entry.children["assets"]
|
||||
assert isinstance(assets, Dir)
|
||||
assert _children_keys(assets) == {"images/icon.txt"}
|
||||
|
||||
def test_from_source_is_mapped_to_skills_root(self) -> None:
|
||||
source = Dir(children={"imported": Dir(children={"SKILL.md": File(content=b"imported")})})
|
||||
capability = Skills(from_=source)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries[Path(".agents")] is source
|
||||
|
||||
def test_local_dir_from_source_stays_eager_by_default(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(from_=LocalDir(src=src_root))
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries[Path(".agents")].type == "local_dir"
|
||||
|
||||
def test_lazy_local_dir_source_skips_manifest_materialization(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries == {}
|
||||
|
||||
def test_lazy_local_dir_rejects_overlapping_manifest_entries(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={Path(".agents"): Dir()},
|
||||
)
|
||||
|
||||
with pytest.raises(SkillsConfigError) as exc_info:
|
||||
capability.process_manifest(manifest)
|
||||
|
||||
assert exc_info.value.message == "skills lazy_from path overlaps existing manifest entries"
|
||||
assert exc_info.value.context == {
|
||||
"path": ".agents",
|
||||
"source": "lazy_from",
|
||||
"overlaps": [".agents"],
|
||||
}
|
||||
|
||||
def test_literal_skills_allow_existing_manifest_entry_when_content_matches(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="Use this skill.",
|
||||
scripts={"run.sh": File(content=b"echo run")},
|
||||
)
|
||||
]
|
||||
)
|
||||
rendered_skill = capability.skills[0].as_dir_entry()
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={".agents/my-skill": rendered_skill},
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(manifest)
|
||||
|
||||
assert processed is manifest
|
||||
assert processed.entries[".agents/my-skill"] == rendered_skill
|
||||
|
||||
def test_process_manifest_rejects_exact_path_collision(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
manifest = Manifest(root="/workspace", entries={Path(".agents/my-skill"): Dir()})
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
capability.process_manifest(manifest)
|
||||
|
||||
def test_custom_skills_path_is_used_for_manifest_entries(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path=".sandbox/skills",
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
|
||||
assert processed.entries[Path(".sandbox/skills/my-skill")] == (
|
||||
capability.skills[0].as_dir_entry()
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsInstructions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_include_root_and_literal_index(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(name="z-skill", description="z description", content="z"),
|
||||
Skill(name="a-skill", description="a description", content="a"),
|
||||
]
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
assert instructions is not None
|
||||
assert instructions.startswith("## Skills\n")
|
||||
assert "### Available skills" in instructions
|
||||
assert "### How to use skills" in instructions
|
||||
assert "- a-skill: a description (file: .agents/a-skill)" in instructions
|
||||
assert "- z-skill: z description (file: .agents/z-skill)" in instructions
|
||||
assert instructions.index(
|
||||
"- a-skill: a description (file: .agents/a-skill)"
|
||||
) < instructions.index("- z-skill: z description (file: .agents/z-skill)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_use_custom_skills_path(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path=".sandbox/skills",
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is not None
|
||||
assert "- my-skill: desc (file: .sandbox/skills/my-skill)" in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_return_none_when_metadata_is_empty(self) -> None:
|
||||
capability = Skills(from_=Dir())
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_metadata_requires_extra_path_grant(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: hidden-skill\ndescription: outside base\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_resolve_from_runtime_frontmatter(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
capability = Skills(
|
||||
from_=Dir(
|
||||
children={
|
||||
"dynamic-skill": Dir(
|
||||
children={
|
||||
"SKILL.md": File(
|
||||
content=(
|
||||
b"---\n"
|
||||
b"name: discovered-skill\n"
|
||||
b"description: loaded from runtime frontmatter\n"
|
||||
b"---\n\n"
|
||||
b"# Skill\n"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
manifest = capability.process_manifest(Manifest(root=str(workspace_root)))
|
||||
session = _SkillsSession(manifest)
|
||||
await session.apply_manifest()
|
||||
capability.bind(session)
|
||||
|
||||
instructions = await capability.instructions(session.state.manifest)
|
||||
|
||||
assert instructions is not None
|
||||
assert (
|
||||
"- discovered-skill: loaded from runtime frontmatter (file: .agents/dynamic-skill)"
|
||||
) in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_resolve_opt_in_lazy_local_dir_metadata(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: discovered-skill\ndescription: local dir metadata\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
assert await capability.instructions(Manifest(root="/workspace")) is None
|
||||
|
||||
instructions = await capability.instructions(_source_granted_manifest(source=src_root))
|
||||
|
||||
assert instructions is not None
|
||||
assert (
|
||||
"- discovered-skill: local dir metadata (file: .agents/dynamic-skill)" in instructions
|
||||
)
|
||||
assert "Call `load_skill` with a single skill name from the list" in instructions
|
||||
assert "loaded on demand instead of being present up front" in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_metadata_skips_symlinked_skill_directory(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
outside_root = tmp_path / "outside"
|
||||
outside_skill = outside_root / "linked-skill"
|
||||
src_root.mkdir()
|
||||
outside_skill.mkdir(parents=True)
|
||||
(outside_skill / "SKILL.md").write_text(
|
||||
"---\nname: linked-skill\ndescription: linked metadata\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
(src_root / "linked-skill").symlink_to(outside_skill, target_is_directory=True)
|
||||
except OSError as e:
|
||||
pytest.skip(f"symlink unavailable: {e}")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(_source_granted_manifest(source=src_root))
|
||||
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_load_skill_tool_materializes_single_skill(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
manifest = capability.process_manifest(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
assert manifest.entries == {}
|
||||
|
||||
session = _SkillsSession(manifest)
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await session.read(Path(".agents/dynamic-skill/SKILL.md"))
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"skill_name":"dynamic-skill"}',
|
||||
)
|
||||
|
||||
assert output == {
|
||||
"status": "loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
loaded_skill = workspace_root / ".agents" / "dynamic-skill" / "SKILL.md"
|
||||
assert loaded_skill.read_text(encoding="utf-8") == "# dynamic skill\n"
|
||||
|
||||
|
||||
class TestSkillsLazyLoading:
|
||||
def test_tools_returns_empty_without_lazy_source(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
|
||||
assert capability.tools() == []
|
||||
|
||||
def test_lazy_tools_require_bound_session(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
with pytest.raises(ValueError, match="Skills is not bound to a SandboxSession"):
|
||||
capability.tools()
|
||||
|
||||
def test_lazy_tools_expose_load_skill_after_bind(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 1
|
||||
assert isinstance(tools[0], FunctionTool)
|
||||
assert tools[0].name == "load_skill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_non_lazy_capability(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("my-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_returns_already_loaded_for_existing_materialized_skill(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
|
||||
capability.bind(session)
|
||||
await session.write(
|
||||
Path(".agents/dynamic-skill/SKILL.md"),
|
||||
io.BytesIO(b"# already loaded\n"),
|
||||
)
|
||||
|
||||
output = await capability.load_skill("dynamic-skill")
|
||||
|
||||
assert output == {
|
||||
"status": "already_loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_materializes_with_bound_run_as_user(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
|
||||
capability.bind(session)
|
||||
capability.bind_run_as(User(name="sandbox-user"))
|
||||
|
||||
output = await capability.load_skill("dynamic-skill")
|
||||
|
||||
assert output == {
|
||||
"status": "loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
assert session.read_users == ["sandbox-user"]
|
||||
assert session.write_users == ["sandbox-user"]
|
||||
assert session.mkdir_users
|
||||
assert set(session.mkdir_users) == {"sandbox-user"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_missing_lazy_source_directory(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=tmp_path / "missing-skills"))
|
||||
)
|
||||
capability.bind(
|
||||
_SkillsSession(
|
||||
_source_granted_manifest(workspace_root, source=tmp_path / "missing-skills")
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("missing-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_ambiguous_skill_name(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
first_dir = src_root / "skill-one"
|
||||
second_dir = src_root / "skill-two"
|
||||
first_dir.mkdir(parents=True)
|
||||
second_dir.mkdir(parents=True)
|
||||
(first_dir / "SKILL.md").write_text(
|
||||
"---\nname: shared-skill\ndescription: first\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(second_dir / "SKILL.md").write_text(
|
||||
"---\nname: shared-skill\ndescription: second\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("shared-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_metadata_cache_is_reset_on_bind(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
"---\nname: cached-skill\ndescription: old description\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
first_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
skill_md.write_text(
|
||||
"---\nname: cached-skill\ndescription: new description\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
second_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
third_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
|
||||
assert first_instructions is not None
|
||||
assert second_instructions is not None
|
||||
assert third_instructions is not None
|
||||
assert "- cached-skill: old description (file: .agents/dynamic-skill)" in first_instructions
|
||||
assert (
|
||||
"- cached-skill: old description (file: .agents/dynamic-skill)" in second_instructions
|
||||
)
|
||||
assert "- cached-skill: new description (file: .agents/dynamic-skill)" in third_instructions
|
||||
@@ -0,0 +1,200 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.capabilities.tools import ViewImageTool
|
||||
from agents.sandbox.errors import WorkspaceReadNotFoundError
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
from agents.tool import ToolOutputImage
|
||||
from agents.tool_context import ToolContext
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
_PNG_BASE64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a84QAAAAASUVORK5CYII="
|
||||
)
|
||||
_PNG_BYTES = base64.b64decode(_PNG_BASE64)
|
||||
|
||||
|
||||
class _ImageSession(BaseSandboxSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest,
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
)
|
||||
self.files: dict[Path, bytes] = {}
|
||||
self.read_users: list[str | None] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
return None
|
||||
|
||||
async def stop(self) -> None:
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return None
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO:
|
||||
self.read_users.append(user.name if isinstance(user, User) else user)
|
||||
normalized = self.normalize_path(path)
|
||||
if normalized not in self.files:
|
||||
raise FileNotFoundError(normalized)
|
||||
return io.BytesIO(self.files[normalized])
|
||||
|
||||
async def write(
|
||||
self,
|
||||
path: Path,
|
||||
data: io.IOBase,
|
||||
*,
|
||||
user: str | User | None = None,
|
||||
) -> None:
|
||||
_ = user
|
||||
normalized = self.normalize_path(path)
|
||||
payload = data.read()
|
||||
if isinstance(payload, str):
|
||||
self.files[normalized] = payload.encode("utf-8")
|
||||
else:
|
||||
self.files[normalized] = bytes(payload)
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = (command, timeout)
|
||||
raise AssertionError("_exec_internal() should not be called")
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
|
||||
class _ProviderNotFoundImageSession(_ImageSession):
|
||||
async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO:
|
||||
self.read_users.append(user.name if isinstance(user, User) else user)
|
||||
normalized = self.normalize_path(path)
|
||||
if normalized in self.files:
|
||||
return io.BytesIO(self.files[normalized])
|
||||
raise WorkspaceReadNotFoundError(path=normalized)
|
||||
|
||||
|
||||
class TestViewImageTool:
|
||||
def test_view_image_accepts_needs_approval_setting(self) -> None:
|
||||
session = _ImageSession(Manifest(root="/workspace"))
|
||||
|
||||
async def needs_approval(_ctx: object, params: dict[str, object], _call_id: str) -> bool:
|
||||
return str(params["path"]).startswith("sensitive/")
|
||||
|
||||
tool = ViewImageTool(session=session, needs_approval=needs_approval)
|
||||
|
||||
assert cast(object, tool.needs_approval) is needs_approval
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_image_returns_tool_output_image_for_png(self) -> None:
|
||||
session = _ImageSession(Manifest(root="/workspace"))
|
||||
session.files[Path("/workspace/images/dot.png")] = _PNG_BYTES
|
||||
tool = ViewImageTool(session=session)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"path":"images/dot.png"}',
|
||||
)
|
||||
|
||||
assert isinstance(output, ToolOutputImage)
|
||||
assert output.image_url == f"data:image/png;base64,{_PNG_BASE64}"
|
||||
assert output.detail is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_image_reads_as_bound_user(self) -> None:
|
||||
session = _ImageSession(Manifest(root="/workspace"))
|
||||
session.files[Path("/workspace/images/dot.png")] = _PNG_BYTES
|
||||
tool = ViewImageTool(session=session, user=User(name="sandbox-user"))
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"path":"images/dot.png"}',
|
||||
)
|
||||
|
||||
assert isinstance(output, ToolOutputImage)
|
||||
assert session.read_users == ["sandbox-user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_image_rejects_non_image_files(self) -> None:
|
||||
session = _ImageSession(Manifest(root="/workspace"))
|
||||
session.files[Path("/workspace/notes.txt")] = b"hello\n"
|
||||
tool = ViewImageTool(session=session)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"path":"notes.txt"}',
|
||||
)
|
||||
|
||||
assert output == "image path `notes.txt` is not a supported image file"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_image_rejects_images_larger_than_10mb(self) -> None:
|
||||
session = _ImageSession(Manifest(root="/workspace"))
|
||||
session.files[Path("/workspace/images/huge.png")] = b"\x89PNG\r\n\x1a\n" + (
|
||||
b"0" * (_MAX_IMAGE_BYTES + 1)
|
||||
)
|
||||
tool = ViewImageTool(session=session)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"path":"images/huge.png"}',
|
||||
)
|
||||
|
||||
assert output == (
|
||||
"image path `images/huge.png` exceeded the allowed size of 10MB; "
|
||||
"resize or compress the image and try again"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_image_rejection_text_does_not_expose_provider_path(self) -> None:
|
||||
provider_root = Path("/provider/private/root")
|
||||
session = _ProviderNotFoundImageSession(Manifest(root=str(provider_root)))
|
||||
session.files[provider_root / "notes.txt"] = b"hello\n"
|
||||
session.files[provider_root / "images/huge.png"] = b"\x89PNG\r\n\x1a\n" + (
|
||||
b"0" * (_MAX_IMAGE_BYTES + 1)
|
||||
)
|
||||
tool = ViewImageTool(session=session)
|
||||
|
||||
missing_output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"path":"images/missing.png"}',
|
||||
)
|
||||
non_image_output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"path":"notes.txt"}',
|
||||
)
|
||||
huge_output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"path":"images/huge.png"}',
|
||||
)
|
||||
|
||||
outputs = [missing_output, non_image_output, huge_output]
|
||||
assert outputs == [
|
||||
"image path `images/missing.png` was not found",
|
||||
"image path `notes.txt` is not a supported image file",
|
||||
(
|
||||
"image path `images/huge.png` exceeded the allowed size of 10MB; "
|
||||
"resize or compress the image and try again"
|
||||
),
|
||||
]
|
||||
for output in outputs:
|
||||
assert isinstance(output, str)
|
||||
assert str(provider_root) not in output
|
||||
Reference in New Issue
Block a user