Skip to content

Commit 72f425e

Browse files
wbxl2000sailist
andauthored
fix(agent-core-v2): make Gemini tool call ids unique across turns (#1730)
* fix(agent-core-v2): make Gemini tool call ids unique across turns * fix(kosong): recover tool names from entropy-suffixed Gemini call ids The orphan tool-message fallback in toolCallIdToName stripped only one trailing "_<suffix>" segment, so with the new "{tool_name}_{upstream_id}_{entropy}" id shape it returned "AgentSwarm_0" instead of "AgentSwarm" once the preceding assistant call had been compacted away. Strip the fixed 8-hex entropy suffix first, then the upstream id segment, in both the kosong and agent-core-v2 providers. Also drop the inline implementation comment that agent-core-v2 forbids outside the file header. --------- Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai>
1 parent 1824e73 commit 72f425e

5 files changed

Lines changed: 59 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Fix tool call id collisions across turns for Gemini-protocol models, which merged separate swarm runs into a single card in the web UI.

packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ interface GooglePart {
141141
function toolCallIdToName(toolCallId: string, toolNameById: Map<string, string>): string {
142142
const name = toolNameById.get(toolCallId);
143143
if (name !== undefined) return name;
144-
const match = /^(.+)_[^_]+$/.exec(toolCallId);
145-
return match?.[1] ?? toolCallId;
144+
const withoutEntropy = toolCallId.replace(/_[0-9a-f]{8}$/, '');
145+
const match = /^(.+)_[^_]+$/.exec(withoutEntropy);
146+
return match?.[1] ?? withoutEntropy;
146147
}
147148

148149
function convertMediaUrl(
@@ -510,7 +511,7 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage {
510511
const name = fc['name'] as string;
511512
if (!name) continue;
512513
const id_ = (fc['id'] as string) ?? crypto.randomUUID();
513-
const toolCallId = `${name}_${id_}`;
514+
const toolCallId = `${name}_${id_}_${crypto.randomUUID().replaceAll('-', '').slice(0, 8)}`;
514515
const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature'];
515516
const toolCall: ToolCall = {
516517
type: 'function',

packages/kosong/src/providers/google-genai.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,18 @@ function toolCallIdToName(toolCallId: string, toolNameById: Map<string, string>)
163163
const name = toolNameById.get(toolCallId);
164164
if (name !== undefined) return name;
165165
// Fallback: ids produced by this provider follow the format
166-
// "{tool_name}_{id_suffix}" where `tool_name` may itself contain
167-
// underscores (e.g. `fetch_image`) and `id_suffix` is a single trailing
168-
// token without underscores (e.g. a random hex / UUID fragment). We strip
169-
// the last "_<suffix>" segment by matching it explicitly — splitting on
170-
// the first underscore would truncate multi-word tool names like
171-
// `fetch_image_<id>` to just `fetch`.
172-
const match = /^(.+)_[^_]+$/.exec(toolCallId);
173-
return match?.[1] ?? toolCallId;
166+
// "{tool_name}_{upstream_id}_{entropy}" where `tool_name` may itself
167+
// contain underscores (e.g. `fetch_image`) and `entropy` is the fixed
168+
// 8-hex-char suffix this provider appends for cross-turn uniqueness. Strip
169+
// the entropy suffix first, then the trailing "_<upstream_id>" segment by
170+
// matching it explicitly — splitting on the first underscore would truncate
171+
// multi-word tool names like `fetch_image_<id>` to just `fetch`. (Pre-entropy
172+
// ids of the form "{tool_name}_{id_suffix}" still parse: a trailing 8-hex
173+
// segment is indistinguishable from the entropy suffix, and stripping it
174+
// recovers the same name the old single-suffix shape did.)
175+
const withoutEntropy = toolCallId.replace(/_[0-9a-f]{8}$/, '');
176+
const match = /^(.+)_[^_]+$/.exec(withoutEntropy);
177+
return match?.[1] ?? withoutEntropy;
174178
}
175179

176180
/**
@@ -588,8 +592,14 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage {
588592
const fc = (p['functionCall'] ?? p['function_call']) as Record<string, unknown>;
589593
const name = fc['name'] as string;
590594
if (!name) continue;
595+
// The upstream function-call id is only unique within its own
596+
// response (some backends re-issue small ids like "0" every turn),
597+
// so `${name}_${id}` collided across turns — two AgentSwarm calls in
598+
// different turns both became `AgentSwarm_0` and the web client
599+
// merged their member lists into one card. Append entropy so ids
600+
// stay unique across the whole session.
591601
const id_ = (fc['id'] as string) ?? crypto.randomUUID();
592-
const toolCallId = `${name}_${id_}`;
602+
const toolCallId = `${name}_${id_}_${crypto.randomUUID().replaceAll('-', '').slice(0, 8)}`;
593603
const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature'];
594604
const toolCall: ToolCall = {
595605
type: 'function',

packages/kosong/test/e2e/google-genai-adapter.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ describe('e2e: Google GenAI adapter bridge', () => {
210210
{ type: 'text', text: 'Done.' },
211211
{
212212
type: 'function',
213-
id: 'notify_call-1',
213+
id: expect.stringMatching(/^notify_call-1_[0-9a-f]{8}$/),
214214
name: 'notify', arguments: '{"ok":true}',
215215
extras: { thought_signature_b64: 'sig-1' },
216216
} satisfies ToolCall,

packages/kosong/test/google-genai.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -753,10 +753,10 @@ describe('GoogleGenAIChatProvider', () => {
753753
// When a tool message arrives without a preceding assistant message
754754
// carrying the tool_call (e.g. after history compaction), the provider
755755
// falls back to parsing the name out of the tool_call_id. Google IDs
756-
// produced by this provider have the shape "{tool_name}_{id_suffix}"
757-
// where the suffix is a single non-underscored token, so stripping the
758-
// first underscore truncates multi-word tool names such as
759-
// `fetch_image_<id>` down to `fetch`.
756+
// produced by this provider have the shape "{tool_name}_{upstream_id}_{entropy}"
757+
// where `entropy` is a fixed 8-hex-char suffix and the upstream id is a
758+
// non-underscored token, so stripping the first underscore would truncate
759+
// multi-word tool names such as `fetch_image_<id>` down to `fetch`.
760760
function firstFunctionResponseName(history: Message[]): string | undefined {
761761
const contents = messagesToGoogleGenAIContents(history);
762762
for (const content of contents) {
@@ -814,6 +814,30 @@ describe('GoogleGenAIChatProvider', () => {
814814
];
815815
expect(firstFunctionResponseName(history)).toBe('bareid');
816816
});
817+
818+
it('strips both the entropy suffix and the upstream id', () => {
819+
const history: Message[] = [
820+
{
821+
role: 'tool',
822+
content: [{ type: 'text', text: 'ok' }],
823+
toolCallId: 'AgentSwarm_0_ab12cd34',
824+
toolCalls: [],
825+
},
826+
];
827+
expect(firstFunctionResponseName(history)).toBe('AgentSwarm');
828+
});
829+
830+
it('strips entropy and upstream id from multi-word tool names', () => {
831+
const history: Message[] = [
832+
{
833+
role: 'tool',
834+
content: [{ type: 'text', text: 'ok' }],
835+
toolCallId: 'fetch_image_abc123_a1b2c3d4',
836+
toolCalls: [],
837+
},
838+
];
839+
expect(firstFunctionResponseName(history)).toBe('fetch_image');
840+
});
817841
});
818842

819843
describe('no id in functionCall or functionResponse', () => {
@@ -1224,7 +1248,7 @@ describe('GoogleGenAIChatProvider', () => {
12241248
expect(parts).toEqual([
12251249
{
12261250
type: 'function',
1227-
id: 'add_call_1',
1251+
id: expect.stringMatching(/^add_call_1_[0-9a-f]{8}$/),
12281252
name: 'add', arguments: '{"a":2,"b":3}',
12291253
},
12301254
]);
@@ -1254,7 +1278,7 @@ describe('GoogleGenAIChatProvider', () => {
12541278
expect(parts).toEqual([
12551279
{
12561280
type: 'function',
1257-
id: 'search_fc_1',
1281+
id: expect.stringMatching(/^search_fc_1_[0-9a-f]{8}$/),
12581282
name: 'search', arguments: '{"q":"test"}',
12591283
extras: { thought_signature_b64: 'sig_abc123' },
12601284
},

0 commit comments

Comments
 (0)