Files
2026-08-13 11:20:33 +09:00

776 lines
26 KiB
Python

from __future__ import annotations
import json
from typing import Any, cast
import pytest
from inline_snapshot import snapshot
from typing_extensions import TypedDict
from agents import (
Agent,
GuardrailFunctionOutput,
InputGuardrail,
InputGuardrailTripwireTriggered,
MaxTurnsExceeded,
ModelBehaviorError,
RunConfig,
RunContextWrapper,
RunHooks,
Runner,
TResponseInputItem,
_debug,
)
from agents.run_internal.error_handlers import attach_generic_agent_error
from agents.testing import ScriptedModel
from .test_responses import (
get_final_output_message,
get_function_tool,
get_function_tool_call,
get_handoff_tool_call,
get_text_message,
)
from .testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans, fetch_span_errors
@pytest.mark.asyncio
async def test_single_turn_model_error():
model = ScriptedModel(emit_traces=True)
model.enqueue(ValueError("test error"))
agent = Agent(
name="test_agent",
model=model,
)
with pytest.raises(ValueError):
await Runner.run(agent, input="first_test")
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"error": {"message": "Error in agent run", "data": {"error": "test error"}},
"data": {
"name": "test_agent",
"handoffs": [],
"tools": [],
"output_type": "str",
},
"children": [
{
"type": "generation",
"error": {
"message": "Error",
"data": {"name": "ValueError", "message": "test error"},
},
}
],
}
],
}
]
)
@pytest.mark.asyncio
async def test_multi_turn_no_handoffs():
model = ScriptedModel(emit_traces=True)
agent = Agent(
name="test_agent",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.extend(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))],
# Second turn: error
ValueError("test error"),
# Third turn: text message
[get_text_message("done")],
]
)
with pytest.raises(ValueError):
await Runner.run(agent, input="first_test")
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"error": {"message": "Error in agent run", "data": {"error": "test error"}},
"data": {
"name": "test_agent",
"handoffs": [],
"tools": ["foo"],
"output_type": "str",
},
"children": [
{"type": "generation"},
{
"type": "function",
"data": {
"name": "foo",
"input": '{"a": "b"}',
"output": "tool_result",
},
},
{
"type": "generation",
"error": {
"message": "Error",
"data": {"name": "ValueError", "message": "test error"},
},
},
],
}
],
}
]
)
@pytest.mark.asyncio
async def test_tool_call_error(monkeypatch: pytest.MonkeyPatch):
# Opt in to tool payload logging so the friendly "parsing tool arguments" message,
# which depends on inspecting the chained JSONDecodeError, is preserved.
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
model = ScriptedModel(emit_traces=True)
agent = Agent(
name="test_agent",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.extend(
[
[get_text_message("a_message"), get_function_tool_call("foo", "bad_json")],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="first_test")
tool_outputs = [item for item in result.new_items if item.type == "tool_call_output_item"]
assert tool_outputs, "Expected a tool output item for invalid JSON"
assert "An error occurred while parsing tool arguments" in str(tool_outputs[0].output)
assert "valid JSON" in str(tool_outputs[0].output)
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"data": {
"name": "test_agent",
"handoffs": [],
"tools": ["foo"],
"output_type": "str",
},
"children": [
{"type": "generation"},
{
"type": "function",
"error": {
"message": "Error running tool",
"data": {
"tool_name": "foo",
"error": "Expecting value: line 1 column 1 (char 0)",
},
},
"data": {
"name": "foo",
"input": "bad_json",
"output": (
"An error occurred while parsing tool arguments. "
"Please try again with valid JSON. Error: Expecting "
"value: line 1 column 1 (char 0)"
),
},
},
{"type": "generation"},
],
}
],
}
]
)
@pytest.mark.asyncio
async def test_multiple_handoff_doesnt_error():
model = ScriptedModel(emit_traces=True)
agent_1 = Agent(
name="test",
model=model,
)
agent_2 = Agent(
name="test",
model=model,
)
agent_3 = Agent(
name="test",
model=model,
handoffs=[agent_1, agent_2],
tools=[get_function_tool("some_function", "result")],
)
model.extend(
[
# First turn: a tool call
[get_function_tool_call("some_function", json.dumps({"a": "b"}))],
# Second turn: a message and 2 handoff
[
get_text_message("a_message"),
get_handoff_tool_call(agent_1, call_id="handoff_1"),
get_handoff_tool_call(agent_2, call_id="handoff_2"),
],
# Third turn: text message
[get_text_message("done")],
]
)
result = await Runner.run(agent_3, input="user_message")
assert result.last_agent == agent_1, "should have picked first handoff"
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"data": {
"name": "test",
"handoffs": ["test"],
"tools": ["some_function"],
"output_type": "str",
},
"children": [
{"type": "generation"},
{
"type": "function",
"data": {
"name": "some_function",
"input": '{"a": "b"}',
"output": "result",
},
},
{"type": "generation"},
{
"type": "handoff",
"data": {"from_agent": "test", "to_agent": "test"},
"error": {
"data": {
"requested_agents": [
"test",
"test",
],
},
"message": "Multiple handoffs requested",
},
},
],
},
{
"type": "agent",
"data": {"name": "test", "handoffs": [], "tools": [], "output_type": "str"},
"children": [{"type": "generation"}],
},
],
}
]
)
class Foo(TypedDict):
bar: str
@pytest.mark.asyncio
async def test_multiple_final_output_doesnt_error():
model = ScriptedModel(emit_traces=True)
agent_1 = Agent(
name="test",
model=model,
output_type=Foo,
)
model.enqueue(
[
get_final_output_message(json.dumps(Foo(bar="baz"))),
get_final_output_message(json.dumps(Foo(bar="abc"))),
]
)
result = await Runner.run(agent_1, input="user_message")
assert result.final_output == Foo(bar="abc")
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"data": {"name": "test", "handoffs": [], "tools": [], "output_type": "Foo"},
"children": [{"type": "generation"}],
}
],
}
]
)
@pytest.mark.asyncio
async def test_handoffs_lead_to_correct_agent_spans():
model = ScriptedModel(emit_traces=True)
agent_1 = Agent(
name="test_agent_1",
model=model,
tools=[get_function_tool("some_function", "result")],
)
agent_2 = Agent(
name="test_agent_2",
model=model,
handoffs=[agent_1],
tools=[get_function_tool("some_function", "result")],
)
agent_3 = Agent(
name="test_agent_3",
model=model,
handoffs=[agent_1, agent_2],
tools=[get_function_tool("some_function", "result")],
)
agent_1.handoffs.append(agent_3)
model.extend(
[
# First turn: a tool call
[get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_1")],
# Second turn: a message and 2 handoff
[
get_text_message("a_message"),
get_handoff_tool_call(agent_1),
get_handoff_tool_call(agent_2),
],
# Third turn: tool call
[get_function_tool_call("some_function", json.dumps({"a": "b"}), call_id="tool_2")],
# Fourth turn: handoff
[get_handoff_tool_call(agent_3)],
# Fifth turn: text message
[get_text_message("done")],
]
)
result = await Runner.run(agent_3, input="user_message")
assert result.last_agent == agent_3, (
f"should have ended on the third agent, got {result.last_agent.name}"
)
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"data": {
"name": "test_agent_3",
"handoffs": ["test_agent_1", "test_agent_2"],
"tools": ["some_function"],
"output_type": "str",
},
"children": [
{"type": "generation"},
{
"type": "function",
"data": {
"name": "some_function",
"input": '{"a": "b"}',
"output": "result",
},
},
{"type": "generation"},
{
"type": "handoff",
"data": {"from_agent": "test_agent_3", "to_agent": "test_agent_1"},
"error": {
"data": {
"requested_agents": [
"test_agent_1",
"test_agent_2",
],
},
"message": "Multiple handoffs requested",
},
},
],
},
{
"type": "agent",
"data": {
"name": "test_agent_1",
"handoffs": ["test_agent_3"],
"tools": ["some_function"],
"output_type": "str",
},
"children": [
{"type": "generation"},
{
"type": "function",
"data": {
"name": "some_function",
"input": '{"a": "b"}',
"output": "result",
},
},
{"type": "generation"},
{
"type": "handoff",
"data": {"from_agent": "test_agent_1", "to_agent": "test_agent_3"},
},
],
},
{
"type": "agent",
"data": {
"name": "test_agent_3",
"handoffs": ["test_agent_1", "test_agent_2"],
"tools": ["some_function"],
"output_type": "str",
},
"children": [{"type": "generation"}],
},
],
}
]
)
@pytest.mark.asyncio
async def test_max_turns_exceeded():
model = ScriptedModel(emit_traces=True)
agent = Agent(
name="test",
model=model,
output_type=Foo,
tools=[get_function_tool("foo", "result")],
)
model.extend(
[
[get_function_tool_call("foo", call_id="tool_1")],
[get_function_tool_call("foo", call_id="tool_2")],
[get_function_tool_call("foo", call_id="tool_3")],
[get_function_tool_call("foo", call_id="tool_4")],
[get_function_tool_call("foo", call_id="tool_5")],
]
)
with pytest.raises(MaxTurnsExceeded):
await Runner.run(agent, input="user_message", max_turns=2)
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"error": {"message": "Max turns exceeded", "data": {"max_turns": 2}},
"data": {
"name": "test",
"handoffs": [],
"tools": ["foo"],
"output_type": "Foo",
},
"children": [
{"type": "generation"},
{
"type": "function",
"data": {"name": "foo", "input": "", "output": "result"},
},
{"type": "generation"},
{
"type": "function",
"data": {"name": "foo", "input": "", "output": "result"},
},
],
}
],
}
]
)
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem]
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
@pytest.mark.asyncio
async def test_guardrail_error():
agent = Agent(
name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)]
)
model = ScriptedModel()
model.enqueue([get_text_message("some_message")])
with pytest.raises(InputGuardrailTripwireTriggered):
await Runner.run(agent, input="user_message")
assert fetch_normalized_spans() == snapshot(
[
{
"workflow_name": "Agent workflow",
"children": [
{
"type": "agent",
"error": {
"message": "Guardrail tripwire triggered",
"data": {"guardrail": "guardrail_function"},
},
"data": {"name": "test", "handoffs": [], "tools": [], "output_type": "str"},
"children": [
{
"type": "guardrail",
"data": {"name": "guardrail_function", "triggered": True},
}
],
}
],
}
]
)
SENSITIVE_ERROR_MESSAGE = "sensitive-error-detail"
def test_run_sync_marks_agent_span_with_generic_error():
model = ScriptedModel(emit_traces=True)
model.enqueue(ValueError("test error"))
with pytest.raises(ValueError, match="test error"):
Runner.run_sync(Agent(name="test_agent", model=model), input="first_test")
assert fetch_span_errors("agent") == [
{"message": "Error in agent run", "data": {"error": "test error"}}
]
@pytest.mark.asyncio
async def test_run_agent_span_error_matches_streamed_path():
"""The non-streamed and streamed paths record the same agent span error."""
non_streamed_model = ScriptedModel(emit_traces=True)
non_streamed_model.enqueue(ValueError("test error"))
with pytest.raises(ValueError):
await Runner.run(Agent(name="test_agent", model=non_streamed_model), input="first_test")
non_streamed_errors = fetch_span_errors("agent")
SPAN_PROCESSOR_TESTING.clear()
streamed_model = ScriptedModel(emit_traces=True)
streamed_model.enqueue(ValueError("test error"))
result = Runner.run_streamed(Agent(name="test_agent", model=streamed_model), input="first_test")
with pytest.raises(ValueError):
async for _ in result.stream_events():
pass
assert non_streamed_errors == fetch_span_errors("agent")
@pytest.mark.asyncio
async def test_run_agent_span_error_redacts_sensitive_data():
model = ScriptedModel(emit_traces=False)
model.enqueue(ValueError(SENSITIVE_ERROR_MESSAGE))
with pytest.raises(ValueError):
await Runner.run(
Agent(name="test_agent", model=model),
input="first_test",
run_config=RunConfig(trace_include_sensitive_data=False),
)
assert fetch_span_errors("agent") == [
{
"message": "Error in agent run",
"data": {"error": "Error details are redacted."},
}
]
@pytest.mark.asyncio
async def test_run_does_not_mark_agent_span_for_model_behavior_error():
"""ModelBehaviorError is reported by the generation span, so the agent span stays clean."""
model = ScriptedModel(emit_traces=True)
model.enqueue(ModelBehaviorError("bad model output"))
with pytest.raises(ModelBehaviorError):
await Runner.run(Agent(name="test_agent", model=model), input="first_test")
assert fetch_span_errors("agent") == []
class UnformattableError(Exception):
"""An exception whose ``__str__`` raises, like an error with a broken custom formatter."""
def __init__(self) -> None:
super().__init__()
self.str_calls = 0
def __str__(self) -> str:
self.str_calls += 1
raise RuntimeError("__str__ is broken")
class BaseExceptionUnformattableError(UnformattableError):
"""An exception whose formatter raises outside the ``Exception`` hierarchy."""
def __str__(self) -> str:
self.str_calls += 1
raise KeyboardInterrupt("__str__ is broken")
class RaisingHooks(RunHooks[Any]):
"""Raises the given error from a run hook, i.e. from user code inside the agent span."""
def __init__(self, error: Exception) -> None:
self.error = error
async def on_agent_start(self, context: RunContextWrapper[Any], agent: Agent[Any]) -> None:
raise self.error
@pytest.mark.asyncio
async def test_run_propagates_exception_whose_str_raises():
"""Tracing must not replace the run exception when formatting it fails."""
error = UnformattableError()
with pytest.raises(UnformattableError) as exc_info:
await Runner.run(
Agent(name="test_agent", model=ScriptedModel(emit_traces=True)),
input="first_test",
hooks=RaisingHooks(error),
)
assert exc_info.value is error
assert fetch_span_errors("agent") == [
{"message": "Error in agent run", "data": {"error": "Error details are unavailable."}}
]
@pytest.mark.asyncio
async def test_streamed_run_propagates_exception_whose_str_raises():
"""The streamed path shares the helper, so it keeps the same guarantee."""
error = UnformattableError()
result = Runner.run_streamed(
Agent(name="test_agent", model=ScriptedModel(emit_traces=True)),
input="first_test",
hooks=RaisingHooks(error),
)
with pytest.raises(UnformattableError) as exc_info:
async for _ in result.stream_events():
pass
assert exc_info.value is error
assert fetch_span_errors("agent") == [
{"message": "Error in agent run", "data": {"error": "Error details are unavailable."}}
]
class RecordingSpan:
"""The subset of the span API the generic agent-error helper uses."""
def __init__(self) -> None:
self.error: Any = None
def set_error(self, error: Any) -> None:
self.error = error
class FailingRecordingSpan:
"""A custom span that fails while the generic error is inspected or attached."""
def __init__(self, failure_point: str) -> None:
self.failure_point = failure_point
@property
def error(self) -> Any:
if self.failure_point == "read":
raise RuntimeError("span error read failed")
return None
def set_error(self, error: Any) -> None:
raise RuntimeError("span set_error failed")
@pytest.mark.parametrize("failure_point", ["read", "write"])
def test_span_failure_cannot_replace_the_run_exception(failure_point: str):
"""A custom span failure is contained so the original run exception is re-raised."""
original_error = ValueError("original run error")
with pytest.raises(ValueError) as exc_info:
try:
raise original_error
except ValueError as error:
attach_generic_agent_error(
cast(Any, FailingRecordingSpan(failure_point)),
error,
trace_include_sensitive_data=True,
)
raise
assert exc_info.value is original_error
def test_trace_formatting_failure_cannot_replace_the_run_exception():
"""Even a ``BaseException`` from ``__str__`` is contained at the trace-only boundary."""
span = RecordingSpan()
error = BaseExceptionUnformattableError()
attach_generic_agent_error(cast(Any, span), error, trace_include_sensitive_data=True)
assert error.str_calls == 1
assert span.error == {
"message": "Error in agent run",
"data": {"error": "Error details are unavailable."},
}
def test_redacted_tracing_never_stringifies_the_exception():
"""With redaction on, the detail is fixed, so the exception is never formatted at all."""
span = RecordingSpan()
error = UnformattableError()
attach_generic_agent_error(cast(Any, span), error, trace_include_sensitive_data=False)
assert error.str_calls == 0
assert span.error == {
"message": "Error in agent run",
"data": {"error": "Error details are redacted."},
}