Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-gemini-tool-call-id-collision.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ interface GooglePart {
function toolCallIdToName(toolCallId: string, toolNameById: Map<string, string>): 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(
Expand Down Expand Up @@ -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)}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the fallback tool-name parser

When the preceding assistant tool call has been compacted away and a standalone tool result reaches messagesToGoogleGenAIContents, toolCallIdToName is the only thing that recovers the Gemini functionResponse.name. This new ${name}_${upstreamId}_${rand} shape is no longer parseable by that fallback because it strips only the final suffix, so an id like AgentSwarm_0_ab12cd34 is sent back as function name AgentSwarm_0 instead of AgentSwarm; please update the encoding/parser together with this id-shape change, including the mirrored kosong provider.

Useful? React with 👍 / 👎.

const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature'];
const toolCall: ToolCall = {
type: 'function',
Expand Down
28 changes: 19 additions & 9 deletions packages/kosong/src/providers/google-genai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,18 @@ function toolCallIdToName(toolCallId: string, toolNameById: Map<string, string>)
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 "_<suffix>" segment by matching it explicitly — splitting on
// the first underscore would truncate multi-word tool names like
// `fetch_image_<id>` 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 "_<upstream_id>" segment by
// matching it explicitly — splitting on the first underscore would truncate
// multi-word tool names like `fetch_image_<id>` 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;
}

/**
Expand Down Expand Up @@ -588,8 +592,14 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage {
const fc = (p['functionCall'] ?? p['function_call']) as Record<string, unknown>;
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',
Expand Down
2 changes: 1 addition & 1 deletion packages/kosong/test/e2e/google-genai-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 30 additions & 6 deletions packages/kosong/test/google-genai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id>` 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_<id>` down to `fetch`.
function firstFunctionResponseName(history: Message[]): string | undefined {
const contents = messagesToGoogleGenAIContents(history);
for (const content of contents) {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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}',
},
]);
Expand Down Expand Up @@ -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' },
},
Expand Down
Loading