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-calling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix tool calling with Google Gemini models, including Gemini 3 thinking-signature round-trips across turns.
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,7 @@ export class ContextMemory {
id: event.toolCallId,
name: event.name,
arguments: event.args === undefined ? null : JSON.stringify(event.args),
extras: event.extras,
});
this.pendingToolResultIds.add(event.toolCallId);
return;
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/loop/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface LoopToolCallEvent {
readonly args: unknown;
readonly description?: string | undefined;
readonly display?: ToolInputDisplay | undefined;
readonly extras?: Record<string, unknown> | undefined;
}

export interface LoopToolResultEvent {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,5 +713,6 @@ async function dispatchToolCall(
args,
description: displayFields?.description,
display: displayFields?.display,
extras: toolCall.extras,
});
}
49 changes: 49 additions & 0 deletions packages/agent-core/test/agent/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,55 @@ describe('Agent context', () => {
expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false);
});

it('preserves tool call extras (Gemini thought_signature) through to projection', () => {
// Regression: Gemini 3 requires the thought_signature returned on a
// functionCall to be echoed back when the call is re-sent in the next turn.
// The signature travels as ToolCall.extras.thought_signature_b64; it must
// survive the loop tool.call event -> context recording -> projection so
// the provider can put it back on the outbound functionCall part.
const ctx = testAgent();
ctx.configure();

ctx.agent.context.appendUserMessage([{ type: 'text', text: 'run' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'sig-step', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'sig-tool',
turnId: '',
step: 1,
stepUuid: 'sig-step',
toolCallId: 'call_sig',
name: 'Bash',
args: { command: 'echo hi' },
extras: { thought_signature_b64: 'c2lnbmF0dXJl' },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.end', uuid: 'sig-step', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'sig-tool',
toolCallId: 'call_sig',
result: { output: 'hi' },
},
});

const expectedExtras = { thought_signature_b64: 'c2lnbmF0dXJl' };
const recorded = ctx.agent.context.history.find((m) => m.role === 'assistant');
expect(recorded?.toolCalls[0]?.extras).toEqual(expectedExtras);
const projected = ctx.agent.context.messages.find((m) => m.role === 'assistant');
expect(projected?.toolCalls[0]?.extras).toEqual(expectedExtras);
});

