diff --git a/.changeset/fix-gemini-tool-call-id-collision.md b/.changeset/fix-gemini-tool-call-id-collision.md new file mode 100644 index 0000000000..dee0e3b027 --- /dev/null +++ b/.changeset/fix-gemini-tool-call-id-collision.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix tool call id collisions across turns for Gemini-protocol models, which merged separate swarm runs into a single card in the web UI. diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts index 893bf9d93c..c7b998aed8 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts @@ -141,8 +141,9 @@ interface GooglePart { function toolCallIdToName(toolCallId: string, toolNameById: Map): string { const name = toolNameById.get(toolCallId); if (name !== undefined) return name; - const match = /^(.+)_[^_]+$/.exec(toolCallId); - return match?.[1] ?? toolCallId; + const withoutEntropy = toolCallId.replace(/_[0-9a-f]{8}$/, ''); + const match = /^(.+)_[^_]+$/.exec(withoutEntropy); + return match?.[1] ?? withoutEntropy; } function convertMediaUrl( @@ -510,7 +511,7 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { const name = fc['name'] as string; if (!name) continue; const id_ = (fc['id'] as string) ?? crypto.randomUUID(); - const toolCallId = `${name}_${id_}`; + const toolCallId = `${name}_${id_}_${crypto.randomUUID().replaceAll('-', '').slice(0, 8)}`; const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature']; const toolCall: ToolCall = { type: 'function', diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index ad69fc0c59..88a26e6e0e 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -163,14 +163,18 @@ function toolCallIdToName(toolCallId: string, toolNameById: Map) const name = toolNameById.get(toolCallId); if (name !== undefined) return name; // Fallback: ids produced by this provider follow the format - // "{tool_name}_{id_suffix}" where `tool_name` may itself contain - // underscores (e.g. `fetch_image`) and `id_suffix` is a single trailing - // token without underscores (e.g. a random hex / UUID fragment). We strip - // the last "_" segment by matching it explicitly — splitting on - // the first underscore would truncate multi-word tool names like - // `fetch_image_` to just `fetch`. - const match = /^(.+)_[^_]+$/.exec(toolCallId); - return match?.[1] ?? toolCallId; + // "{tool_name}_{upstream_id}_{entropy}" where `tool_name` may itself + // contain underscores (e.g. `fetch_image`) and `entropy` is the fixed + // 8-hex-char suffix this provider appends for cross-turn uniqueness. Strip + // the entropy suffix first, then the trailing "_" segment by + // matching it explicitly — splitting on the first underscore would truncate + // multi-word tool names like `fetch_image_` to just `fetch`. (Pre-entropy + // ids of the form "{tool_name}_{id_suffix}" still parse: a trailing 8-hex + // segment is indistinguishable from the entropy suffix, and stripping it + // recovers the same name the old single-suffix shape did.) + const withoutEntropy = toolCallId.replace(/_[0-9a-f]{8}$/, ''); + const match = /^(.+)_[^_]+$/.exec(withoutEntropy); + return match?.[1] ?? withoutEntropy; } /** @@ -588,8 +592,14 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { const fc = (p['functionCall'] ?? p['function_call']) as Record; const name = fc['name'] as string; if (!name) continue; + // The upstream function-call id is only unique within its own + // response (some backends re-issue small ids like "0" every turn), + // so `${name}_${id}` collided across turns — two AgentSwarm calls in + // different turns both became `AgentSwarm_0` and the web client + // merged their member lists into one card. Append entropy so ids + // stay unique across the whole session. const id_ = (fc['id'] as string) ?? crypto.randomUUID(); - const toolCallId = `${name}_${id_}`; + const toolCallId = `${name}_${id_}_${crypto.randomUUID().replaceAll('-', '').slice(0, 8)}`; const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature']; const toolCall: ToolCall = { type: 'function', diff --git a/packages/kosong/test/e2e/google-genai-adapter.test.ts b/packages/kosong/test/e2e/google-genai-adapter.test.ts index 12210eff20..a9b925f240 100644 --- a/packages/kosong/test/e2e/google-genai-adapter.test.ts +++ b/packages/kosong/test/e2e/google-genai-adapter.test.ts @@ -210,7 +210,7 @@ describe('e2e: Google GenAI adapter bridge', () => { { type: 'text', text: 'Done.' }, { type: 'function', - id: 'notify_call-1', + id: expect.stringMatching(/^notify_call-1_[0-9a-f]{8}$/), name: 'notify', arguments: '{"ok":true}', extras: { thought_signature_b64: 'sig-1' }, } satisfies ToolCall, diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index b8a2468dd9..e29614b08a 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -753,10 +753,10 @@ describe('GoogleGenAIChatProvider', () => { // When a tool message arrives without a preceding assistant message // carrying the tool_call (e.g. after history compaction), the provider // falls back to parsing the name out of the tool_call_id. Google IDs - // produced by this provider have the shape "{tool_name}_{id_suffix}" - // where the suffix is a single non-underscored token, so stripping the - // first underscore truncates multi-word tool names such as - // `fetch_image_` down to `fetch`. + // produced by this provider have the shape "{tool_name}_{upstream_id}_{entropy}" + // where `entropy` is a fixed 8-hex-char suffix and the upstream id is a + // non-underscored token, so stripping the first underscore would truncate + // multi-word tool names such as `fetch_image_` down to `fetch`. function firstFunctionResponseName(history: Message[]): string | undefined { const contents = messagesToGoogleGenAIContents(history); for (const content of contents) { @@ -814,6 +814,30 @@ describe('GoogleGenAIChatProvider', () => { ]; expect(firstFunctionResponseName(history)).toBe('bareid'); }); + + it('strips both the entropy suffix and the upstream id', () => { + const history: Message[] = [ + { + role: 'tool', + content: [{ type: 'text', text: 'ok' }], + toolCallId: 'AgentSwarm_0_ab12cd34', + toolCalls: [], + }, + ]; + expect(firstFunctionResponseName(history)).toBe('AgentSwarm'); + }); + + it('strips entropy and upstream id from multi-word tool names', () => { + const history: Message[] = [ + { + role: 'tool', + content: [{ type: 'text', text: 'ok' }], + toolCallId: 'fetch_image_abc123_a1b2c3d4', + toolCalls: [], + }, + ]; + expect(firstFunctionResponseName(history)).toBe('fetch_image'); + }); }); describe('no id in functionCall or functionResponse', () => { @@ -1224,7 +1248,7 @@ describe('GoogleGenAIChatProvider', () => { expect(parts).toEqual([ { type: 'function', - id: 'add_call_1', + id: expect.stringMatching(/^add_call_1_[0-9a-f]{8}$/), name: 'add', arguments: '{"a":2,"b":3}', }, ]); @@ -1254,7 +1278,7 @@ describe('GoogleGenAIChatProvider', () => { expect(parts).toEqual([ { type: 'function', - id: 'search_fc_1', + id: expect.stringMatching(/^search_fc_1_[0-9a-f]{8}$/), name: 'search', arguments: '{"q":"test"}', extras: { thought_signature_b64: 'sig_abc123' }, },