From bd99b57db5401b7508bcae0c321ea4c9d978f0c1 Mon Sep 17 00:00:00 2001 From: leggasai Date: Sun, 31 May 2026 16:57:35 +0800 Subject: [PATCH] perf: cache tool lookups in batches --- .changeset/cache-tool-batch-lookups.md | 6 +++ packages/agent-core/src/loop/tool-call.ts | 18 ++++++-- .../test/loop/tool-call.e2e.test.ts | 41 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 .changeset/cache-tool-batch-lookups.md diff --git a/.changeset/cache-tool-batch-lookups.md b/.changeset/cache-tool-batch-lookups.md new file mode 100644 index 00000000..ad515c5d --- /dev/null +++ b/.changeset/cache-tool-batch-lookups.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Cache tool lookups during each tool-call batch. diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 2e9956ec..22b47657 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -120,7 +120,8 @@ export async function runToolCallBatch( response: LLMChatResponse, ): Promise { if (response.toolCalls.length === 0) return { stopTurn: false }; - const calls = response.toolCalls.map((toolCall) => preflightToolCall(step.tools, toolCall)); + const toolsByName = buildToolsByName(step.tools); + const calls = response.toolCalls.map((toolCall) => preflightToolCall(toolsByName, toolCall)); const scheduler = new ToolScheduler(); const pendingResults: Array> = []; let stopTurn = false; @@ -163,18 +164,29 @@ export async function runToolCallBatch( return { stopTurn }; } +function buildToolsByName( + tools: readonly ExecutableTool[] | undefined, +): ReadonlyMap | undefined { + if (tools === undefined || tools.length === 0) return undefined; + const byName = new Map(); + for (const tool of tools) { + if (!byName.has(tool.name)) byName.set(tool.name, tool); + } + return byName; +} + /** * Provider-order validation pass. It does not run hooks, spawn tools, or write * events. Validator compilation may populate the local cache. */ function preflightToolCall( - tools: readonly ExecutableTool[] | undefined, + toolsByName: ReadonlyMap | undefined, toolCall: ToolCall, ): PreflightedToolCall { const toolName = toolCall.name; const parsedArgs = parseToolCallArguments(toolCall.arguments); const args = parsedArgs.success ? parsedArgs.data : {}; - const tool = tools?.find((candidate) => candidate.name === toolName); + const tool = toolsByName?.get(toolName); if (tool === undefined) { return { kind: 'rejected', diff --git a/packages/agent-core/test/loop/tool-call.e2e.test.ts b/packages/agent-core/test/loop/tool-call.e2e.test.ts index 2f1e500e..6ed1e3cd 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -114,6 +114,47 @@ describe('runTurn — tool-call behaviour', () => { }); }); + it('uses the first registered tool when duplicate names are present', async () => { + const firstCalls: string[] = []; + const secondCalls: string[] = []; + const first: ExecutableTool = { + name: 'dupe', + description: 'first duplicate tool', + parameters: { type: 'object', additionalProperties: true }, + resolveExecution: () => ({ + approvalRule: 'dupe', + execute: async (ctx) => { + firstCalls.push(ctx.toolCallId); + return { output: 'first' }; + }, + }), + }; + const second: ExecutableTool = { + name: 'dupe', + description: 'second duplicate tool', + parameters: { type: 'object', additionalProperties: true }, + resolveExecution: () => ({ + approvalRule: 'dupe', + execute: async (ctx) => { + secondCalls.push(ctx.toolCallId); + return { output: 'second' }; + }, + }), + }; + + const { context } = await runTurn({ + tools: [first, second], + responses: [ + makeToolUseResponse([makeToolCall('dupe', {}, 'tc-1')]), + makeEndTurnResponse('done'), + ], + }); + + expect(firstCalls).toEqual(['tc-1']); + expect(secondCalls).toEqual([]); + expect(context.toolResults()[0]?.result.output).toBe('first'); + }); + it('records an error tool.result when the tool name is unknown', async () => { const { sink, context } = await runTurn({ tools: [], // no tools at all