184 lines
6.1 KiB
Python
184 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from agents import RunConfig, Runner, function_tool
|
|
from agents.items import RunItem, ToolCallOutputItem
|
|
from agents.run_state import RunState
|
|
from agents.sandbox import SandboxAgent, SandboxRunConfig
|
|
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
|
from agents.sandbox.session import CallbackSink, Instrumentation, SandboxSessionEvent
|
|
from tests.sandbox.integration_tests._helpers import (
|
|
SandboxFileCapability,
|
|
SandboxLifecycleProbeCapability,
|
|
build_manifest_with_all_entry_types,
|
|
create_local_sources,
|
|
install_mock_external_tools,
|
|
)
|
|
from tests.sandbox.integration_tests.test_model import TestModel
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_preserves_unix_local_lifecycle_state_across_pause_and_resume(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
install_mock_external_tools(monkeypatch, tmp_path)
|
|
source_root = create_local_sources(tmp_path)
|
|
manifest = build_manifest_with_all_entry_types(
|
|
workspace_root=Path("/workspace"),
|
|
source_root=source_root,
|
|
)
|
|
events: list[SandboxSessionEvent] = []
|
|
client = UnixLocalSandboxClient(
|
|
instrumentation=Instrumentation(
|
|
sinks=[CallbackSink(lambda event, _session: events.append(event), mode="sync")]
|
|
)
|
|
)
|
|
model = TestModel()
|
|
model.queue_function_call(
|
|
"assert_manifest_materialized",
|
|
{},
|
|
call_id="call_manifest_materialized",
|
|
)
|
|
model.queue_function_call(
|
|
"write_file",
|
|
{"path": "runtime_note.txt", "content": "runtime note v1\n"},
|
|
call_id="call_write_runtime_note",
|
|
)
|
|
model.queue_function_call(
|
|
"apply_lifecycle_patch",
|
|
{},
|
|
call_id="call_apply_lifecycle_patch",
|
|
)
|
|
model.queue_function_call(
|
|
"assert_workspace_escape_blocked",
|
|
{},
|
|
call_id="call_assert_workspace_escape_blocked",
|
|
)
|
|
model.queue_function_call(
|
|
"extract_lifecycle_archive",
|
|
{},
|
|
call_id="call_extract_lifecycle_archive",
|
|
)
|
|
model.queue_function_call(
|
|
"start_lifecycle_pty",
|
|
{},
|
|
call_id="call_start_lifecycle_pty",
|
|
)
|
|
model.queue_function_call("approval_tool", {}, call_id="call_approval")
|
|
|
|
@function_tool(name_override="approval_tool", needs_approval=True)
|
|
def approval_tool() -> str:
|
|
return "approved"
|
|
|
|
agent = SandboxAgent(
|
|
name="sandbox",
|
|
model=model,
|
|
instructions="Use the sandbox lifecycle tools.",
|
|
default_manifest=manifest,
|
|
tools=[approval_tool],
|
|
capabilities=[SandboxFileCapability(), SandboxLifecycleProbeCapability()],
|
|
)
|
|
|
|
first_run = await Runner.run(
|
|
agent,
|
|
"verify the UnixLocal sandbox lifecycle and wait for approval",
|
|
run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
|
|
)
|
|
|
|
assert _tool_outputs(first_run.new_items, agent=agent) == [
|
|
"manifest materialized",
|
|
"wrote runtime_note.txt",
|
|
"lifecycle patch applied",
|
|
"workspace escape blocked",
|
|
"archive extracted",
|
|
"pty started and echoed stdin",
|
|
]
|
|
assert len(first_run.interruptions) == 1
|
|
state = first_run.to_state()
|
|
assert state._sandbox is not None
|
|
assert state._sandbox["backend_id"] == "unix_local"
|
|
assert state._sandbox["current_agent_name"] == "sandbox"
|
|
session_state = state._sandbox["session_state"]
|
|
assert isinstance(session_state, dict)
|
|
snapshot = session_state["snapshot"]
|
|
assert isinstance(snapshot, dict)
|
|
assert snapshot["type"] == "local"
|
|
assert session_state["workspace_root_owned"] is True
|
|
assert session_state["workspace_root_ready"] is True
|
|
workspace_root = _session_state_manifest_root(session_state)
|
|
assert not workspace_root.exists()
|
|
assert _successful_event_count(events, op="stop") == 1
|
|
assert _successful_event_count(events, op="shutdown") == 1
|
|
|
|
resumed_model = TestModel()
|
|
resumed_model.queue_function_call(
|
|
"assert_restored_lifecycle_state",
|
|
{},
|
|
call_id="call_assert_restored_lifecycle_state",
|
|
)
|
|
resumed_model.queue_function_call(
|
|
"read_file",
|
|
{"path": "runtime_note.txt"},
|
|
call_id="call_read_runtime_note",
|
|
)
|
|
resumed_model.queue_final_output("done")
|
|
resumed_agent = SandboxAgent(
|
|
name="sandbox",
|
|
model=resumed_model,
|
|
instructions="Use the sandbox lifecycle tools.",
|
|
default_manifest=manifest,
|
|
tools=[approval_tool],
|
|
capabilities=[SandboxFileCapability(), SandboxLifecycleProbeCapability()],
|
|
)
|
|
|
|
restored_state = await RunState.from_json(resumed_agent, state.to_json())
|
|
restored_interruptions = restored_state.get_interruptions()
|
|
assert len(restored_interruptions) == 1
|
|
restored_state.approve(restored_interruptions[0])
|
|
|
|
resumed = await Runner.run(
|
|
resumed_agent,
|
|
restored_state,
|
|
run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
|
|
)
|
|
|
|
assert resumed.final_output == "done"
|
|
assert not workspace_root.exists()
|
|
assert _successful_event_count(events, op="stop") == 2
|
|
assert _successful_event_count(events, op="shutdown") == 2
|
|
assert _tool_outputs(resumed.new_items, agent=resumed_agent)[-3:] == [
|
|
"approved",
|
|
"restored lifecycle state verified",
|
|
"runtime note v1\n",
|
|
]
|
|
|
|
|
|
def _session_state_manifest_root(session_state: dict[str, object]) -> Path:
|
|
manifest = session_state["manifest"]
|
|
assert isinstance(manifest, dict)
|
|
root = manifest["root"]
|
|
assert isinstance(root, str)
|
|
return Path(root)
|
|
|
|
|
|
def _successful_event_count(events: list[SandboxSessionEvent], *, op: str) -> int:
|
|
return sum(
|
|
1
|
|
for event in events
|
|
if event.op == op and event.phase == "finish" and getattr(event, "ok", False) is True
|
|
)
|
|
|
|
|
|
def _tool_outputs(items: Sequence[RunItem], *, agent: SandboxAgent) -> list[str]:
|
|
outputs: list[str] = []
|
|
for item in items:
|
|
if isinstance(item, ToolCallOutputItem) and item.agent is agent:
|
|
assert isinstance(item.output, str)
|
|
outputs.append(item.output)
|
|
return outputs
|