Repository navigation
Description
Please read this first
- Read the docs: yes.
- Searched for related issues: yes. fix(tracing): keep trace state on completed non-streamed runs #5045 dealt with trace reattachment on resume; nothing covers span parents.
Describe the bug
After state.approve(...), the resumed run executes the approved tool before it opens its agent span. The function span becomes a child of the task span, or of the trace root when task and turn spans are off. Only the paused span from the first run (docs/tracing.md:147) sits inside an agent span. If the approved tool has failure_error_function=None, as below, and raises, the resumed run ends without an agent span or Error in agent run.
resolve_interrupted_turn runs at src/agents/run.py:1115 while current_span is still None (:928). The agent span opens later, at :1492-1504. The streamed path has the same order, at src/agents/run_internal/run_loop.py:1323 and :1564-1576.
Debug information
- Agents SDK version: 0.22.3 from PyPI, the
v0.23.0tag, andmainata575a6e - Related library versions:
openai3.23.0,pydantic2.13.5 (PyPI install) - Python version: 3.12.3 (PyPI install), 3.14.7 (
v0.23.0andmain) - Operating system: Ubuntu 24.04
- Model and model provider: none,
agents.testing.ScriptedModel - Reproduces with the latest release: yes, PyPI 0.22.3 and the
v0.23.0tag (not on PyPI yet). - Consistent or intermittent: consistent, with
Runner.runandRunner.run_streamed.
Repro steps
import asyncio
import sys
from agents import Agent, Runner, add_trace_processor, function_tool
from agents.testing import ScriptedModel, assistant_message, function_call
from agents.tracing import TracingProcessor
STREAMED = "--streamed" in sys.argv
spans = {}
class Spans(TracingProcessor):
def on_trace_start(self, trace): pass
def on_trace_end(self, trace): pass
def on_span_start(self, span): pass
def on_span_end(self, span): spans[span.span_id] = span
def shutdown(self): pass
def force_flush(self): pass
add_trace_processor(Spans())
def show(title):
print(title)
for s in sorted(spans.values(), key=lambda s: s.started_at):
parent = spans.get(s.parent_id)
where = f"{parent.span_data.type}" if parent else "trace root"
error = f" error={s.error['message']!r}" if s.error else ""
print(f" {s.span_data.type:8} {s.span_data.export().get('name', ''):8} parent={where}{error}")
spans.clear()
@function_tool(name_override="refund", needs_approval=True, failure_error_function=None)
def approved_refund(order_id: str) -> str:
return f"refunded {order_id}"
@function_tool(name_override="refund", needs_approval=True, failure_error_function=None)
def approved_failing_refund(order_id: str) -> str:
raise RuntimeError("payments API down")
@function_tool(name_override="refund", failure_error_function=None)
def failing_refund(order_id: str) -> str:
raise RuntimeError("payments API down")
async def run(agent, input):
if STREAMED:
result = Runner.run_streamed(agent, input)
async for _ in result.stream_events():
pass
return result
return await Runner.run(agent, input)
def agent_with(tool):
model = ScriptedModel(
[[function_call("refund", {"order_id": "A1"}, call_id="c1")], [assistant_message("done")]]
)
return Agent(name="support", model=model, tools=[tool])
async def main():
try:
await run(agent_with(failing_refund), "refund A1")
except Exception as e:
show(f"no approval, tool raises ({type(e).__name__}):")
for tool in (approved_refund, approved_failing_refund):
agent = agent_with(tool)
first = await run(agent, "refund A1")
spans.clear() # the first run's paused refund span sits under its turn
state = first.to_state()
state.approve(first.interruptions[0])
try:
await run(agent, state)
outcome = "completed"
except Exception as e:
outcome = type(e).__name__
show(f"resumed after approval, tool {'raises' if tool is approved_failing_refund else 'succeeds'} ({outcome}):")
asyncio.run(main())no approval, tool raises (UserError):
task task parent=trace root
agent support parent=task error='Error in agent run'
turn turn parent=agent
function refund parent=turn error='Error running tool'
resumed after approval, tool succeeds (completed):
task task parent=trace root
function refund parent=task
agent support parent=task
turn turn parent=agent
resumed after approval, tool raises (UserError):
task task parent=trace root
function refund parent=task error='Error running tool'
# main a575a6e; --streamed, the v0.23.0 tag and PyPI 0.22.3 print the same; not run: the default OpenAI exporter
Expected behavior
The approved tool span sits under the agent span, as it does without approval, and as tests/test_agent_tracing.py:288-292 asserts for function spans. An approved tool whose exception ends the run leaves Error in agent run on that agent span.
@seratch, is opening the agent span before resolve_interrupted_turn on resume the fix you would take?
Activity
seratch commentedon Oct 3, 2026
Thanks for the detailed report. I traced both runner paths on current main and v0.23.1, and the ordering matches your diagnosis: resumed tool execution happens before the agent span starts, so a propagated tool failure also misses the agent-level error marker.
We are not accepting PRs at the moment, but we may address this by starting the resumed agent span before
resolve_interrupted_turnin both runner modes. We would aim to reuse the existing span lifecycle while preserving turn accounting and handoff behavior, with regression coverage for successful and failing approved tools.chrikrah commentedon Oct 4, 2026
@seratch one correction to my own report before you write that regression coverage. The
tests/test_agent_tracing.py:288-292I cited runs underinclude_task_and_turn_spans: False. It is the only assertion in the suite on a function span parent. Coverage modelled on it would pin the resumed tool atagent -> function.Your insertion point leans the same way.
resolve_interrupted_turnis over 450 lines ahead of the turn span in both modes. An agent span opened there has no turn to parent to. The unapproved tool you traced sits atagent -> turn -> function.Which of the two should the approved tool match?