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
6 changes: 6 additions & 0 deletions .changeset/fix-gemini-thought-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
Comment thread
SeleneXX marked this conversation as resolved.
"@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.
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -956,6 +957,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
toolCallId,
name,
args,
extras,
});
},
})) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] };
},
});
}

Expand Down
38 changes: 38 additions & 0 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion packages/kosong/src/providers/google-genai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions packages/kosong/test/google-genai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading