Files
openai--openai-agents-python/tests/test_run_step_processing.py
T
Steve Coffey 2d665c9a67 Sandbox Agents (#2889)
### Sandbox Agents

This release adds **Sandbox Agents**, a beta SDK surface for running
agents with a persistent, isolated workspace. Sandbox agents keep the
normal `Agent` and `Runner` flow, but add workspace manifests,
sandbox-native capabilities, sandbox clients, snapshots, and resume
support so agents can work over real files, run commands, edit
repositories, generate artifacts, and continue work across runs.

Key pieces:

- `SandboxAgent`: an `Agent` with sandbox defaults such as
`default_manifest`, sandbox instructions, capabilities, and `run_as`.
- `Manifest`: a fresh-workspace contract for files, directories, local
files, local directories, Git repos, environment, users, groups, and
mounts.
- `SandboxRunConfig`: per-run sandbox wiring for client creation, live
session injection, serialized session resume, manifest overrides,
snapshots, and materialization concurrency limits.
- Built-in capabilities for shell access, filesystem editing and image
inspection, skills, memory, and compaction.
- Workspace snapshots and serialized sandbox session state for
reconnecting to existing work or seeding a fresh sandbox from saved
contents.

### Sandbox clients and hosted providers

Sandbox agents now support local, containerized, and hosted execution
backends:

- `UnixLocalSandboxClient` for fast local development.
- `DockerSandboxClient` for container isolation and image parity.
- Hosted sandbox clients for Blaxel, Cloudflare, Daytona, E2B, Modal,
Runloop, and Vercel through optional extras.

The release also adds provider-specific examples and mount strategies
for common storage backends, including S3, Cloudflare R2, Google Cloud
Storage, Azure Blob Storage, and S3 Files where supported by the
selected backend.

### Sandbox memory

Adds a sandbox memory capability that lets future sandbox-agent runs
learn from prior runs. Memory stores extracted lessons in the sandbox
workspace, injects a concise summary into later runs, and uses
progressive disclosure so agents can search deeper rollout summaries
only when useful.

Memory supports:

- Read-only or generate-only modes.
- Live updates when the agent discovers stale memory.
- Multi-turn grouping through `conversation_id`, SDK `Session`,
`RunConfig.group_id`, or generated run IDs.
- Separate memory layouts for isolating memory across agents or
workflows.
- S3-backed examples for persisted memory across runs.

### Workspace mounts, snapshots, and resume

This release adds a full workspace entry and mount model for sandbox
sessions:

- Local files and directories.
- Synthetic files and directories.
- Git repository entries.
- Remote storage mounts for S3, R2, GCS, Azure Blob Storage, and S3
Files.
- Provider-specific mount strategies across Docker, Modal, Cloudflare,
Blaxel, Daytona, E2B, and Runloop.
- Portable snapshots with path normalization, symlink preservation,
mount-safe snapshotting, and remote snapshot support.
- Resume paths through runner-managed `RunState`, explicit
`SandboxSessionState`, or saved snapshots.

### Examples and tutorials

Adds a large `examples/sandbox/` suite covering:

- Local Unix and Docker sandbox runners.
- Docker mount smoke tests for S3, GCS, Azure Blob Storage, and S3
Files.
- Sandbox coding tasks with skills.
- Sandbox agents as tools and handoff patterns.
- Memory examples, including multi-agent/multi-turn memory and S3-backed
memory.
- Tax-prep and healthcare-support workflows.
- Dataroom QA and metric extraction tutorials.
- Repository code review tutorial.
- Vision website clone tutorial.
- Provider examples for Blaxel, Cloudflare, Daytona, E2B, Modal,
Runloop, Temporal, and Vercel.

### Runtime, tracing, and model plumbing

The release includes the runtime plumbing needed to make sandbox agents
work naturally inside the existing SDK:

- Runner-managed sandbox preparation, capability binding, session
lifecycle, state serialization, and resume behavior.
- Sandbox-aware `RunState` serialization.
- Unified sandbox tracing with SDK spans.
- Token usage on tracing spans.
- Runner-managed prompt cache key defaults.
- OpenAI agent registration and harness ID configuration.
- Safer redaction of sensitive MCP tool outputs when sensitive tracing
is disabled.
- Additional OpenAI client/model utilities and Chat Completions
coverage.


## Documentation & Other Changes

- docs: add Asqav to external tracing processors list.
- docs: update translated document pages.

Co-authored-by: Abdulrahman Alfozan <alfozan@openai.com>
Co-authored-by: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com>
Co-authored-by: Andi Liu <andi@openai.com>
Co-authored-by: Aron <263346377+aron-cf@users.noreply.github.com>
Co-authored-by: ashwinnathan-openai <ashwinnathan@openai.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: cploujoux <cploujoux@blaxel.ai>
Co-authored-by: elainegan-openai <168589666+elainegan-openai@users.noreply.github.com>
Co-authored-by: Elias Freider <freider@users.noreply.github.com>
Co-authored-by: Erik Dunteman <erik@erikds-macbook-air.local>
Co-authored-by: Jason Liu <jasonliu@openai.com>
Co-authored-by: Jason Steving <32336750+jasonsteving99@users.noreply.github.com>
Co-authored-by: Kazuhiro Sera <seratch@openai.com>
Co-authored-by: Lovre Pešut <lovre.pesut@gmail.com>
Co-authored-by: Lucas Wang <lucas_wang@lucas-futures.com>
Co-authored-by: Matt Brockman <matt.brockman@e2b.dev>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
Co-authored-by: Naresh <ghostwriternr@gmail.com>
Co-authored-by: nicholasclark-openai <nicholasclark@openai.com>
Co-authored-by: qiyaoq-oai <qiyaoq@openai.com>
Co-authored-by: Scott Trinh <scott@scotttrinh.com>
Co-authored-by: tode-rl <tony@runloop.ai>
Co-authored-by: Wendy Jiao <wendyjiao@openai.com>
2026-04-15 10:00:40 -07:00

549 lines
17 KiB
Python

from __future__ import annotations
from typing import Any, cast
import pytest
from openai.types.responses import (
ResponseComputerToolCall,
ResponseFileSearchToolCall,
ResponseFunctionToolCall,
ResponseFunctionWebSearch,
)
from openai.types.responses.response_computer_tool_call import ActionClick
from openai.types.responses.response_function_web_search import ActionSearch
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
from pydantic import BaseModel
from agents import (
Agent,
Computer,
ComputerTool,
Handoff,
HandoffInputData,
ModelBehaviorError,
ModelResponse,
ReasoningItem,
RunConfig,
RunContextWrapper,
RunHooks,
RunItem,
ToolCallItem,
Usage,
handoff,
)
from agents.run_internal import run_loop
from agents.run_internal.run_loop import ToolRunHandoff, get_handoffs, get_output_schema
from .test_responses import (
get_final_output_message,
get_function_tool,
get_function_tool_call,
get_handoff_tool_call,
get_text_input_item,
get_text_message,
)
def _dummy_ctx() -> RunContextWrapper[None]:
return RunContextWrapper(context=None)
async def process_response(
agent: Agent[Any],
response: ModelResponse,
*,
output_schema: Any = None,
handoffs: list[Handoff[Any, Agent[Any]]] | None = None,
) -> Any:
"""Process a model response using the agent's tools and optional handoffs."""
return run_loop.process_model_response(
agent=agent,
response=response,
output_schema=output_schema,
handoffs=handoffs or [],
all_tools=await agent.get_all_tools(_dummy_ctx()),
)
def test_empty_response():
agent = Agent(name="test")
response = ModelResponse(
output=[],
usage=Usage(),
response_id=None,
)
result = run_loop.process_model_response(
agent=agent,
response=response,
output_schema=None,
handoffs=[],
all_tools=[],
)
assert not result.handoffs
assert not result.functions
def test_no_tool_calls():
agent = Agent(name="test")
response = ModelResponse(
output=[get_text_message("Hello, world!")],
usage=Usage(),
response_id=None,
)
result = run_loop.process_model_response(
agent=agent, response=response, output_schema=None, handoffs=[], all_tools=[]
)
assert not result.handoffs
assert not result.functions
@pytest.mark.asyncio
async def test_single_tool_call():
agent = Agent(name="test", tools=[get_function_tool(name="test")])
response = ModelResponse(
output=[
get_text_message("Hello, world!"),
get_function_tool_call("test", ""),
],
usage=Usage(),
response_id=None,
)
result = await process_response(agent=agent, response=response)
assert not result.handoffs
assert result.functions and len(result.functions) == 1
func = result.functions[0]
assert func.tool_call.name == "test"
assert func.tool_call.arguments == ""
@pytest.mark.asyncio
async def test_missing_tool_call_raises_error():
agent = Agent(name="test", tools=[get_function_tool(name="test")])
response = ModelResponse(
output=[
get_text_message("Hello, world!"),
get_function_tool_call("missing", ""),
],
usage=Usage(),
response_id=None,
)
with pytest.raises(ModelBehaviorError):
await process_response(agent=agent, response=response)
@pytest.mark.asyncio
async def test_multiple_tool_calls():
agent = Agent(
name="test",
tools=[
get_function_tool(name="test_1"),
get_function_tool(name="test_2"),
get_function_tool(name="test_3"),
],
)
response = ModelResponse(
output=[
get_text_message("Hello, world!"),
get_function_tool_call("test_1", "abc"),
get_function_tool_call("test_2", "xyz"),
],
usage=Usage(),
response_id=None,
)
result = await process_response(agent=agent, response=response)
assert not result.handoffs
assert result.functions and len(result.functions) == 2
func_1 = result.functions[0]
assert func_1.tool_call.name == "test_1"
assert func_1.tool_call.arguments == "abc"
func_2 = result.functions[1]
assert func_2.tool_call.name == "test_2"
assert func_2.tool_call.arguments == "xyz"
@pytest.mark.asyncio
async def test_handoffs_parsed_correctly():
agent_1 = Agent(name="test_1")
agent_2 = Agent(name="test_2")
agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2])
response = ModelResponse(
output=[get_text_message("Hello, world!")],
usage=Usage(),
response_id=None,
)
result = await process_response(agent=agent_3, response=response)
assert not result.handoffs, "Shouldn't have a handoff here"
response = ModelResponse(
output=[get_text_message("Hello, world!"), get_handoff_tool_call(agent_1)],
usage=Usage(),
response_id=None,
)
result = await process_response(
agent=agent_3,
response=response,
handoffs=await get_handoffs(agent_3, _dummy_ctx()),
)
assert len(result.handoffs) == 1, "Should have a handoff here"
handoff = result.handoffs[0]
assert handoff.handoff.tool_name == Handoff.default_tool_name(agent_1)
assert handoff.handoff.tool_description == Handoff.default_tool_description(agent_1)
assert handoff.handoff.agent_name == agent_1.name
handoff_agent = await handoff.handoff.on_invoke_handoff(
RunContextWrapper(None), handoff.tool_call.arguments
)
assert handoff_agent == agent_1
@pytest.mark.asyncio
async def test_handoff_can_disable_run_level_history_nesting(monkeypatch: pytest.MonkeyPatch):
source_agent = Agent(name="source")
target_agent = Agent(name="target")
override_handoff = handoff(target_agent, nest_handoff_history=False)
tool_call = cast(ResponseFunctionToolCall, get_handoff_tool_call(target_agent))
run_handoffs = [ToolRunHandoff(handoff=override_handoff, tool_call=tool_call)]
run_config = RunConfig(nest_handoff_history=True)
context_wrapper = RunContextWrapper(context=None)
hooks = RunHooks()
original_input = [get_text_input_item("hello")]
pre_step_items: list[RunItem] = []
new_step_items: list[RunItem] = []
new_response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None)
calls: list[HandoffInputData] = []
def fake_nest(
handoff_input_data: HandoffInputData,
*,
history_mapper: Any,
) -> HandoffInputData:
_ = history_mapper
calls.append(handoff_input_data)
return handoff_input_data
monkeypatch.setattr("agents.run_internal.turn_resolution.nest_handoff_history", fake_nest)
result = await run_loop.execute_handoffs(
public_agent=source_agent,
original_input=list(original_input),
pre_step_items=pre_step_items,
new_step_items=new_step_items,
new_response=new_response,
run_handoffs=run_handoffs,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
)
assert calls == []
assert result.original_input == original_input
@pytest.mark.asyncio
async def test_handoff_can_enable_history_nesting(monkeypatch: pytest.MonkeyPatch):
source_agent = Agent(name="source")
target_agent = Agent(name="target")
override_handoff = handoff(target_agent, nest_handoff_history=True)
tool_call = cast(ResponseFunctionToolCall, get_handoff_tool_call(target_agent))
run_handoffs = [ToolRunHandoff(handoff=override_handoff, tool_call=tool_call)]
run_config = RunConfig(nest_handoff_history=False)
context_wrapper = RunContextWrapper(context=None)
hooks = RunHooks()
original_input = [get_text_input_item("hello")]
pre_step_items: list[RunItem] = []
new_step_items: list[RunItem] = []
new_response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None)
def fake_nest(
handoff_input_data: HandoffInputData,
*,
history_mapper: Any,
) -> HandoffInputData:
_ = history_mapper
return handoff_input_data.clone(
input_history=(
{
"role": "assistant",
"content": "nested",
},
)
)
monkeypatch.setattr("agents.run_internal.turn_resolution.nest_handoff_history", fake_nest)
result = await run_loop.execute_handoffs(
public_agent=source_agent,
original_input=list(original_input),
pre_step_items=pre_step_items,
new_step_items=new_step_items,
new_response=new_response,
run_handoffs=run_handoffs,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
)
assert result.original_input == [
{
"role": "assistant",
"content": "nested",
}
]
@pytest.mark.asyncio
async def test_missing_handoff_fails():
agent_1 = Agent(name="test_1")
agent_2 = Agent(name="test_2")
agent_3 = Agent(name="test_3", handoffs=[agent_1])
response = ModelResponse(
output=[get_text_message("Hello, world!"), get_handoff_tool_call(agent_2)],
usage=Usage(),
response_id=None,
)
with pytest.raises(ModelBehaviorError):
await process_response(
agent=agent_3,
response=response,
handoffs=await get_handoffs(agent_3, _dummy_ctx()),
)
@pytest.mark.asyncio
async def test_multiple_handoffs_doesnt_error():
agent_1 = Agent(name="test_1")
agent_2 = Agent(name="test_2")
agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2])
response = ModelResponse(
output=[
get_text_message("Hello, world!"),
get_handoff_tool_call(agent_1),
get_handoff_tool_call(agent_2),
],
usage=Usage(),
response_id=None,
)
result = await process_response(
agent=agent_3,
response=response,
handoffs=await get_handoffs(agent_3, _dummy_ctx()),
)
assert len(result.handoffs) == 2, "Should have multiple handoffs here"
class Foo(BaseModel):
bar: str
@pytest.mark.asyncio
async def test_final_output_parsed_correctly():
agent = Agent(name="test", output_type=Foo)
response = ModelResponse(
output=[
get_text_message("Hello, world!"),
get_final_output_message(Foo(bar="123").model_dump_json()),
],
usage=Usage(),
response_id=None,
)
await process_response(
agent=agent,
response=response,
output_schema=get_output_schema(agent),
)
@pytest.mark.asyncio
async def test_file_search_tool_call_parsed_correctly():
# Ensure that a ResponseFileSearchToolCall output is parsed into a ToolCallItem and that no tool
# runs are scheduled.
agent = Agent(name="test")
file_search_call = ResponseFileSearchToolCall(
id="fs1",
queries=["query"],
status="completed",
type="file_search_call",
)
response = ModelResponse(
output=[get_text_message("hello"), file_search_call],
usage=Usage(),
response_id=None,
)
result = await process_response(agent=agent, response=response)
# The final item should be a ToolCallItem for the file search call
assert any(
isinstance(item, ToolCallItem) and item.raw_item is file_search_call
for item in result.new_items
)
assert not result.functions
assert not result.handoffs
@pytest.mark.asyncio
async def test_function_web_search_tool_call_parsed_correctly():
agent = Agent(name="test")
web_search_call = ResponseFunctionWebSearch(
id="w1",
action=ActionSearch(type="search", query="query"),
status="completed",
type="web_search_call",
)
response = ModelResponse(
output=[get_text_message("hello"), web_search_call],
usage=Usage(),
response_id=None,
)
result = await process_response(agent=agent, response=response)
assert any(
isinstance(item, ToolCallItem) and item.raw_item is web_search_call
for item in result.new_items
)
assert not result.functions
assert not result.handoffs
@pytest.mark.asyncio
async def test_reasoning_item_parsed_correctly():
# Verify that a Reasoning output item is converted into a ReasoningItem.
reasoning = ResponseReasoningItem(
id="r1", type="reasoning", summary=[Summary(text="why", type="summary_text")]
)
response = ModelResponse(
output=[reasoning],
usage=Usage(),
response_id=None,
)
agent = Agent(name="test")
result = await process_response(agent=agent, response=response)
assert any(
isinstance(item, ReasoningItem) and item.raw_item is reasoning for item in result.new_items
)
class DummyComputer(Computer):
"""Minimal computer implementation for testing."""
@property
def environment(self):
return "mac" # pragma: no cover
@property
def dimensions(self):
return (0, 0) # pragma: no cover
def screenshot(self) -> str:
return "" # pragma: no cover
def click(self, x: int, y: int, button: str) -> None:
return None # pragma: no cover
def double_click(self, x: int, y: int) -> None:
return None # pragma: no cover
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
return None # pragma: no cover
def type(self, text: str) -> None:
return None # pragma: no cover
def wait(self) -> None:
return None # pragma: no cover
def move(self, x: int, y: int) -> None:
return None # pragma: no cover
def keypress(self, keys: list[str]) -> None:
return None # pragma: no cover
def drag(self, path: list[tuple[int, int]]) -> None:
return None # pragma: no cover
@pytest.mark.asyncio
async def test_computer_tool_call_without_computer_tool_raises_error():
# If the agent has no ComputerTool in its tools, process_model_response should raise a
# ModelBehaviorError when encountering a ResponseComputerToolCall.
computer_call = ResponseComputerToolCall(
id="c1",
type="computer_call",
action=ActionClick(type="click", x=1, y=2, button="left"),
call_id="c1",
pending_safety_checks=[],
status="completed",
)
response = ModelResponse(
output=[computer_call],
usage=Usage(),
response_id=None,
)
with pytest.raises(ModelBehaviorError):
await process_response(agent=Agent(name="test"), response=response)
@pytest.mark.asyncio
async def test_computer_tool_call_with_computer_tool_parsed_correctly():
# If the agent contains a ComputerTool, ensure that a ResponseComputerToolCall is parsed into a
# ToolCallItem and scheduled to run in computer_actions.
dummy_computer = DummyComputer()
agent = Agent(name="test", tools=[ComputerTool(computer=dummy_computer)])
computer_call = ResponseComputerToolCall(
id="c1",
type="computer_call",
action=ActionClick(type="click", x=1, y=2, button="left"),
call_id="c1",
pending_safety_checks=[],
status="completed",
)
response = ModelResponse(
output=[computer_call],
usage=Usage(),
response_id=None,
)
result = await process_response(agent=agent, response=response)
assert any(
isinstance(item, ToolCallItem) and item.raw_item is computer_call
for item in result.new_items
)
assert result.computer_actions and result.computer_actions[0].tool_call == computer_call
@pytest.mark.asyncio
async def test_tool_and_handoff_parsed_correctly():
agent_1 = Agent(name="test_1")
agent_2 = Agent(name="test_2")
agent_3 = Agent(
name="test_3", tools=[get_function_tool(name="test")], handoffs=[agent_1, agent_2]
)
response = ModelResponse(
output=[
get_text_message("Hello, world!"),
get_function_tool_call("test", "abc"),
get_handoff_tool_call(agent_1),
],
usage=Usage(),
response_id=None,
)
result = await process_response(
agent=agent_3,
response=response,
handoffs=await get_handoffs(agent_3, _dummy_ctx()),
)
assert result.functions and len(result.functions) == 1
assert len(result.handoffs) == 1, "Should have a handoff here"
handoff = result.handoffs[0]
assert handoff.handoff.tool_name == Handoff.default_tool_name(agent_1)
assert handoff.handoff.tool_description == Handoff.default_tool_description(agent_1)
assert handoff.handoff.agent_name == agent_1.name