fix(claude_agent_sdk): prevent orphan spans for racing MCP tools - #689
Conversation
Local MCP handlers could run before the assistant tool_use reached the application-consumed stream, creating duplicate root traces and attaching nested work or errors to the wrong span. This cluttered projects and made tool failures hard to diagnose. Observe raw SDK messages before control dispatch so handlers re-enter the canonical nested tool span without an arbitrary wait. Add deterministic cassette-backed race coverage.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad4ccab858
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| if request_tracker is None: | ||
| request_tracker = getattr(_thread_local, "request_tracker", None) |
There was a problem hiding this comment.
Bind raw messages to the originating one-shot query
When two exported claude_agent_sdk.query() iterators run concurrently on the same event-loop thread, each RequestTracker overwrites this threading.local() slot, while their internal Query objects have no _braintrust_request_tracker binding. A message emitted by the first query can therefore be pretraced into the second query's tracker; subsequent type-based suppression then produces duplicated spans in one trace and missing or cross-request content in the other. Store the tracker on the originating Query or use request-scoped async context rather than the last tracker on the thread.
Useful? React with 👍 / 👎.
| active_tool_span = tool_span_tracker.acquire_span_for_handler(tool_name, args) | ||
| if active_tool_span is None: |
There was a problem hiding this comment.
Retain fallback tracing for an unrelated active tracker
With two wrapped clients active concurrently on one event-loop thread, _thread_local.tool_span_tracker belongs to whichever request was created last. If the earlier client's handler uses a different tool name or input, this lookup returns an unrelated non-None tracker but acquire_span_for_handler() returns None; the new branch then runs the handler without either its canonical tool span or the previous fallback span, so nested work becomes orphaned. The presence of any thread-local tracker is not sufficient to suppress fallback tracing unless it is known to own this handler invocation.
Useful? React with 👍 / 👎.
Bind raw SDK readers to request-owned trackers without retaining ContextVar tokens across async generator yields. Restore fallback tracing for unmatched local handlers and add deterministic cassette coverage for concurrent clients, query helpers, cancellation, and cross-context cleanup.
resolves https://linear.app/braintrustdata/issue/SDK-225/claude-agent-sdk-integration-creates-orphan-duplicate-tool-spans-when
Local MCP handlers could run before the assistant tool_use reached the application-consumed stream, creating duplicate root traces and attaching nested work or errors to the wrong span. This cluttered projects and made tool failures hard to diagnose.
Observe raw SDK messages before control dispatch so handlers re-enter the canonical nested tool span without an arbitrary wait. Add deterministic cassette-backed race coverage.