From deb7789345504dbf2e27b7f4335d50354fbe81e1 Mon Sep 17 00:00:00 2001 From: Selene Date: Fri, 14 Aug 2026 11:18:13 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): preserve tool call extras in tool.call loop events --- .changeset/fix-gemini-thought-signature.md | 5 +++ .../src/agent/loop/loopService.ts | 2 + .../test/agent/loop/loop.test.ts | 43 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 .changeset/fix-gemini-thought-signature.md diff --git a/.changeset/fix-gemini-thought-signature.md b/.changeset/fix-gemini-thought-signature.md new file mode 100644 index 00000000000..42016ae266b --- /dev/null +++ b/.changeset/fix-gemini-thought-signature.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Gemini tool calls failing with a missing thought_signature error on the follow-up request. diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index ea9ca88e0f8..088302b1d68 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -947,6 +947,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { onToolCall: ({ toolCallId, name, args }) => { const callUuid = randomUUID(); toolCallUuids.set(toolCallId, callUuid); + const extras = response.message.toolCalls.find((t) => t.id === toolCallId)?.extras; this.context.appendLoopEvent({ type: 'tool.call', uuid: callUuid, @@ -956,6 +957,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { toolCallId, name, args, + extras, }); }, })) { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index ae9390b389b..9b2e3ce2860 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -404,6 +404,49 @@ describe('Agent loop', () => { `); }); + it('preserves tool call extras (Gemini thought_signature) through to context', async () => { + // Regression: Gemini 3 requires the thought_signature returned on a + // functionCall to be echoed back when the call is re-sent in the next + // step. The signature travels as ToolCall.extras.thought_signature_b64; it + // must survive loopService's tool.call event recording so the fold + // rebuilds the assistant message with extras for the provider to echo. + const sigCall: ToolCall = { + type: 'function', + id: 'call_sig', + name: 'Lookup', + arguments: '{"query":"moon"}', + extras: { thought_signature_b64: 'c2lnbmF0dXJl' }, + }; + const lookupTool: ExecutableTool<{ query: string }> = { + name: 'Lookup', + description: 'Look up a short test value.', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + required: ['query'], + additionalProperties: false, + }, + resolveExecution: () => ({ + approvalRule: 'Lookup', + execute: async () => ({ output: 'lookup-result' }), + }), + }; + + profile.update({ activeToolNames: ['Lookup'] }); + ctx.get(IAgentToolRegistryService).register(lookupTool); + + ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, sigCall); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] }); + ctx.mockNextResponse({ type: 'text', text: 'The lookup result is lookup-result.' }); + await ctx.untilApproval(true); + await ctx.untilTurnEnd(); + + const assistant = ctx.contextData().history.find((m) => m.role === 'assistant'); + expect(assistant?.toolCalls[0]?.extras).toEqual({ thought_signature_b64: 'c2lnbmF0dXJl' }); + }); + it('lets non-external stop hooks continue a turn more than once', async () => { profile.update({ activeToolNames: [] }); let continuations = 0; From 6bd9d38fc70ed4e16636061ec5bfef87cc78c0ee Mon Sep 17 00:00:00 2001 From: Selene Date: Fri, 14 Aug 2026 11:50:58 +0800 Subject: [PATCH 2/2] fix(google-genai): keep trailing user text before function results when merging --- .changeset/fix-gemini-thought-signature.md | 3 +- .../bases/google-genai/google-genai.ts | 12 ++++++- .../test/agent/loop/loop.test.ts | 5 --- packages/kosong/src/providers/google-genai.ts | 12 ++++++- packages/kosong/test/google-genai.test.ts | 34 +++++++++++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/.changeset/fix-gemini-thought-signature.md b/.changeset/fix-gemini-thought-signature.md index 42016ae266b..17966758829 100644 --- a/.changeset/fix-gemini-thought-signature.md +++ b/.changeset/fix-gemini-thought-signature.md @@ -1,5 +1,6 @@ --- "@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch --- -Fix Gemini tool calls failing with a missing thought_signature error on the follow-up request. +Fix Gemini tool-calling sessions failing on follow-up requests: preserve the tool-call thought signature and keep trailing user text before function results. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts index f0100faff09..129052851ce 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts @@ -445,7 +445,17 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten isToolResultOnly: (content) => content.parts.length > 0 && content.parts.every((part) => part.functionResponse !== undefined), - merge: (last, next) => ({ ...last, parts: [...last.parts, ...next.parts] }), + merge: (last, next) => { + const lastStartsWithFunctionResponse = + last.parts[0]?.functionResponse !== undefined; + const nextHasFunctionResponse = next.parts.some( + (part) => part.functionResponse !== undefined, + ); + if (lastStartsWithFunctionResponse && !nextHasFunctionResponse) { + return { ...next, parts: [...next.parts, ...last.parts] }; + } + return { ...last, parts: [...last.parts, ...next.parts] }; + }, }); } diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 9b2e3ce2860..ddaa53c273b 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -405,11 +405,6 @@ describe('Agent loop', () => { }); it('preserves tool call extras (Gemini thought_signature) through to context', async () => { - // Regression: Gemini 3 requires the thought_signature returned on a - // functionCall to be echoed back when the call is re-sent in the next - // step. The signature travels as ToolCall.extras.thought_signature_b64; it - // must survive loopService's tool.call event recording so the fold - // rebuilds the assistant message with extras for the provider to echo. const sigCall: ToolCall = { type: 'function', id: 'call_sig', diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 88a26e6e0ea..5015e0a4b6e 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -498,7 +498,17 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten isToolResultOnly: (content) => content.parts.length > 0 && content.parts.every((part) => part.functionResponse !== undefined), - merge: (last, next) => ({ ...last, parts: [...last.parts, ...next.parts] }), + merge: (last, next) => { + const lastStartsWithFunctionResponse = + last.parts[0]?.functionResponse !== undefined; + const nextHasFunctionResponse = next.parts.some( + (part) => part.functionResponse !== undefined, + ); + if (lastStartsWithFunctionResponse && !nextHasFunctionResponse) { + return { ...next, parts: [...next.parts, ...last.parts] }; + } + return { ...last, parts: [...last.parts, ...next.parts] }; + }, }); } export class GoogleGenAIStreamedMessage implements StreamedMessage { diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index e29614b08a8..948c43cb330 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -337,6 +337,40 @@ describe('GoogleGenAIChatProvider', () => { const last = contents.at(-1)!; expect(last.parts.some((p) => p.functionResponse !== undefined)).toBe(true); expect(last.parts.some((p) => p.text === 'Now multiply')).toBe(true); + // Gemini rejects a trailing Content whose parts start with + // functionResponse followed by text ("Requests ending with a model turn + // are not supported"); the merged Content must keep the user text first. + expect(last.parts[0]).toEqual({ text: 'Now multiply' }); + }); + + it('keeps trailing user text before a multimodal tool-result Content when merging', () => { + // A tool result carrying media yields [functionResponse, inlineData]; + // the reorder must still put a following user text part first. + const toolCall: ToolCall = { + type: 'function', + id: 'call_img', + name: 'inspect', + arguments: '{}', + }; + const contents = messagesToGoogleGenAIContents([ + { role: 'user', content: [{ type: 'text', text: 'Inspect it' }], toolCalls: [] }, + { role: 'assistant', content: [], toolCalls: [toolCall] }, + { + role: 'tool', + content: [ + { type: 'text', text: 'here is the image' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ], + toolCallId: 'call_img', + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Now explain it' }], toolCalls: [] }, + ]); + + expect(contents.map((c) => c.role)).toEqual(['user', 'model', 'user']); + const last = contents.at(-1)!; + expect(last.parts[0]).toEqual({ text: 'Now explain it' }); + expect(last.parts.some((p) => p.functionResponse !== undefined)).toBe(true); }); it('multi-turn conversation with system prompt sets systemInstruction', async () => {