Skip to content

Commit 5591d9b

Browse files
author
Bot
committed
2 parents 02cfa62 + a7e03a5 commit 5591d9b

7 files changed

Lines changed: 108 additions & 15 deletions

File tree

src/Tool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@ export type ToolUseContext = {
277277
criticalSystemReminder_EXPERIMENTAL?: string
278278
/** Langfuse root trace span for this query turn. Passed down to tool execution for observability. */
279279
langfuseTrace?: LangfuseSpan | null
280+
/** Langfuse batch span wrapping a concurrent tool group. When set, tool observations are nested under it. */
281+
langfuseBatchSpan?: LangfuseSpan | null
280282
/** When true, preserve toolUseResult on messages even for subagents.
281283
* Used by in-process teammates whose transcripts are viewable by the user. */
282284
preserveToolUseResults?: boolean

src/query.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,17 @@ export async function* query(
254254
}
255255
: params
256256

257-
let terminal: Terminal
257+
let terminal: Terminal | undefined
258258
try {
259259
terminal = yield* queryLoop(paramsWithTrace, consumedCommandUuids)
260260
} finally {
261261
// Only end the trace if we created it — sub-agents own their traces
262-
if (ownsTrace) endTrace(langfuseTrace)
262+
if (ownsTrace) {
263+
const isAborted =
264+
terminal?.reason === 'aborted_streaming' ||
265+
terminal?.reason === 'aborted_tools'
266+
endTrace(langfuseTrace, undefined, isAborted ? 'interrupted' : undefined)
267+
}
263268
}
264269

265270
// Only reached if queryLoop returned normally. Skipped on throw (error
@@ -269,7 +274,8 @@ export async function* query(
269274
for (const uuid of consumedCommandUuids) {
270275
notifyCommandLifecycle(uuid, 'completed')
271276
}
272-
return terminal
277+
// biome-ignore lint/style/noNonNullAssertion: terminal is always assigned when queryLoop returns normally
278+
return terminal!
273279
}
274280

275281
async function* queryLoop(

src/services/langfuse/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js'
2-
export { createTrace, createSubagentTrace, recordLLMObservation, recordToolObservation, endTrace } from './tracing.js'
2+
export { createTrace, createSubagentTrace, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js'
33
export type { LangfuseSpan } from './tracing.js'
44
export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js'

src/services/langfuse/tracing.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,15 @@ export function recordToolObservation(
117117
output: string
118118
startTime?: Date
119119
isError?: boolean
120+
parentBatchSpan?: LangfuseSpan | null
120121
},
121122
): void {
122123
if (!rootSpan || !isLangfuseEnabled()) return
123124
try {
124125
// Use the global startObservation directly instead of rootSpan.startObservation().
125126
// The instance method only forwards asType and drops startTime,
126127
// causing tool execution duration to be 0.
128+
const parentSpan = params.parentBatchSpan ?? rootSpan
127129
const toolObs = startObservation(
128130
params.toolName,
129131
{
@@ -136,7 +138,7 @@ export function recordToolObservation(
136138
{
137139
asType: 'tool',
138140
...(params.startTime && { startTime: params.startTime }),
139-
parentSpanContext: rootSpan.otelSpan.spanContext(),
141+
parentSpanContext: parentSpan.otelSpan.spanContext(),
140142
},
141143
)
142144

@@ -158,6 +160,55 @@ export function recordToolObservation(
158160
}
159161
}
160162

163+
/**
164+
* Create a span that wraps a batch of concurrent tool calls.
165+
* Returns the batch span (to be passed as parentBatchSpan to recordToolObservation)
166+
* and must be ended with endToolBatchSpan() after all tools complete.
167+
*/
168+
export function createToolBatchSpan(
169+
rootSpan: LangfuseSpan | null,
170+
params: { toolNames: string[]; batchIndex: number },
171+
): LangfuseSpan | null {
172+
if (!rootSpan || !isLangfuseEnabled()) return null
173+
try {
174+
const batchSpan = startObservation(
175+
`tools`,
176+
{
177+
metadata: {
178+
toolNames: params.toolNames.join(', '),
179+
toolCount: String(params.toolNames.length),
180+
batchIndex: String(params.batchIndex),
181+
},
182+
},
183+
{
184+
asType: 'span',
185+
parentSpanContext: rootSpan.otelSpan.spanContext(),
186+
},
187+
) as LangfuseSpan
188+
189+
const sessionId = (rootSpan as unknown as RootTrace)._sessionId
190+
if (sessionId) {
191+
batchSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
192+
}
193+
194+
logForDebugging(`[langfuse] Tool batch span created: ${batchSpan.id} (tools=${params.toolNames.join(',')})`)
195+
return batchSpan
196+
} catch (e) {
197+
logForDebugging(`[langfuse] createToolBatchSpan failed: ${e}`, { level: 'error' })
198+
return null
199+
}
200+
}
201+
202+
export function endToolBatchSpan(batchSpan: LangfuseSpan | null): void {
203+
if (!batchSpan) return
204+
try {
205+
batchSpan.end()
206+
logForDebugging(`[langfuse] Tool batch span ended: ${batchSpan.id}`)
207+
} catch (e) {
208+
logForDebugging(`[langfuse] endToolBatchSpan failed: ${e}`, { level: 'error' })
209+
}
210+
}
211+
161212
export function createSubagentTrace(params: {
162213
sessionId: string
163214
agentType: string
@@ -187,14 +238,20 @@ export function createSubagentTrace(params: {
187238
}
188239
}
189240

190-
export function endTrace(rootSpan: LangfuseSpan | null, output?: unknown): void {
241+
export function endTrace(
242+
rootSpan: LangfuseSpan | null,
243+
output?: unknown,
244+
status?: 'interrupted' | 'error',
245+
): void {
191246
if (!rootSpan) return
192247
try {
193-
if (output !== undefined) {
194-
rootSpan.update({ output })
195-
}
248+
const updatePayload: Record<string, unknown> = {}
249+
if (output !== undefined) updatePayload.output = output
250+
if (status === 'interrupted') updatePayload.level = 'WARNING'
251+
else if (status === 'error') updatePayload.level = 'ERROR'
252+
if (Object.keys(updatePayload).length > 0) rootSpan.update(updatePayload)
196253
rootSpan.end()
197-
logForDebugging(`[langfuse] Trace ended: ${rootSpan.id}`)
254+
logForDebugging(`[langfuse] Trace ended: ${rootSpan.id}${status ? ` (${status})` : ''}`)
198255
} catch (e) {
199256
logForDebugging(`[langfuse] endTrace failed: ${e}`, { level: 'error' })
200257
}

src/services/tools/StreamingToolExecutor.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/t
1010
import type { AssistantMessage, Message } from '../../types/message.js'
1111
import { createChildAbortController } from '../../utils/abortController.js'
1212
import { runToolUse } from './toolExecution.js'
13+
import { createToolBatchSpan, endToolBatchSpan } from '../langfuse/index.js'
14+
import type { LangfuseSpan } from '../langfuse/index.js'
1315

1416
type MessageUpdate = {
1517
message?: Message
@@ -42,13 +44,10 @@ export class StreamingToolExecutor {
4244
private toolUseContext: ToolUseContext
4345
private hasErrored = false
4446
private erroredToolDescription = ''
45-
// Child of toolUseContext.abortController. Fires when a Bash tool errors
46-
// so sibling subprocesses die immediately instead of running to completion.
47-
// Aborting this does NOT abort the parent — query.ts won't end the turn.
4847
private siblingAbortController: AbortController
4948
private discarded = false
50-
// Signal to wake up getRemainingResults when progress is available
5149
private progressAvailableResolve?: () => void
50+
private turnSpan: LangfuseSpan | null = null
5251

5352
constructor(
5453
private readonly toolDefinitions: Tools,
@@ -74,6 +73,16 @@ export class StreamingToolExecutor {
7473
* Add a tool to the execution queue. Will start executing immediately if conditions allow.
7574
*/
7675
addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
76+
// Create turn span on first tool — will be ended in getRemainingResults
77+
if (this.tools.length === 0 && this.turnSpan === null) {
78+
this.turnSpan = createToolBatchSpan(
79+
this.toolUseContext.langfuseTrace ?? null,
80+
{ toolNames: [block.name], batchIndex: 0 },
81+
)
82+
if (this.turnSpan) {
83+
this.toolUseContext = { ...this.toolUseContext, langfuseBatchSpan: this.turnSpan }
84+
}
85+
}
7786
const toolDefinition = findToolByName(this.toolDefinitions, block.name)
7887
if (!toolDefinition) {
7988
this.tools.push({
@@ -487,6 +496,9 @@ export class StreamingToolExecutor {
487496
for (const result of this.getCompletedResults()) {
488497
yield result
489498
}
499+
500+
endToolBatchSpan(this.turnSpan)
501+
this.turnSpan = null
490502
}
491503

492504
/**

src/services/tools/toolExecution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,7 @@ async function checkPermissionsAndCallTool(
13091309
output: toolResultStr,
13101310
startTime: new Date(startTime),
13111311
isError: false,
1312+
parentBatchSpan: toolUseContext.langfuseBatchSpan,
13121313
})
13131314

13141315
// Map the tool result to API format once and cache it. This block is reused
@@ -1628,6 +1629,7 @@ async function checkPermissionsAndCallTool(
16281629
output: errorMessage(error),
16291630
startTime: new Date(startTime),
16301631
isError: true,
1632+
parentBatchSpan: toolUseContext.langfuseBatchSpan,
16311633
})
16321634

16331635
// Handle MCP auth errors by updating the client status to 'needs-auth'

src/services/tools/toolOrchestration.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { findToolByName, type ToolUseContext } from '../../Tool.js'
44
import type { AssistantMessage, Message } from '../../types/message.js'
55
import { all } from '../../utils/generators.js'
66
import { type MessageUpdateLazy, runToolUse } from './toolExecution.js'
7+
import { createToolBatchSpan, endToolBatchSpan } from '../langfuse/index.js'
78

89
function getMaxToolUseConcurrency(): number {
910
return (
@@ -22,7 +23,18 @@ export async function* runTools(
2223
canUseTool: CanUseToolFn,
2324
toolUseContext: ToolUseContext,
2425
): AsyncGenerator<MessageUpdate, void> {
25-
let currentContext = toolUseContext
26+
// Wrap all tool calls in this turn under a single Langfuse turn span
27+
const turnSpan = toolUseMessages.length > 0
28+
? createToolBatchSpan(toolUseContext.langfuseTrace ?? null, {
29+
toolNames: toolUseMessages.map(b => b.name),
30+
batchIndex: 0,
31+
})
32+
: null
33+
const contextWithTurn = turnSpan
34+
? { ...toolUseContext, langfuseBatchSpan: turnSpan }
35+
: toolUseContext
36+
37+
let currentContext = contextWithTurn
2638
for (const { isConcurrencySafe, blocks } of partitionToolCalls(
2739
toolUseMessages,
2840
currentContext,
@@ -79,6 +91,8 @@ export async function* runTools(
7991
}
8092
}
8193
}
94+
95+
endToolBatchSpan(turnSpan)
8296
}
8397

8498
type Batch = { isConcurrencySafe: boolean; blocks: ToolUseBlock[] }

0 commit comments

Comments
 (0)