diff --git a/.changeset/fix-gemini-thought-signature.md b/.changeset/fix-gemini-thought-signature.md new file mode 100644 index 0000000000..1796675882 --- /dev/null +++ b/.changeset/fix-gemini-thought-signature.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +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/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index ea9ca88e0f..088302b1d6 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/src/kosong/provider/bases/google-genai/google-genai.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts index f0100faff0..129052851c 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 ae9390b389..ddaa53c273 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,44 @@ describe('Agent loop', () => { `); }); + it('preserves tool call extras (Gemini thought_signature) through to context', async () => { + 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; diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 88a26e6e0e..5015e0a4b6 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 e29614b08a..948c43cb33 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 () => {