fix(core): resolve race condition with concurrent tool spans#1034
fix(core): resolve race condition with concurrent tool spans#1034klakpin wants to merge 2 commits intoVoltAgent:mainfrom
Conversation
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 detectedLatest commit: a75d3fc The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
📝 WalkthroughWalkthroughThis 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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: Sameas anycast issue as flagged inplanning/index.ts.See the comment on
packages/core/src/planagent/planning/index.tslines 151-153 regarding theas anycast 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 actualSpanobjects.
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 knownSpaninterface method (e.g.,spanA.spanContext()returns a validSpanContext) rather than relying on an internal.nameproperty that may not exist on allSpanimplementations.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 anycast obscures runtime-added properties; addparentToolSpanto the type definition for clarity.The
parentToolSpanproperty is dynamically set at runtime (seeagent.ts:5057) and accessed in multiple places, but it's not declared inToolExecuteOptions. While the[key: string]: anyindex signature allows this, theas anycast obscures the intent. AddingparentToolSpan?: SpantoToolExecuteOptionswould 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.
| // Extract parentToolSpan from outer options to avoid shadowing with inner execute options | ||
| const parentToolSpan = (options as any).parentToolSpan as Span | undefined; | ||
| return createTool({ |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the file
fd packages/core/src/agent/subagent/index.tsRepository: 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.tsRepository: 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.tsRepository: 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 10Repository: 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 2Repository: VoltAgent/voltagent
Length of output: 880
🏁 Script executed:
# Check the imports and Span type definition
head -50 packages/core/src/agent/subagent/index.tsRepository: 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 -20Repository: VoltAgent/voltagent
Length of output: 1665
🏁 Script executed:
# Check if parentToolSpan is used elsewhere in the codebase
rg "parentToolSpan" packages/core/src --type tsRepository: 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.tsRepository: 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 5Repository: 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 5Repository: 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 5Repository: 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.
| parentSpan: | ||
| ((executeOptions as any).parentToolSpan as Span | undefined) || | ||
| (operationContext.systemContext.get("parentToolSpan") as Span | undefined), |
There was a problem hiding this comment.
🛠️ 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.
| 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.
#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.
Written for commit a75d3fc. Summary will update on new commits.
Summary by CodeRabbit
Bug Fixes
Tests