Skip to content

Commit f43b59e

Browse files
authored
Add deterministic tool call ids (#594)
1 parent 7562031 commit f43b59e

8 files changed

Lines changed: 160 additions & 17 deletions

File tree

packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,28 @@ describe('runProgrammaticStep', () => {
212212
})
213213

214214
describe('tool execution', () => {
215+
it('assigns deterministic per-tool ids to handleSteps tool calls', async () => {
216+
const mockGenerator = (function* () {
217+
yield { toolName: 'read_files', input: { paths: ['first.txt'] } }
218+
yield { toolName: 'read_files', input: { paths: ['second.txt'] } }
219+
yield { toolName: 'end_turn', input: {} }
220+
})() as StepGenerator
221+
222+
mockTemplate.handleSteps = () => mockGenerator
223+
224+
await runProgrammaticStep(mockParams)
225+
226+
expect(executeToolCallSpy.mock.calls[0][0].toolCallId).toBe(
227+
'functions.read_files:0',
228+
)
229+
expect(executeToolCallSpy.mock.calls[1][0].toolCallId).toBe(
230+
'functions.read_files:1',
231+
)
232+
expect(executeToolCallSpy.mock.calls[2][0].toolCallId).toBe(
233+
'functions.end_turn:0',
234+
)
235+
})
236+
215237
it('should not add tool call message for add_message tool', async () => {
216238
const mockGenerator = (function* () {
217239
yield {

packages/agent-runtime/src/__tests__/tool-validation-error.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,13 +401,16 @@ describe('tool validation error handling', () => {
401401
)
402402
expect(toolCallEvents.length).toBe(1)
403403
expect(toolCallEvents[0].toolName).toBe('read_files')
404+
expect(toolCallEvents[0].toolCallId).toBe('functions.read_files:0')
404405

405406
// Verify tool_result event was emitted
406407
const toolResultEvents = responseChunks.filter(
407408
(chunk): chunk is Extract<PrintModeEvent, { type: 'tool_result' }> =>
408409
typeof chunk !== 'string' && chunk.type === 'tool_result',
409410
)
410411
expect(toolResultEvents.length).toBe(1)
412+
expect(toolResultEvents[0].toolName).toBe('read_files')
413+
expect(toolResultEvents[0].toolCallId).toBe('functions.read_files:0')
411414

412415
// Verify NO error events
413416
const errorEvents = responseChunks.filter(

packages/agent-runtime/src/run-programmatic-step.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { cloneDeep } from 'lodash'
66
import { clearProposedContentForRun } from './tools/handlers/tool/proposed-content-store'
77
import { executeToolCall } from './tools/tool-executor'
88
import { parseTextWithToolCalls } from './util/parse-tool-calls-from-text'
9-
9+
import { createToolCallIdGenerator } from './util/tool-call-id'
1010

1111
import type { FileProcessingState } from './tools/handlers/tool/write-file'
1212
import type { ExecuteToolCallParams } from './tools/tool-executor'
@@ -213,6 +213,7 @@ export async function runProgrammaticStep(
213213
let toolResult: ToolResultOutput[] | undefined = undefined
214214
let endTurn = false
215215
let generateN: number | undefined = undefined
216+
const getToolCallId = createToolCallIdGenerator(agentState.messageHistory)
216217

217218
let startTime = new Date()
218219
let creditsBefore = agentState.directCreditsUsed
@@ -273,6 +274,7 @@ export async function runProgrammaticStep(
273274
previousToolCallFinished: Promise.resolve(),
274275
toolCalls,
275276
toolResults,
277+
getToolCallId,
276278
onResponseChunk,
277279
})
278280
}
@@ -301,6 +303,7 @@ export async function runProgrammaticStep(
301303
previousToolCallFinished: Promise.resolve(),
302304
toolCalls,
303305
toolResults,
306+
getToolCallId,
304307
onResponseChunk,
305308
})
306309

@@ -432,6 +435,7 @@ type ExecuteToolCallsArrayParams = Omit<
432435
| 'toolResultsToAddToMessageHistory'
433436
> & {
434437
agentState: AgentState
438+
getToolCallId: (toolName: string) => string
435439
onResponseChunk: (chunk: string | PrintModeEvent) => void
436440
}
437441

@@ -445,7 +449,7 @@ async function executeSingleToolCall(
445449
toolCallToExecute: ToolCallToExecute,
446450
params: ExecuteToolCallsArrayParams,
447451
): Promise<ToolResultOutput[] | undefined> {
448-
const { agentState, onResponseChunk, toolResults } = params
452+
const { agentState, getToolCallId, onResponseChunk, toolResults } = params
449453

450454
// Note: We don't check if the tool is available for the agent template anymore.
451455
// You can run any tool from handleSteps now!
@@ -455,7 +459,7 @@ async function executeSingleToolCall(
455459
// )
456460
// }
457461

458-
const toolCallId = crypto.randomUUID()
462+
const toolCallId = getToolCallId(toolCallToExecute.toolName)
459463
const excludeToolFromMessageHistory =
460464
toolCallToExecute.includeToolCall === false
461465

packages/agent-runtime/src/tool-stream-parser.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ export async function* processStreamWithTools(params: {
5050
}
5151
trackEvent: TrackEventFn
5252
executeXmlToolCall: (params: {
53-
toolCallId: string
5453
toolName: string
5554
input: Record<string, unknown>
5655
}) => Promise<void>
@@ -150,12 +149,9 @@ export async function* processStreamWithTools(params: {
150149

151150
// Then process and yield any XML tool calls found
152151
for (const toolCall of toolCalls) {
153-
const toolCallId = `xml-${crypto.randomUUID().slice(0, 8)}`
154-
155152
// Execute the tool immediately if callback provided, pausing the stream
156153
// The callback handles emitting tool_call and tool_result events
157154
await executeXmlToolCall({
158-
toolCallId,
159155
toolName: toolCall.toolName,
160156
input: toolCall.input,
161157
})

packages/agent-runtime/src/tools/stream-parser.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
assistantMessage,
66
userMessage,
77
} from '@codebuff/common/util/messages'
8-
import { generateCompactId } from '@codebuff/common/util/string'
98

109
import { processStreamWithTools } from '../tool-stream-parser'
1110
import { INCLUDE_REASONING_IN_MESSAGE_HISTORY } from '../constants'
@@ -14,6 +13,7 @@ import {
1413
executeToolCall,
1514
tryTransformAgentToolCall,
1615
} from './tool-executor'
16+
import { createToolCallIdGenerator } from '../util/tool-call-id'
1717
import { withSystemTags } from '../util/messages'
1818

1919
import type { CustomToolCall, ExecuteToolCallParams } from './tool-executor'
@@ -91,6 +91,7 @@ export async function processStream(
9191
const toolCalls: (CodebuffToolCall | CustomToolCall)[] = []
9292
const toolCallsToAddToMessageHistory: (CodebuffToolCall | CustomToolCall)[] = []
9393
const assistantMessages: Message[] = []
94+
const getToolCallId = createToolCallIdGenerator(params.messages)
9495
let hadToolCallError = false
9596
const errorMessages: Message[] = []
9697
const { promise: streamDonePromise, resolve: resolveStreamDonePromise } =
@@ -137,7 +138,6 @@ export async function processStream(
137138
if (signal.aborted) {
138139
return
139140
}
140-
const toolCallId = generateCompactId()
141141
const isNativeTool = toolNames.includes(toolName as ToolName)
142142

143143
// Check if this is an agent tool call that should be transformed to spawn_agents
@@ -160,19 +160,20 @@ export async function processStream(
160160
// Determine which executor to use and with what parameters
161161
let toolPromise: Promise<void>
162162
if (isNativeTool || transformed) {
163+
const effectiveToolName = transformed
164+
? transformed.toolName
165+
: (toolName as ToolName)
163166
// Use executeToolCall for native tools or transformed agent calls
164167
toolPromise = executeToolCall({
165168
...params,
166-
toolName: transformed
167-
? transformed.toolName
168-
: (toolName as ToolName),
169+
toolName: effectiveToolName,
169170
input: transformed ? transformed.input : input,
170171
fromHandleSteps: false,
171172

172173
fileProcessingState,
173174
fullResponse: fullResponseChunks.join(''),
174175
previousToolCallFinished: previousPromise,
175-
toolCallId,
176+
toolCallId: getToolCallId(effectiveToolName),
176177
toolCalls,
177178
toolCallsToAddToMessageHistory,
178179
toolResults,
@@ -191,7 +192,7 @@ export async function processStream(
191192
fileProcessingState,
192193
fullResponse: fullResponseChunks.join(''),
193194
previousToolCallFinished: previousPromise,
194-
toolCallId,
195+
toolCallId: getToolCallId(toolName),
195196
toolCalls,
196197
toolCallsToAddToMessageHistory,
197198
toolResults,

packages/agent-runtime/src/tools/tool-executor.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { endsAgentStepParam, toolNames } from '@codebuff/common/tools/constants'
22
import { toolParams } from '@codebuff/common/tools/list'
3-
import { generateCompactId } from '@codebuff/common/util/string'
43
import { cloneDeep } from 'lodash'
54

65
import { getMCPToolData } from '../mcp'
76
import { MCP_TOOL_SEPARATOR } from '../mcp-constants'
87
import { getAgentShortName, getAgentToolName } from '../templates/prompts'
98
import { formatValueForError } from '../util/format-value'
9+
import { createToolCallIdGenerator } from '../util/tool-call-id'
1010
import { codebuffToolHandlers } from './handlers/list'
1111
import { getMatchingSpawn } from './handlers/tool/spawn-agent-utils'
1212
import { getAgentTemplate } from '../templates/agent-registry'
@@ -308,7 +308,9 @@ export async function executeToolCall<T extends ToolName>(
308308
onResponseChunk,
309309
requestToolCall,
310310
} = params
311-
const toolCallId = params.toolCallId ?? generateCompactId()
311+
const toolCallId =
312+
params.toolCallId ??
313+
createToolCallIdGenerator(agentState.messageHistory, toolCalls)(toolName)
312314

313315
const toolCall: CodebuffToolCall<T> | ToolCallError = parseRawToolCall<T>({
314316
rawToolCall: {
@@ -640,7 +642,11 @@ export async function executeCustomToolCall(
640642
}),
641643
rawToolCall: {
642644
toolName,
643-
toolCallId: toolCallId ?? generateCompactId(),
645+
toolCallId:
646+
toolCallId ??
647+
createToolCallIdGenerator(agentState.messageHistory, toolCalls)(
648+
toolName,
649+
),
644650
input,
645651
},
646652
autoInsertEndStepParam,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { assistantMessage } from '@codebuff/common/util/messages'
2+
import { describe, expect, it } from 'bun:test'
3+
4+
import {
5+
countToolCallsByName,
6+
createToolCallIdGenerator,
7+
formatToolCallId,
8+
} from '../tool-call-id'
9+
10+
describe('tool call ids', () => {
11+
it('formats ids with the tool name and per-tool invocation index', () => {
12+
expect(formatToolCallId('glob', 0)).toBe('functions.glob:0')
13+
})
14+
15+
it('seeds per-tool counters from existing message history', () => {
16+
const messages = [
17+
assistantMessage({
18+
type: 'tool-call',
19+
toolName: 'glob',
20+
toolCallId: 'functions.glob:0',
21+
input: { pattern: '**/*.ts' },
22+
}),
23+
assistantMessage({
24+
type: 'tool-call',
25+
toolName: 'read_files',
26+
toolCallId: 'functions.read_files:0',
27+
input: { paths: ['src/index.ts'] },
28+
}),
29+
assistantMessage({
30+
type: 'tool-call',
31+
toolName: 'glob',
32+
toolCallId: 'functions.glob:1',
33+
input: { pattern: '**/*.tsx' },
34+
}),
35+
]
36+
37+
expect(countToolCallsByName(messages)).toEqual(
38+
new Map([
39+
['glob', 2],
40+
['read_files', 1],
41+
]),
42+
)
43+
44+
const getToolCallId = createToolCallIdGenerator(messages)
45+
46+
expect(getToolCallId('glob')).toBe('functions.glob:2')
47+
expect(getToolCallId('glob')).toBe('functions.glob:3')
48+
expect(getToolCallId('read_files')).toBe('functions.read_files:1')
49+
})
50+
51+
it('can seed counters from pending tool calls', () => {
52+
const getToolCallId = createToolCallIdGenerator([], [
53+
{
54+
toolName: 'glob',
55+
},
56+
{
57+
toolName: 'glob',
58+
},
59+
])
60+
61+
expect(getToolCallId('glob')).toBe('functions.glob:2')
62+
})
63+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Message } from '@codebuff/common/types/messages/codebuff-message'
2+
3+
const TOOL_CALL_ID_PREFIX = 'functions'
4+
type ToolCallLike = { toolName: string }
5+
6+
export function formatToolCallId(toolName: string, index: number): string {
7+
return `${TOOL_CALL_ID_PREFIX}.${toolName}:${index}`
8+
}
9+
10+
export function countToolCallsByName(
11+
messages: Message[],
12+
pendingToolCalls: ToolCallLike[] = [],
13+
): Map<string, number> {
14+
const counts = new Map<string, number>()
15+
16+
for (const message of messages) {
17+
if (message.role !== 'assistant') {
18+
continue
19+
}
20+
21+
for (const part of message.content) {
22+
if (part.type !== 'tool-call') {
23+
continue
24+
}
25+
26+
counts.set(part.toolName, (counts.get(part.toolName) ?? 0) + 1)
27+
}
28+
}
29+
30+
for (const toolCall of pendingToolCalls) {
31+
counts.set(toolCall.toolName, (counts.get(toolCall.toolName) ?? 0) + 1)
32+
}
33+
34+
return counts
35+
}
36+
37+
export function createToolCallIdGenerator(
38+
messages: Message[],
39+
pendingToolCalls: ToolCallLike[] = [],
40+
) {
41+
const counts = countToolCallsByName(messages, pendingToolCalls)
42+
43+
return (toolName: string): string => {
44+
const index = counts.get(toolName) ?? 0
45+
counts.set(toolName, index + 1)
46+
return formatToolCallId(toolName, index)
47+
}
48+
}

0 commit comments

Comments
 (0)