Skip to content

Commit 77b9379

Browse files
hsusulclaude
andcommitted
fix(tracing): mark the agent span when a non-streaming run fails
The streamed run loop attaches an "Error in agent run" SpanError to the active agent span, but the non-streaming failure handler only recorded run_exception, so Runner.run() and Runner.run_sync() left the agent span unmarked. Failures raised outside a generation or function span, such as a lifecycle hook error, produced a trace with no error at all. Attach the same SpanError from the non-streaming handler. The span is left alone when it already carries a more specific error, such as "Max turns exceeded", and cancellation is excluded because it is not an agent failure. Move the shared exclusion predicate into error_handlers so both paths use one source of truth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c3f1781 commit 77b9379

4 files changed

Lines changed: 220 additions & 11 deletions

File tree

src/agents/run.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
create_message_output_item,
7474
format_final_output_text,
7575
resolve_run_error_handler_result,
76+
should_attach_generic_agent_error,
7677
validate_handler_final_output,
7778
)
7879
from .run_internal.items import (
@@ -1587,6 +1588,29 @@ def _finalize_result(result: RunResult) -> RunResult:
15871588
turn_result.new_step_items.clear()
15881589
except BaseException as exc:
15891590
run_exception = exc
1591+
if (
1592+
current_span is not None
1593+
and current_span.error is None
1594+
and isinstance(exc, Exception)
1595+
and should_attach_generic_agent_error(exc)
1596+
):
1597+
# Mirror the streamed run loop so both paths report the failing agent. A span
1598+
# that already carries a more specific error keeps it, and cancellation is
1599+
# excluded because it is not an agent failure.
1600+
_error_tracing.attach_error_to_span(
1601+
current_span,
1602+
SpanError(
1603+
message="Error in agent run",
1604+
data={
1605+
"error": _error_tracing.get_trace_error(
1606+
trace_include_sensitive_data=(
1607+
run_config.trace_include_sensitive_data
1608+
),
1609+
error_message=str(exc),
1610+
)
1611+
},
1612+
),
1613+
)
15901614
if isinstance(exc, AgentsException):
15911615
exc.run_data = RunErrorDetails(
15921616
input=original_input,

src/agents/run_internal/error_handlers.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88

99
from ..agent import Agent
1010
from ..agent_output import _WRAPPER_DICT_KEY, AgentOutputSchema
11-
from ..exceptions import MaxTurnsExceeded, ModelBehaviorError, ModelRefusalError, UserError
11+
from ..exceptions import (
12+
InputGuardrailTripwireTriggered,
13+
MaxTurnsExceeded,
14+
ModelBehaviorError,
15+
ModelRefusalError,
16+
OutputGuardrailTripwireTriggered,
17+
UserError,
18+
)
1219
from ..items import (
1320
ItemHelpers,
1421
MessageOutputItem,
@@ -30,6 +37,18 @@
3037
RunErrorHandlerKind = Literal["max_turns", "model_refusal", "invalid_final_output"]
3138

3239

40+
def should_attach_generic_agent_error(exc: Exception) -> bool:
41+
"""Return whether a failed run still needs the generic agent-span error.
42+
43+
Failures that already write their own agent-span error, or that a dedicated child span
44+
reports, are excluded so the span keeps the more specific diagnosis.
45+
"""
46+
return not isinstance(
47+
exc,
48+
ModelBehaviorError | InputGuardrailTripwireTriggered | OutputGuardrailTripwireTriggered,
49+
)
50+
51+
3352
def build_run_error_data(
3453
*,
3554
input: str | list[TResponseInputItem],

src/agents/run_internal/run_loop.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
create_message_output_item,
111111
format_final_output_text,
112112
resolve_run_error_handler_result,
113+
should_attach_generic_agent_error,
113114
validate_handler_final_output,
114115
)
115116
from .guardrails import (
@@ -284,13 +285,6 @@ def _agent_diagnostic_extra(agent: Agent[Any]) -> dict[str, object]:
284285
return {"agent_name": agent.name}
285286

286287

287-
def _should_attach_generic_agent_error(exc: Exception) -> bool:
288-
return not isinstance(
289-
exc,
290-
ModelBehaviorError | InputGuardrailTripwireTriggered | OutputGuardrailTripwireTriggered,
291-
)
292-
293-
294288
async def _should_persist_stream_items(
295289
*,
296290
session: Session | None,
@@ -1247,7 +1241,7 @@ async def _save_stream_items_without_count(
12471241
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
12481242
break
12491243
except Exception as e:
1250-
if current_span and _should_attach_generic_agent_error(e):
1244+
if current_span and should_attach_generic_agent_error(e):
12511245
_error_tracing.attach_error_to_span(
12521246
current_span,
12531247
SpanError(
@@ -1277,7 +1271,7 @@ async def _save_stream_items_without_count(
12771271
)
12781272
raise
12791273
except Exception as e:
1280-
if current_span and _should_attach_generic_agent_error(e):
1274+
if current_span and should_attach_generic_agent_error(e):
12811275
_error_tracing.attach_error_to_span(
12821276
current_span,
12831277
SpanError(

tests/test_tracing_errors.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@
1313
InputGuardrail,
1414
InputGuardrailTripwireTriggered,
1515
MaxTurnsExceeded,
16+
ModelBehaviorError,
17+
ModelRefusalError,
18+
RunConfig,
1619
RunContextWrapper,
1720
Runner,
1821
TResponseInputItem,
22+
UserError,
1923
_debug,
2024
)
25+
from agents.tracing.span_data import AgentSpanData
26+
from agents.tracing.spans import SpanImpl
2127

2228
from .fake_model import FakeModel
2329
from .test_responses import (
@@ -27,7 +33,7 @@
2733
get_handoff_tool_call,
2834
get_text_message,
2935
)
30-
from .testing_processor import fetch_normalized_spans
36+
from .testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans, fetch_span_errors
3137

3238

3339
@pytest.mark.asyncio
@@ -49,6 +55,7 @@ async def test_single_turn_model_error():
4955
"children": [
5056
{
5157
"type": "agent",
58+
"error": {"message": "Error in agent run", "data": {"error": "test error"}},
5259
"data": {
5360
"name": "test_agent",
5461
"handoffs": [],
@@ -102,6 +109,7 @@ async def test_multi_turn_no_handoffs():
102109
"children": [
103110
{
104111
"type": "agent",
112+
"error": {"message": "Error in agent run", "data": {"error": "test error"}},
105113
"data": {
106114
"name": "test_agent",
107115
"handoffs": [],
@@ -558,3 +566,167 @@ async def test_guardrail_error():
558566
}
559567
]
560568
)
569+
570+
571+
SENSITIVE_ERROR_MESSAGE = "sensitive-error-detail"
572+
573+
574+
@pytest.mark.asyncio
575+
async def test_run_marks_agent_span_with_generic_error():
576+
"""A generic run failure marks the agent span, matching the streamed path."""
577+
model = FakeModel(tracing_enabled=True)
578+
model.set_next_output(ValueError("test error"))
579+
580+
with pytest.raises(ValueError, match="test error"):
581+
await Runner.run(Agent(name="test_agent", model=model), input="first_test")
582+
583+
assert fetch_span_errors("agent") == [
584+
{"message": "Error in agent run", "data": {"error": "test error"}}
585+
]
586+
587+
588+
def test_run_sync_marks_agent_span_with_generic_error():
589+
model = FakeModel(tracing_enabled=True)
590+
model.set_next_output(ValueError("test error"))
591+
592+
with pytest.raises(ValueError, match="test error"):
593+
Runner.run_sync(Agent(name="test_agent", model=model), input="first_test")
594+
595+
assert fetch_span_errors("agent") == [
596+
{"message": "Error in agent run", "data": {"error": "test error"}}
597+
]
598+
599+
600+
@pytest.mark.asyncio
601+
async def test_run_agent_span_error_matches_streamed_path():
602+
"""The non-streamed and streamed paths record the same agent span error."""
603+
non_streamed_model = FakeModel(tracing_enabled=True)
604+
non_streamed_model.set_next_output(ValueError("test error"))
605+
with pytest.raises(ValueError):
606+
await Runner.run(Agent(name="test_agent", model=non_streamed_model), input="first_test")
607+
non_streamed_errors = fetch_span_errors("agent")
608+
609+
SPAN_PROCESSOR_TESTING.clear()
610+
611+
streamed_model = FakeModel(tracing_enabled=True)
612+
streamed_model.set_next_output(ValueError("test error"))
613+
result = Runner.run_streamed(Agent(name="test_agent", model=streamed_model), input="first_test")
614+
with pytest.raises(ValueError):
615+
async for _ in result.stream_events():
616+
pass
617+
618+
assert non_streamed_errors == fetch_span_errors("agent")
619+
620+
621+
@pytest.mark.asyncio
622+
async def test_run_agent_span_error_redacts_sensitive_data():
623+
model = FakeModel(tracing_enabled=False)
624+
model.set_next_output(ValueError(SENSITIVE_ERROR_MESSAGE))
625+
626+
with pytest.raises(ValueError):
627+
await Runner.run(
628+
Agent(name="test_agent", model=model),
629+
input="first_test",
630+
run_config=RunConfig(trace_include_sensitive_data=False),
631+
)
632+
633+
assert fetch_span_errors("agent") == [
634+
{
635+
"message": "Error in agent run",
636+
"data": {"error": "Error details are redacted."},
637+
}
638+
]
639+
640+
641+
@pytest.mark.asyncio
642+
async def test_run_does_not_mark_agent_span_for_model_behavior_error():
643+
"""ModelBehaviorError is reported by the generation span, so the agent span stays clean."""
644+
model = FakeModel(tracing_enabled=True)
645+
model.set_next_output(ModelBehaviorError("bad model output"))
646+
647+
with pytest.raises(ModelBehaviorError):
648+
await Runner.run(Agent(name="test_agent", model=model), input="first_test")
649+
650+
assert fetch_span_errors("agent") == []
651+
652+
653+
@pytest.mark.asyncio
654+
@pytest.mark.parametrize(
655+
("error", "expected_detail"),
656+
[
657+
pytest.param(
658+
ModelRefusalError("refused"),
659+
"Model refused to produce output: refused",
660+
id="model-refusal-error",
661+
),
662+
pytest.param(UserError("user problem"), "user problem", id="user-error"),
663+
],
664+
)
665+
async def test_run_marks_agent_span_for_other_agents_exceptions(
666+
error: Exception, expected_detail: str
667+
):
668+
"""Agents exceptions without a dedicated agent-span error match the streamed path."""
669+
model = FakeModel(tracing_enabled=True)
670+
model.set_next_output(error)
671+
672+
with pytest.raises(type(error)):
673+
await Runner.run(Agent(name="test_agent", model=model), input="first_test")
674+
675+
assert fetch_span_errors("agent") == [
676+
{"message": "Error in agent run", "data": {"error": expected_detail}}
677+
]
678+
679+
680+
@pytest.mark.asyncio
681+
async def test_run_keeps_specific_max_turns_agent_span_error():
682+
"""A more specific span error already on the agent span is not overwritten."""
683+
model = FakeModel(tracing_enabled=True)
684+
agent = Agent(name="test_agent", model=model, tools=[get_function_tool("foo", "res")])
685+
model.add_multiple_turn_outputs(
686+
[
687+
[get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="c1")],
688+
[get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="c2")],
689+
[get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="c3")],
690+
]
691+
)
692+
693+
with pytest.raises(MaxTurnsExceeded):
694+
await Runner.run(agent, input="first_test", max_turns=2)
695+
696+
assert fetch_span_errors("agent") == [
697+
{"message": "Max turns exceeded", "data": {"max_turns": 2}}
698+
]
699+
700+
701+
@pytest.mark.asyncio
702+
async def test_run_attaches_agent_span_error_exactly_once(
703+
monkeypatch: pytest.MonkeyPatch,
704+
):
705+
"""The generic error is recorded once, not re-applied by nested handlers."""
706+
recorded: list[Any] = []
707+
original_set_error = SpanImpl.set_error
708+
709+
def counting_set_error(self: Any, error: Any) -> None:
710+
if isinstance(self.span_data, AgentSpanData):
711+
recorded.append(error)
712+
original_set_error(self, error)
713+
714+
monkeypatch.setattr(SpanImpl, "set_error", counting_set_error)
715+
716+
model = FakeModel(tracing_enabled=True)
717+
model.set_next_output(ValueError("test error"))
718+
with pytest.raises(ValueError):
719+
await Runner.run(Agent(name="test_agent", model=model), input="first_test")
720+
721+
assert recorded == [{"message": "Error in agent run", "data": {"error": "test error"}}]
722+
723+
724+
@pytest.mark.asyncio
725+
async def test_successful_run_leaves_agent_span_without_error():
726+
model = FakeModel(tracing_enabled=True)
727+
model.set_next_output([get_text_message("done")])
728+
729+
result = await Runner.run(Agent(name="test_agent", model=model), input="first_test")
730+
731+
assert result.final_output == "done"
732+
assert fetch_span_errors("agent") == []

0 commit comments

Comments
 (0)