From 32793a5731015f468278c1d1c8f316e389c7be2b Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 14 Feb 2026 01:45:30 +0100 Subject: [PATCH 1/2] fix(ai): trace caller.toolId dependencies in pruneMessages Fixes #12504 --- .../src/generate-text/prune-messages.test.ts | 328 ++++++++++++++++++ .../ai/src/generate-text/prune-messages.ts | 65 ++++ 2 files changed, 393 insertions(+) diff --git a/packages/ai/src/generate-text/prune-messages.test.ts b/packages/ai/src/generate-text/prune-messages.test.ts index 49d52b4c4d64..05fc1fe9a5f4 100644 --- a/packages/ai/src/generate-text/prune-messages.test.ts +++ b/packages/ai/src/generate-text/prune-messages.test.ts @@ -716,5 +716,333 @@ describe('pruneMessages', () => { `); }); }); + + describe('caller.toolId dependencies', () => { + it('should keep server_tool_use referenced by a kept tool-call via providerOptions caller.toolId', () => { + const messages: ModelMessage[] = [ + { + role: 'user', + content: [{ type: 'text', text: 'Run code and query db' }], + }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'srvtoolu_01CodeExec', + toolName: 'code_execution', + input: '{"code":"print(1)"}', + providerExecuted: true, + }, + { + type: 'tool-call', + toolCallId: 'toolu_01Query', + toolName: 'query_database', + input: '{"sql":"SELECT 1"}', + providerOptions: { + anthropic: { + caller: { + type: 'code_execution_20250825', + toolId: 'srvtoolu_01CodeExec', + }, + }, + }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'srvtoolu_01CodeExec', + toolName: 'code_execution', + output: { type: 'text', value: '1' }, + }, + { + type: 'tool-result', + toolCallId: 'toolu_01Query', + toolName: 'query_database', + output: { type: 'text', value: 'result' }, + }, + ], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Done' }], + }, + ]; + + const result = pruneMessages({ + messages, + toolCalls: 'before-last-2-messages', + }); + + // Both tool calls and results should be kept because toolu_01Query + // references srvtoolu_01CodeExec via caller.toolId + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Run code and query db", + "type": "text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "input": "{"code":"print(1)"}", + "providerExecuted": true, + "toolCallId": "srvtoolu_01CodeExec", + "toolName": "code_execution", + "type": "tool-call", + }, + { + "input": "{"sql":"SELECT 1"}", + "providerOptions": { + "anthropic": { + "caller": { + "toolId": "srvtoolu_01CodeExec", + "type": "code_execution_20250825", + }, + }, + }, + "toolCallId": "toolu_01Query", + "toolName": "query_database", + "type": "tool-call", + }, + ], + "role": "assistant", + }, + { + "content": [ + { + "output": { + "type": "text", + "value": "1", + }, + "toolCallId": "srvtoolu_01CodeExec", + "toolName": "code_execution", + "type": "tool-result", + }, + { + "output": { + "type": "text", + "value": "result", + }, + "toolCallId": "toolu_01Query", + "toolName": "query_database", + "type": "tool-result", + }, + ], + "role": "tool", + }, + { + "content": [ + { + "text": "Done", + "type": "text", + }, + ], + "role": "assistant", + }, + ] + `); + }); + + it('should transitively trace caller.toolId chains', () => { + const messages: ModelMessage[] = [ + { + role: 'user', + content: [{ type: 'text', text: 'nested tool calls' }], + }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'srv_root', + toolName: 'code_execution', + input: '{}', + providerExecuted: true, + }, + { + type: 'tool-call', + toolCallId: 'srv_mid', + toolName: 'intermediate_tool', + input: '{}', + providerOptions: { + anthropic: { + caller: { + type: 'code_execution_20250825', + toolId: 'srv_root', + }, + }, + }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'srv_root', + toolName: 'code_execution', + output: { type: 'text', value: 'ok' }, + }, + { + type: 'tool-result', + toolCallId: 'srv_mid', + toolName: 'intermediate_tool', + output: { type: 'text', value: 'ok' }, + }, + ], + }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'toolu_leaf', + toolName: 'leaf_tool', + input: '{}', + providerOptions: { + anthropic: { + caller: { + type: 'code_execution_20250825', + toolId: 'srv_mid', + }, + }, + }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'toolu_leaf', + toolName: 'leaf_tool', + output: { type: 'text', value: 'leaf result' }, + }, + ], + }, + ]; + + const result = pruneMessages({ + messages, + toolCalls: 'before-last-2-messages', + }); + + // toolu_leaf (kept) -> srv_mid -> srv_root: all should be kept + const allToolCallIds = result.flatMap(m => + typeof m.content === 'string' + ? [] + : m.content + .filter( + (p): p is { type: 'tool-call'; toolCallId: string } => + p.type === 'tool-call', + ) + .map(p => p.toolCallId), + ); + expect(allToolCallIds).toContain('srv_root'); + expect(allToolCallIds).toContain('srv_mid'); + expect(allToolCallIds).toContain('toolu_leaf'); + }); + + it('should not affect pruning when no caller.toolId is present', () => { + // This verifies backward compatibility + const result = pruneMessages({ + messages: messagesFixture1, + toolCalls: 'before-last-2-messages', + }); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "Weather in Tokyo and Busan?", + "type": "text", + }, + ], + "role": "user", + }, + { + "content": [ + { + "text": "I need to get the weather in Tokyo and Busan.", + "type": "reasoning", + }, + { + "input": "{"city": "Tokyo"}", + "toolCallId": "call-1", + "toolName": "get-weather-tool-1", + "type": "tool-call", + }, + { + "input": "{"city": "Busan"}", + "toolCallId": "call-2", + "toolName": "get-weather-tool-2", + "type": "tool-call", + }, + { + "approvalId": "approval-1", + "toolCallId": "call-2", + "type": "tool-approval-request", + }, + ], + "role": "assistant", + }, + { + "content": [ + { + "approvalId": "approval-1", + "approved": true, + "type": "tool-approval-response", + }, + { + "output": { + "type": "text", + "value": "sunny", + }, + "toolCallId": "call-1", + "toolName": "get-weather-tool-1", + "type": "tool-result", + }, + { + "output": { + "type": "error-text", + "value": "Error: Fetching weather data failed", + }, + "toolCallId": "call-2", + "toolName": "get-weather-tool-2", + "type": "tool-result", + }, + ], + "role": "tool", + }, + { + "content": [ + { + "text": "I have got the weather in Tokyo and Busan.", + "type": "reasoning", + }, + { + "text": "The weather in Tokyo is sunny. I could not get the weather in Busan.", + "type": "text", + }, + ], + "role": "assistant", + }, + ] + `); + }); + }); }); }); diff --git a/packages/ai/src/generate-text/prune-messages.ts b/packages/ai/src/generate-text/prune-messages.ts index 953cc3ea7211..75b29412a8b0 100644 --- a/packages/ai/src/generate-text/prune-messages.ts +++ b/packages/ai/src/generate-text/prune-messages.ts @@ -98,6 +98,12 @@ export function pruneMessages({ } } } + + // Transitively trace caller.toolId dependencies across all messages. + // When a tool-call references another tool via providerOptions caller.toolId + // (e.g. Anthropic programmatic tool calling), the referenced tool must also + // be kept to avoid orphaned references. + traceCallerDependencies(messages, keptToolCallIds); } messages = messages.map((message, messageIndex) => { @@ -165,3 +171,62 @@ export function pruneMessages({ return messages; } + +/** + * Traces caller.toolId dependencies across all messages, adding referenced + * toolCallIds to the kept set. This handles cases where a tool-call references + * another tool via providerOptions (e.g. Anthropic's code_execution + * programmatic tool calling where caller.toolId points to a server_tool_use). + */ +function traceCallerDependencies( + messages: ModelMessage[], + keptToolCallIds: Set, +): void { + // Build a map from toolCallId to its caller.toolId for all tool-call parts + const callerDeps = new Map(); + for (const message of messages) { + if ( + (message.role === 'assistant' || message.role === 'tool') && + typeof message.content !== 'string' + ) { + for (const part of message.content) { + if (part.type === 'tool-call') { + const callerToolId = getCallerToolId(part.providerOptions); + if (callerToolId != null) { + callerDeps.set(part.toolCallId, callerToolId); + } + } + } + } + } + + // Iteratively resolve: if a kept tool-call depends on another tool, + // add that tool to the kept set. Repeat until stable. + let changed = true; + while (changed) { + changed = false; + for (const toolCallId of keptToolCallIds) { + const depId = callerDeps.get(toolCallId); + if (depId != null && !keptToolCallIds.has(depId)) { + keptToolCallIds.add(depId); + changed = true; + } + } + } +} + +function getCallerToolId( + providerOptions: Record> | undefined, +): string | undefined { + if (providerOptions == null) return undefined; + + // Search across all provider namespaces for a caller.toolId reference + for (const providerData of Object.values(providerOptions)) { + const caller = providerData?.caller as { toolId?: string } | undefined; + if (typeof caller?.toolId === 'string') { + return caller.toolId; + } + } + + return undefined; +} From f5ce2f258ae749ab9ddbbb21c7c6d399be867c1f Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 14 Feb 2026 07:18:44 +0100 Subject: [PATCH 2/2] test(ai): fix type-safe toolCallId extraction in prune-messages test --- .../src/generate-text/prune-messages.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/generate-text/prune-messages.test.ts b/packages/ai/src/generate-text/prune-messages.test.ts index 05fc1fe9a5f4..3f22cfeacabb 100644 --- a/packages/ai/src/generate-text/prune-messages.test.ts +++ b/packages/ai/src/generate-text/prune-messages.test.ts @@ -940,16 +940,15 @@ describe('pruneMessages', () => { }); // toolu_leaf (kept) -> srv_mid -> srv_root: all should be kept - const allToolCallIds = result.flatMap(m => - typeof m.content === 'string' - ? [] - : m.content - .filter( - (p): p is { type: 'tool-call'; toolCallId: string } => - p.type === 'tool-call', - ) - .map(p => p.toolCallId), - ); + const allToolCallIds = result.flatMap(m => { + if (typeof m.content === 'string') { + return []; + } + + return m.content + .map(part => (part.type === 'tool-call' ? part.toolCallId : null)) + .filter((toolCallId): toolCallId is string => toolCallId != null); + }); expect(allToolCallIds).toContain('srv_root'); expect(allToolCallIds).toContain('srv_mid'); expect(allToolCallIds).toContain('toolu_leaf');