chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
@@ -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