it('reroutes an inline image-compression caption into a hidden system reminder', () => {
const ctx = testAgent();
ctx.configure();
Expand Down
96 changes: 48 additions & 48 deletions packages/kosong/src/providers/google-genai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,36 +92,36 @@ export interface GoogleGenAIOptions {
}

export interface GoogleGenAIGenerationKwargs {
max_output_tokens?: number | undefined;
maxOutputTokens?: number | undefined;
temperature?: number | undefined;
top_k?: number | undefined;
top_p?: number | undefined;
thinking_config?: ThinkingConfig | undefined;
topK?: number | undefined;
topP?: number | undefined;
thinkingConfig?: ThinkingConfig | undefined;
[key: string]: unknown;
}

interface ThinkingConfig {
include_thoughts?: boolean;
thinking_budget?: number;
thinking_level?: string;
includeThoughts?: boolean;
thinkingBudget?: number;
thinkingLevel?: string;
}
interface GoogleFunctionDeclaration {
name: string;
description: string;
parameters_json_schema: Record<string, unknown>;
parametersJsonSchema: Record<string, unknown>;
}

interface GoogleTool {
function_declarations: GoogleFunctionDeclaration[];
functionDeclarations: GoogleFunctionDeclaration[];
}

function toolToGoogleGenAI(tool: Tool): GoogleTool {
return {
function_declarations: [
functionDeclarations: [
{
name: tool.name,
description: tool.description,
parameters_json_schema: tool.parameters,
parametersJsonSchema: tool.parameters,
},
],
};
Expand All @@ -133,13 +133,13 @@ interface GoogleContent {

interface GooglePart {
text?: string;
function_call?: { name: string; args: Record<string, unknown> };
function_response?: {
functionCall?: { name: string; args: Record<string, unknown> };
functionResponse?: {
name: string;
response: Record<string, string>;
parts: unknown[];
};
thought_signature?: string;
thoughtSignature?: string;
[key: string]: unknown;
}

Expand Down Expand Up @@ -274,15 +274,15 @@ function messageToGoogleGenAI(message: Message): GoogleContent {
}

const functionCallPart: GooglePart = {
function_call: {
functionCall: {
name: toolCall.name,
args,
},
};

// Restore thought_signature if available
// Restore thoughtSignature if available
if (toolCall.extras && 'thought_signature_b64' in toolCall.extras) {
functionCallPart['thought_signature'] = toolCall.extras['thought_signature_b64'] as string;
functionCallPart['thoughtSignature'] = toolCall.extras['thought_signature_b64'] as string;
}

parts.push(functionCallPart);
Expand Down Expand Up @@ -335,7 +335,7 @@ function toolMessageToFunctionResponseParts(
}

const functionResponsePart: GooglePart = {
function_response: {
functionResponse: {
name: toolCallIdToName(message.toolCallId, toolNameById),
response: { output: textOutput },
parts: [],
Expand All @@ -361,7 +361,7 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten
// the content by wrapping it in a `<system>` tag and attaching it as
// a user turn — mirrors the Anthropic provider's behavior. The
// dedicated top-level `systemPrompt` still flows into
// `system_instruction` separately; only historical system messages
// `systemInstruction` separately; only historical system messages
// come through here.
const text = message.content
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
Expand Down Expand Up @@ -463,7 +463,7 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten
isUser: (content) => content.role === 'user',
isToolResultOnly: (content) =>
content.parts.length > 0 &&
content.parts.every((part) => part.function_response !== undefined),
content.parts.every((part) => part.functionResponse !== undefined),
merge: (last, next) => ({ ...last, parts: [...last.parts, ...next.parts] }),
});
}
Expand Down Expand Up @@ -746,16 +746,16 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}

get thinkingEffort(): ThinkingEffort | null {
const thinkingConfig = this._generationKwargs.thinking_config;
const thinkingConfig = this._generationKwargs.thinkingConfig;
if (thinkingConfig === undefined) return null;

// For gemini-3 models that use thinking_level
if (thinkingConfig.thinking_level !== undefined) {
switch (thinkingConfig.thinking_level) {
// For gemini-3 models that use thinkingLevel
if (thinkingConfig.thinkingLevel !== undefined) {
switch (thinkingConfig.thinkingLevel) {
case 'MINIMAL':
// MINIMAL + suppressed thoughts is how 'off' is encoded for Gemini 3,
// which has no true "disabled" level.
return thinkingConfig.include_thoughts === false ? 'off' : 'low';
return thinkingConfig.includeThoughts === false ? 'off' : 'low';
case 'LOW':
return 'low';
case 'MEDIUM':
Expand All @@ -767,11 +767,11 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}
}

// For other models that use thinking_budget
if (thinkingConfig.thinking_budget !== undefined) {
if (thinkingConfig.thinking_budget === 0) return 'off';
if (thinkingConfig.thinking_budget <= 1024) return 'low';
if (thinkingConfig.thinking_budget <= 4096) return 'medium';
// For other models that use thinkingBudget
if (thinkingConfig.thinkingBudget !== undefined) {
if (thinkingConfig.thinkingBudget === 0) return 'off';
if (thinkingConfig.thinkingBudget <= 1024) return 'low';
if (thinkingConfig.thinkingBudget <= 4096) return 'medium';
return 'high';
}

Expand Down Expand Up @@ -801,7 +801,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {

const config: Record<string, unknown> = {
...this._generationKwargs,
system_instruction: systemPrompt,
systemInstruction: systemPrompt,
...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}),
};

Expand Down Expand Up @@ -866,53 +866,53 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}

withThinking(effort: ThinkingEffort): GoogleGenAIChatProvider {
const thinkingConfig: ThinkingConfig = { include_thoughts: true };
const thinkingConfig: ThinkingConfig = { includeThoughts: true };

if (this._model.includes('gemini-3')) {
// Gemini 3 models use thinking_level (MINIMAL/LOW/MEDIUM/HIGH). The SDK
// Gemini 3 models use thinkingLevel (MINIMAL/LOW/MEDIUM/HIGH). The SDK
// does not expose a "disabled" level, so 'off' maps to MINIMAL with
// thought output suppressed — the lowest thinking intensity available.
switch (effort) {
case 'off':
thinkingConfig.thinking_level = 'MINIMAL';
thinkingConfig.include_thoughts = false;
thinkingConfig.thinkingLevel = 'MINIMAL';
thinkingConfig.includeThoughts = false;
break;
case 'low':
thinkingConfig.thinking_level = 'LOW';
thinkingConfig.thinkingLevel = 'LOW';
break;
case 'medium':
thinkingConfig.thinking_level = 'MEDIUM';
thinkingConfig.thinkingLevel = 'MEDIUM';
break;
case 'high':
case 'xhigh':
case 'max':
thinkingConfig.thinking_level = 'HIGH';
thinkingConfig.thinkingLevel = 'HIGH';
break;
}
} else {
switch (effort) {
case 'off':
thinkingConfig.thinking_budget = 0;
thinkingConfig.include_thoughts = false;
thinkingConfig.thinkingBudget = 0;
thinkingConfig.includeThoughts = false;
break;
case 'low':
thinkingConfig.thinking_budget = 1024;
thinkingConfig.include_thoughts = true;
thinkingConfig.thinkingBudget = 1024;
thinkingConfig.includeThoughts = true;
break;
case 'medium':
thinkingConfig.thinking_budget = 4096;
thinkingConfig.include_thoughts = true;
thinkingConfig.thinkingBudget = 4096;
thinkingConfig.includeThoughts = true;
break;
case 'high':
case 'xhigh':
case 'max':
thinkingConfig.thinking_budget = 32_000;
thinkingConfig.include_thoughts = true;
thinkingConfig.thinkingBudget = 32_000;
thinkingConfig.includeThoughts = true;
break;
}
}

return this.withGenerationKwargs({ thinking_config: thinkingConfig });
return this.withGenerationKwargs({ thinkingConfig });
}

withGenerationKwargs(kwargs: GoogleGenAIGenerationKwargs): GoogleGenAIChatProvider {
Expand All @@ -922,7 +922,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
}

withMaxCompletionTokens(maxCompletionTokens: number): GoogleGenAIChatProvider {
return this.withGenerationKwargs({ max_output_tokens: maxCompletionTokens });
return this.withGenerationKwargs({ maxOutputTokens: maxCompletionTokens });
}

private _clone(): GoogleGenAIChatProvider {
Expand Down
35 changes: 33 additions & 2 deletions packages/kosong/test/e2e/google-genai-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,42 @@ describe('e2e: Google GenAI adapter bridge', () => {
{ role: 'user', parts: [{ text: 'Add and multiply these numbers.' }] },
{
role: 'model',
parts: [{ text: 'I will calculate both.' }, {}, {}],
parts: [
{ text: 'I will calculate both.' },
{ functionCall: { name: 'add', args: { a: 2, b: 3 } } },
{ functionCall: { name: 'multiply', args: { a: 4, b: 5 } } },
],
},
{
role: 'user',
parts: [{}, {}],
parts: [
{ functionResponse: { name: 'add', response: { output: '5' }, parts: [] } },
{
functionResponse: { name: 'multiply', response: { output: '20' }, parts: [] },
},
],
},
]);
// Regression: the snake_case `system_instruction` / tool declarations used
// to be silently dropped by the @google/genai SDK, so the model saw neither
// a system prompt nor any tools. Both must now reach the wire as camelCase.
expect(body['systemInstruction']).toEqual({
parts: [{ text: 'You are a calculator.' }],
role: 'user',
});
expect(body['tools']).toEqual([
{
functionDeclarations: [
expect.objectContaining({ name: 'add', parametersJsonSchema: expect.any(Object) }),
],
},
{
functionDeclarations: [
expect.objectContaining({
name: 'multiply',
parametersJsonSchema: expect.any(Object),
}),
],
},
]);

Expand Down
Loading
Loading