diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 157fe10e098..2f107dfa6b7 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -1268,7 +1268,15 @@ describe('AnthropicContentConverter', () => { }); }); - it('drops tool results that do not lead user content', () => { + it('reorders a tool_result ahead of other content in the same message rather than dropping it', () => { + // Anthropic requires tool_result to be the first content in a user + // message replying to a tool_use. A text part preceding the + // functionResponse part within the same Gemini Content used to be + // treated by cleanOrphanedToolCalls's own "seenNonToolResult" gate as + // if the tool_result never showed up at all -- silently discarding + // both the tool_result AND its paired tool_use, rather than fixing + // the order. Now the blocks are reordered before that gate runs, so + // the pairing is recognized and everything survives. const { messages } = converter.convertGeminiRequestToAnthropic({ model: 'models/test', contents: [ @@ -1294,10 +1302,15 @@ describe('AnthropicContentConverter', () => { }); expect(messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'tool', input: {} }], + }, { role: 'user', content: [ - { type: 'text', text: 'Hi' }, + { type: 'tool_result', tool_use_id: 't1', content: 'late' }, { type: 'text', text: 'preface', @@ -1308,6 +1321,53 @@ describe('AnthropicContentConverter', () => { ]); }); + it('preserves relative order among multiple tool_result blocks when reordering ahead of text', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { functionCall: { id: 't1', name: 'tool', args: {} } }, + { functionCall: { id: 't2', name: 'tool', args: {} } }, + ], + }, + { + role: 'user', + parts: [ + { text: 'preface' }, + { + functionResponse: { + id: 't1', + name: 'tool', + response: { output: 'first' }, + }, + }, + { + functionResponse: { + id: 't2', + name: 'tool', + response: { output: 'second' }, + }, + }, + ], + }, + ], + }); + + const lastMsg = messages[messages.length - 1]; + expect(lastMsg.content).toEqual([ + { type: 'tool_result', tool_use_id: 't1', content: 'first' }, + { type: 'tool_result', tool_use_id: 't2', content: 'second' }, + { + type: 'text', + text: 'preface', + cache_control: { type: 'ephemeral' }, + }, + ]); + }); + it('deduplicates tool_use blocks by id during merge', () => { const { messages } = converter.convertGeminiRequestToAnthropic({ model: 'models/test', diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 5454c86c324..18d0afef12c 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -471,6 +471,24 @@ export class AnthropicContentConverter { } if (contentBlocks.length > 0) { + // Anthropic requires tool_result to be the first content in a user + // message replying to a tool_use -- it doesn't scan past a leading + // non-tool_result block to find the result later in the same + // message. The source Gemini parts can arrive in any order (e.g. a + // text part preceding the functionResponse part within the same + // Content), so move tool_result blocks to the front of a user + // message whenever any are present. A stable sort preserves the + // relative order of multiple tool_result blocks against each other. + if ( + role === 'user' && + contentBlocks.some((b) => b.type === 'tool_result') + ) { + contentBlocks.sort((a, b) => { + if (a.type === 'tool_result' && b.type !== 'tool_result') return -1; + if (a.type !== 'tool_result' && b.type === 'tool_result') return 1; + return 0; + }); + } messages.push({ role, content: contentBlocks }); } }