350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""Tests for local shell tool execution.
|
|
|
|
These confirm that LocalShellAction.execute forwards the command to the executor
|
|
and that Runner.run executes local shell calls and records their outputs.
|
|
"""
|
|
|
|
import json
|
|
from typing import Any, cast
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import AsyncOpenAI
|
|
from openai.types.responses import ResponseOutputText
|
|
from openai.types.responses.response_input_param import LocalShellCallOutput
|
|
from openai.types.responses.response_output_item import LocalShellCall, LocalShellCallAction
|
|
|
|
from agents import (
|
|
Agent,
|
|
LocalShellCommandRequest,
|
|
LocalShellTool,
|
|
OpenAIResponsesModel,
|
|
RunConfig,
|
|
RunContextWrapper,
|
|
RunHooks,
|
|
Runner,
|
|
UserError,
|
|
)
|
|
from agents.items import ToolCallOutputItem
|
|
from agents.run_internal.run_loop import LocalShellAction, ToolRunLocalShellCall
|
|
from agents.run_state import RunState
|
|
from agents.testing import ScriptedModel
|
|
from tests.model_test_helpers import get_response_obj
|
|
|
|
from .test_responses import get_text_message
|
|
|
|
|
|
class RecordingLocalShellExecutor:
|
|
"""A `LocalShellTool` executor that records the requests it receives."""
|
|
|
|
def __init__(self, output: str = "shell output") -> None:
|
|
self.output = output
|
|
self.calls: list[LocalShellCommandRequest] = []
|
|
|
|
def __call__(self, request: LocalShellCommandRequest) -> str:
|
|
self.calls.append(request)
|
|
return self.output
|
|
|
|
|
|
async def _create_serialized_local_shell_state() -> tuple[LocalShellTool, dict[str, Any]]:
|
|
tool = LocalShellTool(executor=RecordingLocalShellExecutor(output="shell result"))
|
|
initial_model = ScriptedModel()
|
|
initial_agent = Agent(name="shell-agent", model=initial_model, tools=[tool])
|
|
local_shell_call = LocalShellCall(
|
|
id="lsh_test",
|
|
action=LocalShellCallAction(
|
|
command=["bash", "-c", "echo shell"],
|
|
env={},
|
|
type="exec",
|
|
timeout_ms=1000,
|
|
working_directory="/tmp",
|
|
),
|
|
call_id="call_local_shell",
|
|
status="completed",
|
|
type="local_shell_call",
|
|
)
|
|
initial_model.extend(
|
|
[
|
|
[get_text_message("running shell"), local_shell_call],
|
|
[get_text_message("shell complete")],
|
|
]
|
|
)
|
|
result = await Runner.run(initial_agent, input="please run shell")
|
|
return tool, json.loads(json.dumps(result.to_state().to_json()))
|
|
|
|
|
|
def _create_recording_responses_model() -> tuple[
|
|
OpenAIResponsesModel, list[httpx.Request], httpx.AsyncClient
|
|
]:
|
|
requests: list[httpx.Request] = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
200,
|
|
content=get_response_obj([get_text_message("resumed")]).model_dump_json(),
|
|
headers={"content-type": "application/json"},
|
|
request=request,
|
|
)
|
|
|
|
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
client = AsyncOpenAI(
|
|
api_key="test-key",
|
|
base_url="https://example.test/v1",
|
|
http_client=http_client,
|
|
)
|
|
return (
|
|
OpenAIResponsesModel(model="codex-mini-latest", openai_client=client),
|
|
requests,
|
|
http_client,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_shell_action_execute_invokes_executor() -> None:
|
|
executor = RecordingLocalShellExecutor(output="test output")
|
|
tool = LocalShellTool(executor=executor)
|
|
|
|
action = LocalShellCallAction(
|
|
command=["bash", "-c", "ls"],
|
|
env={"TEST": "value"},
|
|
type="exec",
|
|
timeout_ms=5000,
|
|
working_directory="/tmp",
|
|
)
|
|
tool_call = LocalShellCall(
|
|
id="lsh_123",
|
|
action=action,
|
|
call_id="call_456",
|
|
status="completed",
|
|
type="local_shell_call",
|
|
)
|
|
|
|
tool_run = ToolRunLocalShellCall(tool_call=tool_call, local_shell_tool=tool)
|
|
agent = Agent(name="test_agent", tools=[tool])
|
|
context_wrapper: RunContextWrapper[Any] = RunContextWrapper(context=None)
|
|
|
|
output_item = await LocalShellAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert len(executor.calls) == 1
|
|
request = executor.calls[0]
|
|
assert isinstance(request, LocalShellCommandRequest)
|
|
assert request.ctx_wrapper is context_wrapper
|
|
assert request.data is tool_call
|
|
assert request.data.action.command == ["bash", "-c", "ls"]
|
|
assert request.data.action.env == {"TEST": "value"}
|
|
assert request.data.action.timeout_ms == 5000
|
|
assert request.data.action.working_directory == "/tmp"
|
|
|
|
assert isinstance(output_item, ToolCallOutputItem)
|
|
assert output_item.agent is agent
|
|
assert output_item.output == "test output"
|
|
|
|
raw_item = output_item.raw_item
|
|
assert isinstance(raw_item, dict)
|
|
raw = cast(dict[str, Any], raw_item)
|
|
assert raw["type"] == "local_shell_call_output"
|
|
assert raw["call_id"] == "call_456"
|
|
assert raw["output"] == "test output"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_executes_local_shell_calls() -> None:
|
|
executor = RecordingLocalShellExecutor(output="shell result")
|
|
tool = LocalShellTool(executor=executor)
|
|
|
|
model = ScriptedModel()
|
|
agent = Agent(name="shell-agent", model=model, tools=[tool])
|
|
|
|
action = LocalShellCallAction(
|
|
command=["bash", "-c", "echo shell"],
|
|
env={},
|
|
type="exec",
|
|
timeout_ms=1000,
|
|
working_directory="/tmp",
|
|
)
|
|
local_shell_call = LocalShellCall(
|
|
id="lsh_test",
|
|
action=action,
|
|
call_id="call_local_shell",
|
|
status="completed",
|
|
type="local_shell_call",
|
|
)
|
|
|
|
model.extend(
|
|
[
|
|
[get_text_message("running shell"), local_shell_call],
|
|
[get_text_message("shell complete")],
|
|
]
|
|
)
|
|
|
|
result = await Runner.run(agent, input="please run shell")
|
|
|
|
assert len(executor.calls) == 1
|
|
request = executor.calls[0]
|
|
assert isinstance(request, LocalShellCommandRequest)
|
|
assert request.data == local_shell_call
|
|
assert request.data is not local_shell_call
|
|
|
|
items = result.new_items
|
|
assert len(items) == 4
|
|
|
|
message_before = items[0]
|
|
assert message_before.type == "message_output_item"
|
|
first_content = message_before.raw_item.content[0]
|
|
assert isinstance(first_content, ResponseOutputText)
|
|
assert first_content.text == "running shell"
|
|
|
|
tool_call_item = items[1]
|
|
assert tool_call_item.type == "tool_call_item"
|
|
assert tool_call_item.raw_item == local_shell_call
|
|
assert tool_call_item.raw_item is not local_shell_call
|
|
|
|
local_shell_output = items[2]
|
|
assert isinstance(local_shell_output, ToolCallOutputItem)
|
|
assert isinstance(local_shell_output.raw_item, dict)
|
|
assert local_shell_output.raw_item.get("type") == "local_shell_call_output"
|
|
assert local_shell_output.output == "shell result"
|
|
|
|
message_after = items[3]
|
|
assert message_after.type == "message_output_item"
|
|
last_content = message_after.raw_item.content[0]
|
|
assert isinstance(last_content, ResponseOutputText)
|
|
assert last_content.text == "shell complete"
|
|
|
|
assert result.final_output == "shell complete"
|
|
assert len(result.raw_responses) == 2
|
|
|
|
|
|
@pytest.mark.allow_call_model_methods
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"schema_version",
|
|
[None, "1.13"],
|
|
ids=["current", "v0.19.4"],
|
|
)
|
|
async def test_local_shell_output_survives_run_state_resume(schema_version: str | None) -> None:
|
|
tool, serialized = await _create_serialized_local_shell_state()
|
|
if schema_version is not None:
|
|
serialized["$schemaVersion"] = schema_version
|
|
|
|
resumed_model, requests, http_client = _create_recording_responses_model()
|
|
try:
|
|
resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool])
|
|
resumed_state = await RunState.from_json(resumed_agent, serialized)
|
|
|
|
shell_outputs = [
|
|
item.raw_item
|
|
for item in resumed_state._generated_items
|
|
if isinstance(item, ToolCallOutputItem)
|
|
and isinstance(item.raw_item, dict)
|
|
and item.raw_item.get("type") == "local_shell_call_output"
|
|
]
|
|
assert shell_outputs == [
|
|
{
|
|
"type": "local_shell_call_output",
|
|
"call_id": "call_local_shell",
|
|
"output": "shell result",
|
|
}
|
|
]
|
|
|
|
await Runner.run(resumed_agent, resumed_state)
|
|
finally:
|
|
await http_client.aclose()
|
|
|
|
assert len(requests) == 1
|
|
request_body = json.loads(requests[0].content)
|
|
replayed = [item for item in request_body["input"] if isinstance(item, dict)]
|
|
replayed_call = next(item for item in replayed if item.get("type") == "local_shell_call")
|
|
replayed_output = next(
|
|
item for item in replayed if item.get("type") == "local_shell_call_output"
|
|
)
|
|
assert replayed_call["call_id"] == replayed_output["call_id"] == "call_local_shell"
|
|
assert "id" not in replayed_output
|
|
|
|
|
|
@pytest.mark.allow_call_model_methods
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"schema_version",
|
|
[None, "1.13"],
|
|
ids=["current", "v0.19.4"],
|
|
)
|
|
async def test_run_state_rejects_id_only_local_shell_output(schema_version: str | None) -> None:
|
|
tool, serialized = await _create_serialized_local_shell_state()
|
|
invalid_output = {
|
|
"type": "local_shell_call_output",
|
|
"id": "legacy-only",
|
|
"output": "shell result",
|
|
}
|
|
for item_group in ("generated_items", "session_items"):
|
|
output_items = [
|
|
item
|
|
for item in serialized[item_group]
|
|
if item.get("raw_item", {}).get("type") == "local_shell_call_output"
|
|
]
|
|
assert len(output_items) == 1
|
|
output_items[0]["raw_item"] = invalid_output.copy()
|
|
if schema_version is not None:
|
|
serialized["$schemaVersion"] = schema_version
|
|
|
|
resumed_model, requests, http_client = _create_recording_responses_model()
|
|
resumed_agent = Agent(name="shell-agent", model=resumed_model, tools=[tool])
|
|
try:
|
|
if schema_version is None:
|
|
with pytest.raises(
|
|
UserError,
|
|
match="completed tool invocation does not match a restored tool call and output",
|
|
) as exc_info:
|
|
await RunState.from_json(resumed_agent, serialized)
|
|
assert "call_local_shell" not in str(exc_info.value)
|
|
else:
|
|
resumed_state = await RunState.from_json(resumed_agent, serialized)
|
|
await Runner.run(resumed_agent, resumed_state)
|
|
finally:
|
|
await http_client.aclose()
|
|
|
|
if schema_version is None:
|
|
assert requests == []
|
|
else:
|
|
assert len(requests) == 1
|
|
request_body = json.loads(requests[0].content)
|
|
replayed_types = {
|
|
item.get("type") for item in request_body["input"] if isinstance(item, dict)
|
|
}
|
|
assert "local_shell_call" not in replayed_types
|
|
assert "local_shell_call_output" not in replayed_types
|
|
|
|
|
|
@pytest.mark.allow_call_model_methods
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"schema_version",
|
|
[None, "1.13"],
|
|
ids=["current", "v0.19.4"],
|
|
)
|
|
async def test_run_state_preserves_official_local_shell_original_input(
|
|
schema_version: str | None,
|
|
) -> None:
|
|
original_input: LocalShellCallOutput = {
|
|
"type": "local_shell_call_output",
|
|
"id": "lsh_output_123",
|
|
"output": "shell result",
|
|
}
|
|
model = ScriptedModel()
|
|
model.extend([[get_text_message("complete")]])
|
|
agent = Agent(name="shell-agent", model=model)
|
|
result = await Runner.run(agent, input=[original_input])
|
|
serialized = json.loads(json.dumps(result.to_state().to_json()))
|
|
if schema_version is not None:
|
|
serialized["$schemaVersion"] = schema_version
|
|
|
|
restored_state = await RunState.from_json(agent, serialized)
|
|
assert restored_state.to_json()["original_input"] == [original_input]
|