Files
2026-07-13 12:39:17 +08:00

290 lines
9.3 KiB
Python

from __future__ import annotations
from typing import Any, cast
import pytest
from openai.types.responses import ResponseCustomToolCall
from openai.types.responses.response_computer_tool_call import (
ActionScreenshot,
ResponseComputerToolCall,
)
from agents import (
Agent,
ApplyPatchTool,
Computer,
ComputerTool,
CustomTool,
RunConfig,
RunContextWrapper,
RunHooks,
Runner,
UserError,
function_tool,
)
from agents.editor import ApplyPatchOperation, ApplyPatchResult
from agents.items import ToolCallOutputItem
from agents.run_internal.run_loop import (
ToolRunApplyPatchCall,
ToolRunComputerAction,
)
from agents.run_internal.run_steps import ToolRunCustom
from agents.run_internal.tool_actions import (
ApplyPatchAction,
ComputerAction,
CustomToolAction,
)
from agents.tool_context import ToolContext
from .fake_model import FakeModel
from .mcp.helpers import FakeMCPServer
from .test_apply_patch_tool import DummyApplyPatchCall
from .test_responses import get_function_tool_call, get_text_message
def _tool_output_items(items: list[Any]) -> list[ToolCallOutputItem]:
return [item for item in items if isinstance(item, ToolCallOutputItem)]
@pytest.mark.asyncio
async def test_function_tool_custom_data_is_attached_but_not_replayed() -> None:
def extract_custom_data(ctx: Any) -> dict[str, Any]:
ctx.raw_item["renderer"] = "chart"
return {"call_id": ctx.raw_item["call_id"], "output": ctx.output}
@function_tool(custom_data_extractor=extract_custom_data)
def get_data() -> str:
return "tool_result"
model = FakeModel()
model.add_multiple_turn_outputs(
[
[get_text_message("call tool"), get_function_tool_call("get_data", "{}")],
[get_text_message("done")],
]
)
agent = Agent(name="test", model=model, tools=[get_data])
result = await Runner.run(agent, input="user")
tool_output = _tool_output_items(result.new_items)[0]
assert tool_output.custom_data == {"call_id": "2", "output": "tool_result"}
replay_payload = tool_output.to_input_item()
assert isinstance(replay_payload, dict)
assert "custom_data" not in replay_payload
assert "renderer" not in replay_payload
assert "renderer" not in cast(dict[str, Any], tool_output.raw_item)
assert all(
not (isinstance(item, dict) and "custom_data" in item)
for item in model.last_turn_args["input"]
)
@pytest.mark.asyncio
async def test_function_tool_custom_data_rejects_non_json_compatible_data() -> None:
@function_tool(custom_data_extractor=lambda _ctx: {"bad": object()})
def get_data() -> str:
return "tool_result"
model = FakeModel()
model.add_multiple_turn_outputs(
[[get_text_message("call tool"), get_function_tool_call("get_data", "{}")]]
)
agent = Agent(name="test", model=model, tools=[get_data])
with pytest.raises(UserError, match="custom_data_extractor must return JSON-compatible data"):
await Runner.run(agent, input="user")
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_value", [float("nan"), float("inf"), float("-inf")])
async def test_function_tool_custom_data_rejects_non_finite_floats(
bad_value: float,
) -> None:
@function_tool(custom_data_extractor=lambda _ctx: {"score": bad_value})
def get_data() -> str:
return "tool_result"
model = FakeModel()
model.add_multiple_turn_outputs(
[[get_text_message("call tool"), get_function_tool_call("get_data", "{}")]]
)
agent = Agent(name="test", model=model, tools=[get_data])
with pytest.raises(UserError, match="custom_data_extractor must return JSON-compatible data"):
await Runner.run(agent, input="user")
@pytest.mark.asyncio
async def test_mcp_custom_data_extractor_maps_result_meta_to_tool_output_item() -> None:
def extract_custom_data(ctx: Any) -> dict[str, Any]:
return {"mcp_response_meta": dict(ctx.result_meta or {})}
server = FakeMCPServer(custom_data_extractor=extract_custom_data)
server.add_tool("meta_tool", {})
server._response_meta = {"chart": {"type": "line"}}
model = FakeModel()
model.add_multiple_turn_outputs(
[
[get_text_message("call tool"), get_function_tool_call("meta_tool", "{}")],
[get_text_message("done")],
]
)
agent = Agent(name="test", model=model, mcp_servers=[server])
result = await Runner.run(agent, input="user")
tool_output = _tool_output_items(result.new_items)[0]
assert tool_output.custom_data == {"mcp_response_meta": {"chart": {"type": "line"}}}
@pytest.mark.asyncio
async def test_custom_tool_custom_data_is_attached() -> None:
async def invoke(_ctx: ToolContext[Any], raw_input: str) -> str:
return raw_input.upper()
def extract_custom_data(ctx: Any) -> dict[str, Any]:
ctx.raw_item["renderer"] = "chart"
return {"input": ctx.input, "output": ctx.output}
tool = CustomTool(
name="raw_editor",
description="Edit raw text.",
on_invoke_tool=invoke,
format={"type": "text"},
custom_data_extractor=extract_custom_data,
)
agent = Agent(name="custom-agent", tools=[tool])
tool_call = ResponseCustomToolCall(
type="custom_tool_call",
name="raw_editor",
call_id="call_custom",
input="hello",
)
result = await CustomToolAction.execute(
agent=agent,
call=ToolRunCustom(tool_call=tool_call, custom_tool=tool),
hooks=RunHooks[Any](),
context_wrapper=RunContextWrapper(context=None),
config=RunConfig(),
)
assert isinstance(result, ToolCallOutputItem)
assert result.custom_data == {"input": "hello", "output": "HELLO"}
assert "renderer" not in cast(dict[str, Any], result.raw_item)
class ScreenshotComputer(Computer):
def screenshot(self) -> str:
return "base64png"
def click(self, x: int, y: int, button: str) -> None:
pass
def double_click(self, x: int, y: int) -> None:
pass
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
def type(self, text: str) -> None:
pass
def wait(self) -> None:
pass
def move(self, x: int, y: int) -> None:
pass
def keypress(self, keys: list[str]) -> None:
pass
def drag(self, path: list[tuple[int, int]]) -> None:
pass
@pytest.mark.asyncio
async def test_computer_tool_custom_data_is_attached() -> None:
def extract_custom_data(ctx: Any) -> dict[str, Any]:
ctx.raw_item["output"]["image_url"] = "mutated"
return {"call_id": ctx.tool_call.call_id}
computer_tool = ComputerTool(
computer=ScreenshotComputer(),
custom_data_extractor=extract_custom_data,
)
tool_call = ResponseComputerToolCall(
id="computer_1",
type="computer_call",
action=ActionScreenshot(type="screenshot"),
call_id="call_computer",
pending_safety_checks=[],
status="completed",
)
agent = Agent(name="computer-agent", tools=[computer_tool])
result = await ComputerAction.execute(
agent=agent,
action=ToolRunComputerAction(tool_call=tool_call, computer_tool=computer_tool),
hooks=RunHooks[Any](),
context_wrapper=RunContextWrapper(context=None),
config=RunConfig(),
)
assert isinstance(result, ToolCallOutputItem)
assert result.custom_data == {"call_id": "call_computer"}
assert (
cast(dict[str, Any], result.raw_item)["output"]["image_url"]
== "data:image/png;base64,base64png"
)
class RecordingEditor:
def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
return ApplyPatchResult(output=f"Updated {operation.path}")
def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
return ApplyPatchResult(output=f"Created {operation.path}")
def delete_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
return ApplyPatchResult(output=f"Deleted {operation.path}")
@pytest.mark.asyncio
async def test_apply_patch_tool_custom_data_is_attached() -> None:
def extract_custom_data(ctx: Any) -> dict[str, Any]:
ctx.raw_item["status"] = "failed"
ctx.raw_item["renderer"] = "patch"
return {
"status": ctx.status,
"paths": [operation.path for operation in ctx.operations],
}
tool = ApplyPatchTool(
editor=RecordingEditor(),
custom_data_extractor=extract_custom_data,
)
call = DummyApplyPatchCall(
type="apply_patch_call",
call_id="call_patch",
operation={"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"},
)
agent = Agent(name="patch-agent", tools=[tool])
result = await ApplyPatchAction.execute(
agent=agent,
call=ToolRunApplyPatchCall(tool_call=call, apply_patch_tool=tool),
hooks=RunHooks[Any](),
context_wrapper=RunContextWrapper(context=None),
config=RunConfig(),
)
assert isinstance(result, ToolCallOutputItem)
assert result.custom_data == {"status": "completed", "paths": ["tasks.md"]}
replay_payload = cast(dict[str, Any], result.to_input_item())
assert "custom_data" not in replay_payload
assert "renderer" not in replay_payload
assert replay_payload["status"] == "completed"