Skip to content

fix(core): resolve race condition with concurrent tool spans#1034

Open
klakpin wants to merge 2 commits intoVoltAgent:mainfrom
klakpin:fix/concurrent-tool-spans
Open

fix(core): resolve race condition with concurrent tool spans#1034
klakpin wants to merge 2 commits intoVoltAgent:mainfrom
klakpin:fix/concurrent-tool-spans

Conversation

@klakpin
Copy link

@klakpin klakpin commented Feb 9, 2026

#1033

Fixed a race condition where tools running in parallel would overwrite each other's parentToolSpan in the shared systemContext. The fix passes parentToolSpan through execution options instead of systemContext, ensuring each tool receives its unique span. Backward compatibility is maintained by checking both options and systemContext.

PR Checklist

Please check if your PR fulfills the following requirements:

Bugs / Features

What is the current behavior?

When multiple tools are called the spans are propagated incorrectly - the underlying actions inside tool (for example subagent invocation) have parent span of the last invoked tool.

What is the new behavior?

Each tool correctly traces the parent span.


Summary by cubic

Fixes a race condition where tools running in parallel overwrote each other’s parent spans, causing incorrect tracing. Parent spans are now passed via execution options so each tool gets a unique span, with a fallback to systemContext for compatibility.

  • Bug Fixes
    • Pass parentToolSpan through execution options per tool invocation; prefer options over systemContext.
    • Update subagent, plan-agent, and retriever tools to read parentToolSpan from options; fix name shadowing during parent context retrieval.
    • Add a concurrency test to verify parallel tools receive unique parent spans and correct span names.

Written for commit a75d3fc. Summary will update on new commits.

Summary by CodeRabbit

Bug Fixes

  • Fixed a race condition occurring when tools executed concurrently. Tools now maintain isolated tracing spans during parallel execution, preventing cross-interference between concurrent tasks. Full backward compatibility is preserved.

Tests

  • Added comprehensive tests to verify distributed tracing spans are properly isolated when tools execute in parallel.

Fixed a race condition where tools running in parallel would overwrite
each other's parentToolSpan in the shared systemContext. The fix passes
parentToolSpan through execution options instead of systemContext, ensuring
each tool receives its unique span. Backward compatibility is maintained
by checking both options and systemContext.
@changeset-bot
Copy link

changeset-bot bot commented Feb 9, 2026

🦋 Changeset detected

Latest commit: a75d3fc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@voltagent/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 9, 2026

📝 Walkthrough

Walkthrough

This PR fixes a race condition in concurrent tool execution by moving the parentToolSpan propagation mechanism from the shared systemContext to per-execution options. Each tool invocation now receives its own span through executionOptions.parentToolSpan, with backward compatibility maintained via systemContext fallback.

Changes

