Skip to content

Commit 0cd9292

Browse files
jbreiteclaude
andauthored
fix: replace global parentStack with AsyncLocalStorage for concurrent-safe debug tracing (#18)
The previous push/pop parent stack was a global mutable array shared across all async contexts, causing corrupted parent attribution when multiple Task tools run in parallel. Replace with AsyncLocalStorage which gives each async continuation chain its own isolated context. - Add runWithDebugParent() using AsyncLocalStorage for context propagation - Move debugEnd/debugError outside parent context for correct indent level - Remove deprecated pushParent/popParent (no callers, not publicly exported) - Add 54 unit tests for debug module (previously zero coverage) - Update AGENTS.md documentation - Bump version to 0.5.2 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b23ecca commit 0cd9292

File tree

5 files changed

+818
-203
lines changed

5 files changed

+818
-203
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bashkit",
3-
"version": "0.5.1",
3+
"version": "0.5.2",
44
"description": "Agentic coding tools for the Vercel AI SDK",
55
"type": "module",
66
"main": "./dist/index.js",

src/tools/task.ts

Lines changed: 163 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ import {
2020
debugError,
2121
debugStart,
2222
isDebugEnabled,
23-
popParent,
24-
pushParent,
23+
runWithDebugParent,
2524
} from "../utils/debug";
2625

2726
export interface TaskOutput {
@@ -209,73 +208,134 @@ export function createTaskTool(
209208
})
210209
: "";
211210

212-
// Push this task as parent context for child tool calls
213-
if (debugId) pushParent(debugId);
214-
215-
try {
216-
const model = typeConfig.model || defaultModel;
217-
// Custom tools override the type's default tools, additionalTools are merged in
218-
const tools = filterTools(
219-
allTools,
220-
customTools ?? typeConfig.tools,
221-
typeConfig.additionalTools,
222-
);
223-
// Custom system_prompt overrides the type's default
224-
const systemPrompt = system_prompt ?? typeConfig.systemPrompt;
225-
226-
// Merge budget stopWhen with existing stop conditions
227-
const baseStopWhen =
228-
typeConfig.stopWhen ?? defaultStopWhen ?? stepCountIs(15);
229-
const effectiveStopWhen = budget
230-
? [baseStopWhen, budget.stopWhen].flat()
231-
: baseStopWhen;
232-
233-
// Common options for both generateText and streamText
234-
const commonOptions = {
235-
model,
236-
tools,
237-
system: systemPrompt,
238-
prompt,
239-
stopWhen: effectiveStopWhen,
240-
prepareStep: typeConfig.prepareStep,
241-
};
242-
243-
// Use streamText if streamWriter is provided, otherwise generateText
244-
if (streamWriter) {
245-
// Emit start event
246-
const startId = generateEventId();
247-
streamWriter.write({
248-
type: "data-subagent",
249-
id: startId,
250-
data: {
251-
event: "start",
211+
const executeTask = async (): Promise<TaskOutput | TaskError> => {
212+
try {
213+
const model = typeConfig.model || defaultModel;
214+
// Custom tools override the type's default tools, additionalTools are merged in
215+
const tools = filterTools(
216+
allTools,
217+
customTools ?? typeConfig.tools,
218+
typeConfig.additionalTools,
219+
);
220+
// Custom system_prompt overrides the type's default
221+
const systemPrompt = system_prompt ?? typeConfig.systemPrompt;
222+
223+
// Merge budget stopWhen with existing stop conditions
224+
const baseStopWhen =
225+
typeConfig.stopWhen ?? defaultStopWhen ?? stepCountIs(15);
226+
const effectiveStopWhen = budget
227+
? [baseStopWhen, budget.stopWhen].flat()
228+
: baseStopWhen;
229+
230+
// Common options for both generateText and streamText
231+
const commonOptions = {
232+
model,
233+
tools,
234+
system: systemPrompt,
235+
prompt,
236+
stopWhen: effectiveStopWhen,
237+
prepareStep: typeConfig.prepareStep,
238+
};
239+
240+
// Use streamText if streamWriter is provided, otherwise generateText
241+
if (streamWriter) {
242+
// Emit start event
243+
const startId = generateEventId();
244+
streamWriter.write({
245+
type: "data-subagent",
246+
id: startId,
247+
data: {
248+
event: "start",
249+
subagent: subagent_type,
250+
description,
251+
} satisfies SubagentEventData,
252+
});
253+
254+
const result = streamText({
255+
...commonOptions,
256+
onStepFinish: async (step) => {
257+
// Track cost before anything else
258+
budget?.onStepFinish(step);
259+
// Stream tool calls
260+
if (step.toolCalls?.length) {
261+
for (const tc of step.toolCalls) {
262+
const eventId = generateEventId();
263+
streamWriter.write({
264+
type: "data-subagent",
265+
id: eventId,
266+
data: {
267+
event: "tool-call",
268+
subagent: subagent_type,
269+
description,
270+
toolName: tc.toolName,
271+
args: tc.input as Record<string, unknown>,
272+
} satisfies SubagentEventData,
273+
});
274+
}
275+
}
276+
// Call subagent-specific callback
277+
await typeConfig.onStepFinish?.(step);
278+
// Call default callback with subagent context
279+
await defaultOnStepFinish?.({
280+
subagentType: subagent_type,
281+
description,
282+
step,
283+
});
284+
},
285+
});
286+
287+
// Wait for stream to complete
288+
const text = await result.text;
289+
const usage = await result.usage;
290+
const response = await result.response;
291+
292+
// Emit done event
293+
streamWriter.write({
294+
type: "data-subagent",
295+
id: generateEventId(),
296+
data: {
297+
event: "done",
298+
subagent: subagent_type,
299+
description,
300+
} satisfies SubagentEventData,
301+
});
302+
303+
// Emit complete event with full messages for UI access
304+
streamWriter.write({
305+
type: "data-subagent",
306+
id: generateEventId(),
307+
data: {
308+
event: "complete",
309+
subagent: subagent_type,
310+
description,
311+
messages: response.messages,
312+
} satisfies SubagentEventData,
313+
});
314+
315+
const durationMs = Math.round(performance.now() - startTime);
316+
317+
return {
318+
result: text,
319+
usage:
320+
usage.inputTokens !== undefined &&
321+
usage.outputTokens !== undefined
322+
? {
323+
input_tokens: usage.inputTokens,
324+
output_tokens: usage.outputTokens,
325+
}
326+
: undefined,
327+
duration_ms: durationMs,
252328
subagent: subagent_type,
253329
description,
254-
} satisfies SubagentEventData,
255-
});
330+
};
331+
}
256332

257-
const result = streamText({
333+
// Default: use generateText (no streaming)
334+
const result = await generateText({
258335
...commonOptions,
259336
onStepFinish: async (step) => {
260337
// Track cost before anything else
261338
budget?.onStepFinish(step);
262-
// Stream tool calls
263-
if (step.toolCalls?.length) {
264-
for (const tc of step.toolCalls) {
265-
const eventId = generateEventId();
266-
streamWriter.write({
267-
type: "data-subagent",
268-
id: eventId,
269-
data: {
270-
event: "tool-call",
271-
subagent: subagent_type,
272-
description,
273-
toolName: tc.toolName,
274-
args: tc.input as Record<string, unknown>,
275-
} satisfies SubagentEventData,
276-
});
277-
}
278-
}
279339
// Call subagent-specific callback
280340
await typeConfig.onStepFinish?.(step);
281341
// Call default callback with subagent context
@@ -287,136 +347,63 @@ export function createTaskTool(
287347
},
288348
});
289349

290-
// Wait for stream to complete
291-
const text = await result.text;
292-
const usage = await result.usage;
293-
const response = await result.response;
294-
295-
// Emit done event
296-
streamWriter.write({
297-
type: "data-subagent",
298-
id: generateEventId(),
299-
data: {
300-
event: "done",
301-
subagent: subagent_type,
302-
description,
303-
} satisfies SubagentEventData,
304-
});
305-
306-
// Emit complete event with full messages for UI access
307-
streamWriter.write({
308-
type: "data-subagent",
309-
id: generateEventId(),
310-
data: {
311-
event: "complete",
312-
subagent: subagent_type,
313-
description,
314-
messages: response.messages,
315-
} satisfies SubagentEventData,
316-
});
317-
318350
const durationMs = Math.round(performance.now() - startTime);
319351

320-
// Pop parent context and emit debug end
321-
if (debugId) {
322-
popParent();
323-
debugEnd(debugId, "task", {
324-
summary: {
325-
tokens: {
326-
input: usage.inputTokens,
327-
output: usage.outputTokens,
328-
},
329-
steps: response.messages?.length,
330-
},
331-
duration_ms: durationMs,
332-
});
333-
}
352+
// Format usage
353+
const usage =
354+
result.usage.inputTokens !== undefined &&
355+
result.usage.outputTokens !== undefined
356+
? {
357+
input_tokens: result.usage.inputTokens,
358+
output_tokens: result.usage.outputTokens,
359+
}
360+
: undefined;
334361

335362
return {
336-
result: text,
337-
usage:
338-
usage.inputTokens !== undefined &&
339-
usage.outputTokens !== undefined
340-
? {
341-
input_tokens: usage.inputTokens,
342-
output_tokens: usage.outputTokens,
343-
}
344-
: undefined,
363+
result: result.text,
364+
usage,
345365
duration_ms: durationMs,
346366
subagent: subagent_type,
347367
description,
348368
};
369+
} catch (error) {
370+
const errorMessage =
371+
error instanceof Error ? error.message : "Unknown error";
372+
373+
const durationMs = Math.round(performance.now() - startTime);
374+
return {
375+
error: errorMessage,
376+
subagent: subagent_type,
377+
description,
378+
duration_ms: durationMs,
379+
};
349380
}
381+
};
350382

351-
// Default: use generateText (no streaming)
352-
const result = await generateText({
353-
...commonOptions,
354-
onStepFinish: async (step) => {
355-
// Track cost before anything else
356-
budget?.onStepFinish(step);
357-
// Call subagent-specific callback
358-
await typeConfig.onStepFinish?.(step);
359-
// Call default callback with subagent context
360-
await defaultOnStepFinish?.({
361-
subagentType: subagent_type,
362-
description,
363-
step,
364-
});
365-
},
366-
});
367-
368-
const durationMs = Math.round(performance.now() - startTime);
369-
370-
// Format usage
371-
const usage =
372-
result.usage.inputTokens !== undefined &&
373-
result.usage.outputTokens !== undefined
374-
? {
375-
input_tokens: result.usage.inputTokens,
376-
output_tokens: result.usage.outputTokens,
377-
}
378-
: undefined;
379-
380-
// Pop parent context and emit debug end
381-
if (debugId) {
382-
popParent();
383+
// Wrap in debug parent context so child tool calls are correctly attributed
384+
const result = await runWithDebugParent(debugId, executeTask);
385+
386+
// Emit debug end/error outside the parent context for correct indent level
387+
if (debugId) {
388+
if ("error" in result) {
389+
debugError(debugId, "task", result.error);
390+
} else {
383391
debugEnd(debugId, "task", {
384392
summary: {
385-
tokens: {
386-
input: result.usage.inputTokens,
387-
output: result.usage.outputTokens,
388-
},
389-
steps: result.steps?.length,
393+
tokens: result.usage
394+
? {
395+
input: result.usage.input_tokens,
396+
output: result.usage.output_tokens,
397+
}
398+
: undefined,
390399
},
391-
duration_ms: durationMs,
400+
duration_ms:
401+
result.duration_ms ?? Math.round(performance.now() - startTime),
392402
});
393403
}
394-
395-
return {
396-
result: result.text,
397-
usage,
398-
duration_ms: durationMs,
399-
subagent: subagent_type,
400-
description,
401-
};
402-
} catch (error) {
403-
const errorMessage =
404-
error instanceof Error ? error.message : "Unknown error";
405-
406-
// Pop parent context and emit debug error
407-
if (debugId) {
408-
popParent();
409-
debugError(debugId, "task", errorMessage);
410-
}
411-
412-
const durationMs = Math.round(performance.now() - startTime);
413-
return {
414-
error: errorMessage,
415-
subagent: subagent_type,
416-
description,
417-
duration_ms: durationMs,
418-
};
419404
}
405+
406+
return result;
420407
},
421408
});
422409
}

0 commit comments

Comments
 (0)