Skip to content

Commit b65b342

Browse files
authored
fix(core): resolve race condition with concurrent tool spans (#1034)
* fix(core): resolve race condition with concurrent tool spans 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. * fix(core): fixed name shadowing during parent context retrieval * fix(core): code cleanup for tool span * fix(core): correct parent tool span propagation during tool creation
1 parent 4ec1c4a commit b65b342

7 files changed

Lines changed: 201 additions & 13 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@voltagent/core": patch
3+
---
4+
5+
fix(core): resolve race condition with concurrent tool spans
6+
7+
Fixed a race condition where tools running in parallel would overwrite each other's
8+
parentToolSpan in the shared systemContext. The fix passes parentToolSpan through
9+
execution options instead of systemContext, ensuring each tool receives its unique
10+
span. Backward compatibility is maintained by checking both options and systemContext.

packages/core/src/agent/agent.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5158,7 +5158,8 @@ export class Agent {
51585158
// Push execution metadata into systemContext for tools to consume
51595159
oc.systemContext.set("agentId", this.id);
51605160
oc.systemContext.set("historyEntryId", oc.operationId);
5161-
oc.systemContext.set("parentToolSpan", toolSpan);
5161+
5162+
executionOptions.parentToolSpan = toolSpan;
51625163

51635164
const hasOutputOverride = (
51645165
value: unknown,
@@ -5639,7 +5640,10 @@ export class Agent {
56395640
topK ?? toolRouting?.topK ?? embeddingTopK ?? DEFAULT_TOOL_SEARCH_TOP_K,
56405641
);
56415642
const candidates = this.buildToolSearchCandidates();
5642-
const parentToolSpan = oc.systemContext.get("parentToolSpan") as Span | undefined;
5643+
// Check both options and systemContext for backward compatibility, prefer options
5644+
const parentToolSpan =
5645+
((options as any).parentToolSpan as Span | undefined) ||
5646+
(oc.systemContext.get("parentToolSpan") as Span | undefined);
56435647
const selectionSpanAttributes = {
56445648
"tool.name": TOOL_ROUTING_SEARCH_TOOL_NAME,
56455649
"tool.search.name": TOOL_ROUTING_SEARCH_TOOL_NAME,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { Span } from "@opentelemetry/api";
2+
import * as ai from "ai";
3+
import { MockLanguageModelV3 } from "ai/test";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { z } from "zod";
6+
import { NodeVoltAgentObservability } from "../observability";
7+
import { createTool } from "../tool";
8+
import { Agent } from "./agent";
9+
import { SubAgentManager } from "./subagent";
10+
11+
// Mock the AI SDK functions
12+
vi.mock("ai", async () => {
13+
const actual = await vi.importActual<typeof import("ai")>("ai");
14+
return {
15+
...actual,
16+
generateText: vi.fn(),
17+
streamText: vi.fn(),
18+
generateObject: vi.fn(),
19+
streamObject: vi.fn(),
20+
stepCountIs: vi.fn(() => vi.fn(() => false)),
21+
};
22+
});
23+
24+
describe("Agent Concurrent Tool Spans", () => {
25+
let observability: NodeVoltAgentObservability;
26+
let mockModel: MockLanguageModelV3;
27+
28+
beforeEach(() => {
29+
observability = new NodeVoltAgentObservability();
30+
mockModel = new MockLanguageModelV3();
31+
});
32+
33+
it("should provide unique parent spans to tools running in parallel", async () => {
34+
const capturedSpans: Map<string, Span> = new Map();
35+
36+
// Create two tools that capture their parentToolSpan
37+
const toolA = createTool({
38+
name: "toolA",
39+
description: "Tool A",
40+
parameters: z.object({}),
41+
execute: async (_, options) => {
42+
// Capture the span passed in options
43+
const span = (options as any).parentToolSpan;
44+
capturedSpans.set("toolA", span);
45+
// Simulate some async work to allow interleaving
46+
await new Promise((resolve) => setTimeout(resolve, 50));
47+
return "resultA";
48+
},
49+
});
50+
51+
const toolB = createTool({
52+
name: "toolB",
53+
description: "Tool B",
54+
parameters: z.object({}),
55+
execute: async (_, options) => {
56+
// Capture the span passed in options
57+
const span = (options as any).parentToolSpan;
58+
capturedSpans.set("toolB", span);
59+
await new Promise((resolve) => setTimeout(resolve, 10));
60+
return "resultB";
61+
},
62+
});
63+
64+
const agent = new Agent({
65+
name: "test-agent",
66+
instructions: "test instructions",
67+
model: mockModel as any,
68+
observability,
69+
tools: [toolA, toolB],
70+
toolRouting: false, // Disable tool routing to ensure tools are directly available
71+
});
72+
73+
// Mock ai.generateText to simulate parallel tool execution
74+
vi.mocked(ai.generateText).mockImplementation(async (options: any) => {
75+
// options.tools contains the wrapped tools from Agent.ts
76+
const toolAWrapper = options.tools.toolA;
77+
const toolBWrapper = options.tools.toolB;
78+
79+
// Parallel tools execution simulation
80+
await Promise.all([toolAWrapper.execute({}), toolBWrapper.execute({})]);
81+
82+
return {
83+
text: "Done",
84+
finishReason: "stop",
85+
usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 },
86+
steps: [],
87+
toolCalls: [],
88+
toolResults: [],
89+
response: { id: "test", modelId: "test", timestamp: new Date(), messages: [] },
90+
} as any;
91+
});
92+
93+
await agent.generateText("Run tools in parallel");
94+
95+
expect(capturedSpans.has("toolA")).toBe(true);
96+
expect(capturedSpans.has("toolB")).toBe(true);
97+
98+
const spanA = capturedSpans.get("toolA");
99+
const spanB = capturedSpans.get("toolB");
100+
101+
expect(spanA).toBeDefined();
102+
expect(spanB).toBeDefined();
103+
104+
// Spans shouldn't be the same
105+
expect(spanA).not.toBe(spanB);
106+
expect((spanA as any).name).toContain("tool.execution:toolA");
107+
expect((spanB as any).name).toContain("tool.execution:toolB");
108+
});
109+
110+
it("should provide unique parent spans to subagents running in parallel via delegate_task", async () => {
111+
// 1. Setup SubAgentManager
112+
const subAgent = new Agent({
113+
name: "sub",
114+
instructions: "sub",
115+
model: mockModel as any,
116+
});
117+
118+
// Mock generateText on subAgent to verify parentSpan
119+
const generateTextSpy = vi.spyOn(subAgent, "generateText");
120+
generateTextSpy.mockResolvedValue({ text: "ok", usage: {} } as any);
121+
122+
const manager = new SubAgentManager("parent", [{ agent: subAgent, method: "generateText" }]);
123+
124+
// 2. Create delegate_task tool
125+
const delegateTool = manager.createDelegateTool({
126+
sourceAgent: new Agent({
127+
name: "parent",
128+
instructions: "parent",
129+
model: mockModel as any,
130+
}),
131+
// Simulate creation-time parentToolSpan (stale/wrong one)
132+
parentToolSpan: { spanContext: () => ({ traceId: "stale" }) } as any,
133+
});
134+
135+
// 3. Simulate concurrent execution with distinct parent spans
136+
const span1 = { spanContext: () => ({ traceId: "trace1" }) } as any;
137+
const span2 = { spanContext: () => ({ traceId: "trace2" }) } as any;
138+
139+
await Promise.all([
140+
delegateTool.execute?.({ task: "task1", targetAgents: ["sub"] }, {
141+
parentToolSpan: span1,
142+
} as any),
143+
delegateTool.execute?.({ task: "task2", targetAgents: ["sub"] }, {
144+
parentToolSpan: span2,
145+
} as any),
146+
]);
147+
148+
// 4. Verify subAgent.generateText was called with correct parent spans
149+
expect(generateTextSpy).toHaveBeenCalledTimes(2);
150+
151+
const calls = generateTextSpy.mock.calls;
152+
// Extract parentSpan from options (second argument to generateText)
153+
const spans = calls.map((c) => (c[1] as any).parentSpan);
154+
155+
// Should contain the spans passed during execution
156+
expect(spans).toContain(span1);
157+
expect(spans).toContain(span2);
158+
159+
// Should NOT contain the stale creation-time span
160+
const staleSpanInCalls = spans.some((s) => s && s.spanContext().traceId === "stale");
161+
expect(staleSpanInCalls).toBe(false);
162+
});
163+
});

packages/core/src/agent/subagent/index.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,7 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`;
752752
maxSteps?: number;
753753
conversationId?: string;
754754
userId?: string;
755+
parentToolSpan?: Span;
755756
}): Tool<any, any> {
756757
const {
757758
sourceAgent,
@@ -760,6 +761,7 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`;
760761
maxSteps,
761762
conversationId,
762763
userId,
764+
parentToolSpan,
763765
} = options;
764766
return createTool({
765767
id: "delegate_task",
@@ -773,10 +775,10 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`;
773775
.optional()
774776
.describe("Additional context for the task"),
775777
}),
776-
execute: async ({ task, targetAgents, context = {} }, options) => {
778+
execute: async ({ task, targetAgents, context = {} }, executeOptions) => {
777779
// Extract OperationContext from options if available
778780
// Since ToolExecuteOptions extends Partial<OperationContext>, we can cast it
779-
const currentOperationContext = options as OperationContext | undefined;
781+
const currentOperationContext = executeOptions as OperationContext | undefined;
780782
// Fall back to the original operation context if not available
781783
const effectiveOperationContext = currentOperationContext || operationContext;
782784

@@ -838,9 +840,10 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`;
838840
// Pass maxSteps from parent to subagents
839841
maxSteps,
840842
// Pass the parentToolSpan from executeOptions for proper span hierarchy
841-
parentSpan: effectiveOperationContext?.systemContext?.get("parentToolSpan") as
842-
| Span
843-
| undefined,
843+
parentSpan:
844+
(executeOptions?.parentToolSpan as Span | undefined) ||
845+
parentToolSpan ||
846+
(effectiveOperationContext?.systemContext?.get("parentToolSpan") as Span | undefined),
844847
});
845848

846849
// Return structured results with agent names and their responses

packages/core/src/planagent/plan-agent.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,9 @@ function createTaskToolkit(options: {
751751
}),
752752
execute: async (input, executeOptions) => {
753753
const operationContext = executeOptions as OperationContext;
754-
const toolSpan = operationContext.systemContext.get("parentToolSpan") as Span | undefined;
754+
const toolSpan =
755+
((executeOptions as any).parentToolSpan as Span | undefined) ||
756+
(operationContext.systemContext.get("parentToolSpan") as Span | undefined);
755757
if (toolSpan) {
756758
toolSpan.setAttribute("voltagent.label", `task:${input.subagent_type}`);
757759
toolSpan.setAttribute("planagent.task.subagent_type", input.subagent_type);
@@ -774,7 +776,7 @@ function createTaskToolkit(options: {
774776
conversationId: operationContext.conversationId,
775777
parentOperationContext: operationContext,
776778
maxSteps: taskOptions?.maxSteps,
777-
parentSpan: operationContext.systemContext.get("parentToolSpan") as Span | undefined,
779+
parentSpan: toolSpan,
778780
});
779781

780782
if (toolSpan) {

packages/core/src/planagent/planning/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,9 @@ export function createPlanningToolkit(agent: Agent, options: PlanningToolkitOpti
148148
await resolvedBackend.setTodos(guardedTodos);
149149
operationContext.systemContext.set(PLAN_PROGRESS_CONTEXT_KEY, false);
150150

151-
const toolSpan = operationContext.systemContext.get("parentToolSpan") as Span | undefined;
151+
const toolSpan =
152+
((executeOptions as any).parentToolSpan as Span | undefined) ||
153+
(operationContext.systemContext.get("parentToolSpan") as Span | undefined);
152154
if (toolSpan) {
153155
const pendingCount = guardedTodos.filter((todo) => todo.status === "pending").length;
154156
const inProgressCount = guardedTodos.filter((todo) => todo.status === "in_progress").length;

packages/core/src/retriever/tools/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ export const createRetrieverTool = (
5151
execute: async ({ query }, options?: ToolExecuteOptions) => {
5252
// Pass complete options to retriever for access to userId, conversationId, etc.
5353
const startTime = Date.now();
54-
const toolSpan = options?.systemContext?.get("parentToolSpan") as
55-
| { setAttribute?: (key: string, value: unknown) => void }
56-
| undefined;
54+
const toolSpan =
55+
((options as any)?.parentToolSpan as
56+
| { setAttribute?: (key: string, value: unknown) => void }
57+
| undefined) ||
58+
(options?.systemContext?.get("parentToolSpan") as
59+
| { setAttribute?: (key: string, value: unknown) => void }
60+
| undefined);
5761

5862
const normalizeAttributeValue = (value: unknown) => {
5963
if (value === null || value === undefined) return null;

0 commit comments

Comments
 (0)