镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

After an approval, the resumed run records the approved tool span outside the agent span #5290

Closed

Description

@chrikrah

Please read this first

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.0 tag, and main at a575a6e
  • Related library versions: openai 3.23.0, pydantic 2.13.5 (PyPI install)
  • Python version: 3.12.3 (PyPI install), 3.14.7 (v0.23.0 and main)
  • 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.0 tag (not on PyPI yet).
  • Consistent or intermittent: consistent, with Runner.run and Runner.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

  1. seratch commented on Oct 3, 2026

    @seratch
    Member

    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_turn in 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.

  2. chrikrah commented on Oct 4, 2026

    @chrikrah
    Author

    @seratch one correction to my own report before you write that regression coverage. The tests/test_agent_tracing.py:288-292 I cited runs under include_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 at agent -> function.

    Your insertion point leans the same way. resolve_interrupted_turn is 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 at agent -> turn -> function.

    $ git grep -n 'resolve_interrupted_turn(\|turn_span(' 81f0ccf -- src/agents/run.py src/agents/run_internal/run_loop.py
    81f0ccf:src/agents/run.py:1115:                            turn_result = await resolve_interrupted_turn(
    81f0ccf:src/agents/run.py:1667:                        turn_span(
    81f0ccf:src/agents/run_internal/run_loop.py:1323:                    turn_result = await resolve_interrupted_turn(
    81f0ccf:src/agents/run_internal/run_loop.py:1780:                    turn_span(
    
    $ git show 81f0ccf:tests/test_agent_tracing.py | sed -n '257p;274p;288p'
    async def test_task_and_turn_spans_can_be_disabled():
            run_config=RunConfig(tracing={"include_task_and_turn_spans": False}),
        assert [span.parent_id for span in generation_spans + function_spans] == [
    

    Which of the two should the approved tool match?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions