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/gemini-extra-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

fix: preserve extra_content for Gemini 3 thought_signature support
789 changes: 5 additions & 784 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
finishReason: string | null | undefined,
activeToolCallIds: Set<string>,
): Generator<
| { type: "tool_call_partial"; index: number; id?: string; name?: string; arguments?: string }
| {
type: "tool_call_partial"
index: number
id?: string
name?: string
arguments?: string
extra_content?: Record<string, unknown> // kilocode_change
}
| { type: "tool_call_end"; id: string }
> {
if (delta?.tool_calls) {
Expand All @@ -517,6 +524,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
// kilocode_change start: Preserve extra_content for Gemini 3 thought_signature support
extra_content: (toolCall as any).extra_content,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Unvalidated as any cast on untrusted API response data

(toolCall as any).extra_content extracts an arbitrary property from the API stream response without any validation. This value flows through the entire pipeline and is eventually sent back to the API in subsequent requests.

Consider adding minimal validation (e.g., checking it's a plain object) to prevent unexpected data types from propagating:

const rawExtra = (toolCall as Record<string, unknown>).extra_content
extra_content: rawExtra != null && typeof rawExtra === 'object' && !Array.isArray(rawExtra)
  ? rawExtra as Record<string, unknown>
  : undefined,

// kilocode_change end
}
}
}
Expand Down
32 changes: 23 additions & 9 deletions src/api/transform/openai-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,15 +502,29 @@ export function convertToOpenAiMessages(
}

// Process tool use messages
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
id: normalizeId(toolMessage.id),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}))
// kilocode_change start: Use type assertion to access extra_content which may be added for Gemini 3 support
let tool_calls: (OpenAI.Chat.ChatCompletionMessageToolCall & {
extra_content?: Record<string, unknown>
})[] = toolMessages.map((toolMessage) => {
const toolCall: OpenAI.Chat.ChatCompletionMessageToolCall & {
extra_content?: Record<string, unknown>
} = {
id: normalizeId(toolMessage.id),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}
// Preserve extra_content for Gemini 3 thought_signature support
const toolMessageWithExtra = toolMessage as any
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Unvalidated as any cast on toolMessage to access extra_content

Same pattern as in openai.ts:528. The toolMessage is typed as Anthropic.ToolUseBlockParam which doesn't include extra_content. Consider adding a type guard or extending the type with an intersection to avoid silently accepting any shape:

interface ToolUseBlockWithExtra extends Anthropic.ToolUseBlockParam {
  extra_content?: Record<string, unknown>
}
const toolMessageWithExtra = toolMessage as ToolUseBlockWithExtra

This would provide compile-time safety while still allowing the extra field.

if (toolMessageWithExtra.extra_content) {
toolCall.extra_content = toolMessageWithExtra.extra_content
}
return toolCall
})
// kilocode_change end

// Check if the message has reasoning_details (used by Gemini 3, xAI, etc.)
const messageWithDetails = anthropicMessage as any
Expand Down
21 changes: 21 additions & 0 deletions src/api/transform/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,26 @@ export interface ApiStreamToolCallChunk {
id: string
name: string
arguments: string
// kilocode_change start
/**
* Extra content from provider-specific extensions (e.g., Gemini 3 thought_signature).
* Must be preserved and sent back in subsequent requests for multi-turn conversations.
*/
extra_content?: Record<string, unknown>
// kilocode_change end
}

export interface ApiStreamToolCallStartChunk {
type: "tool_call_start"
id: string
name: string
// kilocode_change start
/**
* Extra content from provider-specific extensions (e.g., Gemini 3 thought_signature).
* Must be preserved and sent back in subsequent requests for multi-turn conversations.
*/
extra_content?: Record<string, unknown>
// kilocode_change end
}

export interface ApiStreamToolCallDeltaChunk {
Expand All @@ -123,6 +137,13 @@ export interface ApiStreamToolCallPartialChunk {
id?: string
name?: string
arguments?: string
// kilocode_change start
/**
* Extra content from provider-specific extensions (e.g., Gemini 3 thought_signature).
* Must be preserved and sent back in subsequent requests for multi-turn conversations.
*/
extra_content?: Record<string, unknown>
// kilocode_change end
}

export interface GroundingSource {
Expand Down
39 changes: 36 additions & 3 deletions src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class NativeToolCallParser {
id: string
name: string
argumentsAccumulator: string
extra_content?: Record<string, unknown> // kilocode_change
}
>()

Expand All @@ -70,6 +71,7 @@ export class NativeToolCallParser {
name: string
hasStarted: boolean
deltaBuffer: string[]
extra_content?: Record<string, unknown> // kilocode_change
}
>()

Expand All @@ -85,10 +87,11 @@ export class NativeToolCallParser {
id?: string
name?: string
arguments?: string
extra_content?: Record<string, unknown> // kilocode_change
}): ToolCallStreamEvent[] {
const events: ToolCallStreamEvent[] = []
// kilocode_change start: Some providers (e.g. MiniMax) return tool call id as a number; coerce to string.
const { index, id: rawId, name, arguments: args } = chunk
const { index, id: rawId, name, arguments: args, extra_content } = chunk

const id = rawId != null ? String(rawId) : undefined
// kilocode_change end
Expand All @@ -102,6 +105,7 @@ export class NativeToolCallParser {
name: name || "",
hasStarted: false,
deltaBuffer: [],
extra_content, // kilocode_change
}
this.rawChunkTracker.set(index, tracked)
}
Expand All @@ -115,12 +119,19 @@ export class NativeToolCallParser {
tracked.name = name
}

// kilocode_change start: Update extra_content if present (Gemini 3 sends it once in the first chunk)
if (extra_content) {
tracked.extra_content = extra_content
}
// kilocode_change end

// Emit start event when we have the name
if (!tracked.hasStarted && tracked.name) {
events.push({
type: "tool_call_start",
id: tracked.id,
name: tracked.name,
extra_content: tracked.extra_content, // kilocode_change
})
tracked.hasStarted = true

Expand Down Expand Up @@ -205,12 +216,15 @@ export class NativeToolCallParser {
* Initializes tracking for incremental argument parsing.
* Accepts string to support both ToolName and dynamic MCP tools (mcp--serverName--toolName).
*/
public static startStreamingToolCall(id: string, name: string): void {
// kilocode_change start: extra_content parameter for Gemini 3 thought_signature support
public static startStreamingToolCall(id: string, name: string, extra_content?: Record<string, unknown>): void {
this.streamingToolCalls.set(id, {
id,
name,
argumentsAccumulator: "",
extra_content,
})
// kilocode_change end
}

/**
Expand Down Expand Up @@ -293,6 +307,7 @@ export class NativeToolCallParser {
id: toolCall.id,
name: toolCall.name as ToolName,
arguments: toolCall.argumentsAccumulator,
extra_content: toolCall.extra_content, // kilocode_change
})

// Clean up streaming state
Expand Down Expand Up @@ -596,6 +611,7 @@ export class NativeToolCallParser {
id: string
name: TName
arguments: string
extra_content?: Record<string, unknown> // kilocode_change
}): ToolUse<TName> | McpToolUse | null {
// Check if this is a dynamic MCP tool (mcp--serverName--toolName)
// Also handle models that output underscores instead of hyphens (mcp__serverName__toolName)
Expand Down Expand Up @@ -894,6 +910,12 @@ export class NativeToolCallParser {
result.originalName = toolCall.name
}

// kilocode_change start: Preserve extra_content for Gemini 3 thought_signature support
if (toolCall.extra_content) {
result.extra_content = toolCall.extra_content
}
// kilocode_change end

return result
} catch (error) {
console.error(
Expand All @@ -914,7 +936,12 @@ export class NativeToolCallParser {
* their original name so it appears correctly in API conversation history.
* The use_mcp_tool wrapper is only used in XML mode.
*/
public static parseDynamicMcpTool(toolCall: { id: string; name: string; arguments: string }): McpToolUse | null {
public static parseDynamicMcpTool(toolCall: {
id: string
name: string
arguments: string
extra_content?: Record<string, unknown> // kilocode_change
}): McpToolUse | null {
try {
// Parse the arguments - these are the actual tool arguments passed directly
const args = JSON.parse(toolCall.arguments || "{}")
Expand Down Expand Up @@ -944,6 +971,12 @@ export class NativeToolCallParser {
partial: false,
}

// kilocode_change start: Preserve extra_content for Gemini 3 thought_signature support
if (toolCall.extra_content) {
result.extra_content = toolCall.extra_content
}
// kilocode_change end

return result
} catch (error) {
console.error(`Failed to parse dynamic MCP tool:`, error)
Expand Down
48 changes: 43 additions & 5 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3086,6 +3086,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
id: chunk.id,
name: chunk.name,
arguments: chunk.arguments,
extra_content: chunk.extra_content,
})

for (const event of events) {
Expand All @@ -3103,7 +3104,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

// Initialize streaming in NativeToolCallParser
NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName)
NativeToolCallParser.startStreamingToolCall(
event.id,
event.name as ToolName,
event.extra_content,
)

// Before adding a new tool, finalize any preceding text block
// This prevents the text block from blocking tool presentation
Expand All @@ -3128,6 +3133,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Store the ID for native protocol
;(partialToolUse as any).id = event.id

// Preserve extra_content for Gemini 3 thought_signature support
if (event.extra_content) {
partialToolUse.extra_content = event.extra_content
}

// Add to content and present
this.assistantMessageContent.push(partialToolUse)
this.userMessageContentReady = false
Expand All @@ -3146,6 +3156,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Store the ID for native protocol
;(partialToolUse as any).id = event.id

// Preserve extra_content from the original tool use (Gemini 3 thought_signature)
const existingToolUse = this.assistantMessageContent[
toolUseIndex
] as any
if (existingToolUse?.extra_content && !partialToolUse.extra_content) {
partialToolUse.extra_content = existingToolUse.extra_content
}

// Update the existing tool use with new partial data
this.assistantMessageContent[toolUseIndex] = partialToolUse

Expand All @@ -3164,6 +3182,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Store the tool call ID
;(finalToolUse as any).id = event.id

// Preserve extra_content from the original tool use (Gemini 3 thought_signature)
if (toolUseIndex !== undefined) {
const existingToolUse = this.assistantMessageContent[
toolUseIndex
] as any
if (existingToolUse?.extra_content && !finalToolUse.extra_content) {
finalToolUse.extra_content = existingToolUse.extra_content
}
}

// Get the index and replace partial with final
if (toolUseIndex !== undefined) {
this.assistantMessageContent[toolUseIndex] = finalToolUse
Expand Down Expand Up @@ -3762,12 +3790,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
continue
}
seenToolUseIds.add(sanitizedId)
assistantContent.push({
const toolUseBlock: any = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Type safety regression — const toolUseBlock: any loses type checking

The previous code pushed a typed object literal directly into assistantContent (typed as Array<Anthropic.ContentBlockParam>). Changing to any disables all type checking on this object.

Consider using an intersection type instead to preserve type safety while allowing the extra property:

const toolUseBlock: Anthropic.ToolUseBlockParam & { extra_content?: Record<string, unknown> } = {
  type: "tool_use" as const,
  id: sanitizedId,
  name: mcpBlock.name,
  input: mcpBlock.arguments,
}

The same applies to the second toolUseBlock: any on line 3828.

type: "tool_use" as const,
id: sanitizedId,
name: mcpBlock.name, // Original dynamic name
input: mcpBlock.arguments, // Direct tool arguments
})
}
// Preserve extra_content for Gemini 3 thought_signature support
if (mcpBlock.extra_content) {
toolUseBlock.extra_content = mcpBlock.extra_content
}
assistantContent.push(toolUseBlock)
}
} else {
// Regular ToolUse
Expand All @@ -3792,12 +3825,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// was told the tool was named, preventing confusion in multi-turn conversations.
const toolNameForHistory = toolUse.originalName ?? toolUse.name

assistantContent.push({
const toolUseBlock: any = {
type: "tool_use" as const,
id: sanitizedId,
name: toolNameForHistory,
input,
})
}
// Preserve extra_content for Gemini 3 thought_signature support
if (toolUse.extra_content) {
toolUseBlock.extra_content = toolUse.extra_content
}
assistantContent.push(toolUseBlock)
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/shared/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ export interface ToolUse<TName extends ToolName = ToolName> {
toolUseId?: string // kilocode_change
// nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never
nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never
/**
* Extra content from provider-specific extensions (e.g., Gemini 3 thought_signature).
* Must be preserved and sent back in subsequent requests for multi-turn conversations.
*/
extra_content?: Record<string, unknown>
}

/**
Expand All @@ -170,6 +175,11 @@ export interface McpToolUse {
/** Arguments passed to the MCP tool */
arguments: Record<string, unknown>
partial: boolean
/**
* Extra content from provider-specific extensions (e.g., Gemini 3 thought_signature).
* Must be preserved and sent back in subsequent requests for multi-turn conversations.
*/
extra_content?: Record<string, unknown>
}

export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> {
Expand Down
Loading