Cohort / File(s) Summary
Changelog
.changeset/fix-concurrent-tool-spans.md
Changelog entry documenting the race condition fix and span propagation mechanism change.
Core Agent Span Propagation
packages/core/src/agent/agent.ts, packages/core/src/agent/concurrent-tool-spans.spec.ts
Updated agent to pass parentToolSpan through executionOptions instead of systemContext; added comprehensive test verifying concurrent tools receive distinct isolated spans.
Sub-Agent Span Handling
packages/core/src/agent/subagent/index.ts
Updated delegate tool to extract and forward parentToolSpan from outer options to sub-agent handoffs, maintaining span hierarchy across delegations.
Plan Agent Span Propagation
packages/core/src/planagent/plan-agent.ts, packages/core/src/planagent/planning/index.ts
Updated plan agent and planning tool to check executeOptions.parentToolSpan first, falling back to systemContext, ensuring consistent span propagation in task orchestration.
Retriever Span Extraction
packages/core/src/retriever/tools/index.ts
Modified span extraction to prioritize options.parentToolSpan over systemContext lookup, aligning with unified propagation pattern.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant Tool A
    participant Tool B
    participant OTel as OpenTelemetry

    Agent->>Agent: Create parentToolSpan for<br/>Tool execution
    Agent->>Tool A: Call with<br/>executionOptions.<br/>parentToolSpan
    Agent->>Tool B: Call with<br/>executionOptions.<br/>parentToolSpan
    
    par Concurrent Execution
        Tool A->>OTel: Create span<br/>(tool.execution:toolA)<br/>with distinct parent
        Tool B->>OTel: Create span<br/>(tool.execution:toolB)<br/>with distinct parent
        Tool A->>Tool A: Execute async work
        Tool B->>Tool B: Execute async work
    end
    
    Tool A->>Agent: Return result
    Tool B->>Agent: Return result
    Agent->>OTel: Both spans properly<br/>isolated and correlated
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Concurrent tools once tangled, now run free,
Each span isolated, as it ought to be,
Through execution options, not shared state we roam,
Race conditions vanquished—spans find their home!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: resolving a race condition with concurrent tool spans, which is the core focus of the changeset.
Description check ✅ Passed The PR description covers all required template sections: issue link provided, tests added, changesets added checkbox marked, current behavior explained, new behavior described, and reviewer notes included.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@packages/core/src/agent/subagent/index.ts`:
- Around line 764-766: The code currently casts options to any to extract
parentToolSpan; instead add parentToolSpan to the declared type for the options
parameter so you can read (options.parentToolSpan) without casting. Update the
options interface/type used by the function that calls createTool (the options
parameter in index.ts where parentToolSpan is read) to include parentToolSpan?:
Span | undefined, then remove the (options as any) cast and use const
parentToolSpan = options.parentToolSpan before calling createTool so the call
site remains fully typed.

In `@packages/core/src/planagent/plan-agent.ts`:
- Around line 773-775: The parentSpan assignment duplicates the same resolution
logic already stored in toolSpan (declared earlier on line ~748); replace the
inline re-derived expression ((executeOptions as any).parentToolSpan ...
operationContext.systemContext.get("parentToolSpan")) with the existing toolSpan
variable when setting parentSpan to avoid divergence and duplication, keeping
the type as Span | undefined (cast if necessary) and ensuring parentSpan uses
toolSpan as its source of truth.
🧹 Nitpick comments (4)
packages/core/src/planagent/plan-agent.ts (1)

748-750: Same as any cast issue as flagged in planning/index.ts.

See the comment on packages/core/src/planagent/planning/index.ts lines 151-153 regarding the as any cast and the suggestion to extract a shared helper.

packages/core/src/agent/concurrent-tool-spans.spec.ts (2)

100-106: Strengthen span assertions to validate actual Span objects.

expect(spanA).toBeDefined() passes for any truthy value. Since this test's purpose is to validate that distinct OpenTelemetry spans are created per tool, consider asserting on a known Span interface method (e.g., spanA.spanContext() returns a valid SpanContext) rather than relying on an internal .name property that may not exist on all Span implementations.

Suggested improvement
     expect(spanA).toBeDefined();
     expect(spanB).toBeDefined();
 
     // Spans shouldn't be the same
     expect(spanA).not.toBe(spanB);
-    expect((spanA as any).name).toContain("tool.execution:toolA");
-    expect((spanB as any).name).toContain("tool.execution:toolB");
+    // Verify these are real Span objects with distinct span contexts
+    const ctxA = spanA!.spanContext();
+    const ctxB = spanB!.spanContext();
+    expect(ctxA.spanId).toBeTruthy();
+    expect(ctxB.spanId).toBeTruthy();
+    expect(ctxA.spanId).not.toBe(ctxB.spanId);

27-30: Consider restoring mocks after each test.

Adding afterEach(() => vi.restoreAllMocks()) prevents mock state from leaking if more tests are added to this file later.

packages/core/src/planagent/planning/index.ts (1)

151-153: as any cast obscures runtime-added properties; add parentToolSpan to the type definition for clarity.

The parentToolSpan property is dynamically set at runtime (see agent.ts:5057) and accessed in multiple places, but it's not declared in ToolExecuteOptions. While the [key: string]: any index signature allows this, the as any cast obscures the intent. Adding parentToolSpan?: Span to ToolExecuteOptions would make the type explicit and eliminate the cast.

This pattern appears in at least six locations (planning/index.ts, plan-agent.ts ×2, retriever/tools/index.ts, agent.ts, subagent/index.ts), making it a good candidate for extraction into a shared helper function to avoid duplication.

Comment on lines +764 to 766
// Extract parentToolSpan from outer options to avoid shadowing with inner execute options
const parentToolSpan = (options as any).parentToolSpan as Span | undefined;
return createTool({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the file
fd packages/core/src/agent/subagent/index.ts

Repository: VoltAgent/voltagent

Length of output: 45


🏁 Script executed:

# Check the file size to understand how much we need to read
wc -l packages/core/src/agent/subagent/index.ts

Repository: VoltAgent/voltagent

Length of output: 108


🏁 Script executed:

# Read the specific lines mentioned in the review (764-766) with context
sed -n '750,780p' packages/core/src/agent/subagent/index.ts

Repository: VoltAgent/voltagent

Length of output: 1270


🏁 Script executed:

# Let's also search for the createDelegateTool method signature to understand the options type
rg "createDelegateTool\s*\(" packages/core/src/agent/subagent/index.ts -A 10

Repository: VoltAgent/voltagent

Length of output: 353


🏁 Script executed:

# Search for all usages of parentToolSpan in this file
rg "parentToolSpan" packages/core/src/agent/subagent/index.ts -B 2 -A 2

Repository: VoltAgent/voltagent

Length of output: 880


🏁 Script executed:

# Check the imports and Span type definition
head -50 packages/core/src/agent/subagent/index.ts

Repository: VoltAgent/voltagent

Length of output: 1618


🏁 Script executed:

# Search for Span type across the codebase to understand it
rg "type Span|interface Span|from.*Span" packages/core/src --type ts | head -20

Repository: VoltAgent/voltagent

Length of output: 1665


🏁 Script executed:

# Check if parentToolSpan is used elsewhere in the codebase
rg "parentToolSpan" packages/core/src --type ts

Repository: VoltAgent/voltagent

Length of output: 2528


🏁 Script executed:

# Check the comment context more broadly - maybe there's a reason for the 'any' cast
sed -n '755,775p' packages/core/src/agent/subagent/index.ts

Repository: VoltAgent/voltagent

Length of output: 832


🏁 Script executed:

# Search for createDelegateTool calls to see if parentToolSpan is ever passed
rg "createDelegateTool\(" packages/core/src --type ts -B 2 -A 5

Repository: VoltAgent/voltagent

Length of output: 6502


🏁 Script executed:

# Let's also check if there's a ToolExecuteOptions type that defines what execute function receives
rg "ToolExecuteOptions|type ToolExecuteOptions" packages/core/src --type ts -B 1 -A 5

Repository: VoltAgent/voltagent

Length of output: 15477


🏁 Script executed:

# Check the tool definition to understand execute options signature
rg "execute\s*:\s*async" packages/core/src/agent/subagent/index.ts -B 2 -A 5

Repository: VoltAgent/voltagent

Length of output: 590


Add parentToolSpan to the options type instead of casting as any.

This keeps the call site typed and avoids bypassing the TypeScript type checker, maintaining type safety in the TypeScript-first codebase.

Suggested fix
   public createDelegateTool(options: {
     sourceAgent: Agent;
     currentHistoryEntryId?: string;
     operationContext?: OperationContext;
     maxSteps?: number;
     conversationId?: string;
     userId?: string;
+    parentToolSpan?: Span;
   }): Tool<any, any> {
     const {
       sourceAgent,
       operationContext,
       currentHistoryEntryId,
       maxSteps,
       conversationId,
       userId,
+      parentToolSpan,
     } = options;
-    const parentToolSpan = (options as any).parentToolSpan as Span | undefined;
🤖 Prompt for AI Agents
In `@packages/core/src/agent/subagent/index.ts` around lines 764 - 766, The code
currently casts options to any to extract parentToolSpan; instead add
parentToolSpan to the declared type for the options parameter so you can read
(options.parentToolSpan) without casting. Update the options interface/type used
by the function that calls createTool (the options parameter in index.ts where
parentToolSpan is read) to include parentToolSpan?: Span | undefined, then
remove the (options as any) cast and use const parentToolSpan =
options.parentToolSpan before calling createTool so the call site remains fully
typed.

Comment on lines +773 to +775
parentSpan:
((executeOptions as any).parentToolSpan as Span | undefined) ||
(operationContext.systemContext.get("parentToolSpan") as Span | undefined),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Reuse the already-resolved toolSpan instead of duplicating the expression.

toolSpan (line 748) already holds the resolved parent span using the same logic. Re-deriving it here is redundant and risks the two diverging if one is updated but not the other.

Proposed fix
       const result = await subAgentManager.handoffTask({
         task: input.description,
         targetAgent: target.config,
         sourceAgent,
         userId: operationContext.userId,
         conversationId: operationContext.conversationId,
         parentOperationContext: operationContext,
         maxSteps: taskOptions?.maxSteps,
-        parentSpan:
-          ((executeOptions as any).parentToolSpan as Span | undefined) ||
-          (operationContext.systemContext.get("parentToolSpan") as Span | undefined),
+        parentSpan: toolSpan,
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
parentSpan:
((executeOptions as any).parentToolSpan as Span | undefined) ||
(operationContext.systemContext.get("parentToolSpan") as Span | undefined),
const result = await subAgentManager.handoffTask({
task: input.description,
targetAgent: target.config,
sourceAgent,
userId: operationContext.userId,
conversationId: operationContext.conversationId,
parentOperationContext: operationContext,
maxSteps: taskOptions?.maxSteps,
parentSpan: toolSpan,
});
🤖 Prompt for AI Agents
In `@packages/core/src/planagent/plan-agent.ts` around lines 773 - 775, The
parentSpan assignment duplicates the same resolution logic already stored in
toolSpan (declared earlier on line ~748); replace the inline re-derived
expression ((executeOptions as any).parentToolSpan ...
operationContext.systemContext.get("parentToolSpan")) with the existing toolSpan
variable when setting parentSpan to avoid divergence and duplication, keeping
the type as Span | undefined (cast if necessary) and ensuring parentSpan uses
toolSpan as its source of truth.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant