From d36744ad2b64d9fe18e87b2e28a2566fa0e3aeef Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 7 Aug 2026 16:36:32 +0000 Subject: [PATCH 1/4] fix(ai): preserve Gemini agent loop parity --- packages/ai/src/protocols/gemini.ts | 29 ++++- .../src/protocols/utils/gemini-tool-schema.ts | 16 +-- packages/ai/test/provider/gemini.test.ts | 120 ++++++++++++++++++ 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 2e20324c6e27..ddb896791e42 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -25,8 +25,18 @@ import { ToolSchemaProjection } from "./utils/tool-schema" const ADAPTER = "gemini" const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) +// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost. +const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" +// Model IDs are open-ended, so unknown Gemini aliases inherit the newest supported request behavior. +const usesGemini3Features = (modelID: string) => { + if (!/(^|\/)gemini-/i.test(modelID)) return false + if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false + if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false + return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID) +} + export interface OptionsInput { readonly [key: string]: unknown readonly cachedContent?: string @@ -145,6 +155,9 @@ const GeminiGenerationConfig = Schema.Struct({ temperature: Schema.optional(Schema.Number), topP: Schema.optional(Schema.Number), topK: Schema.optional(Schema.Number), + frequencyPenalty: Schema.optional(Schema.Number), + presencePenalty: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), stopSequences: optionalArray(Schema.String), thinkingConfig: Schema.optional(GeminiThinkingConfig), }) @@ -282,6 +295,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR if (message.role === "assistant") { const parts: Array> = [] + let hasSignedToolCall = false for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"]) @@ -294,7 +308,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR continue } if (part.type === "tool-call") { - parts.push(lowerToolCall(part)) + const lowered = lowerToolCall(part) + const signature = lowered.thoughtSignature + parts.push({ + ...lowered, + thoughtSignature: + signature ?? + (usesGemini3Features(request.model.id) && !hasSignedToolCall + ? SKIP_THOUGHT_SIGNATURE_VALIDATOR + : undefined), + }) + if (signature !== undefined) hasSignedToolCall = true continue } } @@ -388,6 +412,9 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque temperature: generation?.temperature, topP: generation?.topP, topK: generation?.topK, + frequencyPenalty: generation?.frequencyPenalty, + presencePenalty: generation?.presencePenalty, + seed: generation?.seed, stopSequences: generation?.stop, thinkingConfig: options.thinkingConfig, } diff --git a/packages/ai/src/protocols/utils/gemini-tool-schema.ts b/packages/ai/src/protocols/utils/gemini-tool-schema.ts index efdbe3f6ec65..649b207ea4e7 100644 --- a/packages/ai/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/ai/src/protocols/utils/gemini-tool-schema.ts @@ -61,9 +61,9 @@ const emptyObjectSchema = (schema: Record) => (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) && !schema.additionalProperties -const projectNode = (schema: unknown): Record | undefined => { +const projectNode = (schema: unknown, nested = false): Record | undefined => { if (!isRecord(schema)) return undefined - if (emptyObjectSchema(schema)) return undefined + if (!nested && emptyObjectSchema(schema)) return undefined return Object.fromEntries( [ ["description", schema.description], @@ -75,20 +75,20 @@ const projectNode = (schema: unknown): Record | undefined => { [ "properties", isRecord(schema.properties) - ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)])) + ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)])) : undefined, ], [ "items", Array.isArray(schema.items) - ? schema.items.map(projectNode) + ? schema.items.map((item) => projectNode(item, true)) : schema.items === undefined ? undefined - : projectNode(schema.items), + : projectNode(schema.items, true), ], - ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined], - ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined], - ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined], + ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined], + ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map((item) => projectNode(item, true)) : undefined], + ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined], ["minLength", schema.minLength], ].filter((entry) => entry[1] !== undefined), ) diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 9bc388f495fd..37953b2e27ad 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -16,6 +16,13 @@ const model = Gemini.route }) .model({ id: "gemini-2.5-flash" }) +const gemini3 = Gemini.route + .with({ + endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, + auth: Auth.header("x-goog-api-key", "test"), + }) + .model({ id: "gemini-3-flash-preview" }) + const request = LLM.request({ id: "req_1", model, @@ -86,6 +93,39 @@ describe("Gemini route", () => { }), ) + it.effect("forwards standard Gemini generation options", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + prompt: "Say hello.", + generation: { + maxTokens: 40, + temperature: 0.2, + topP: 0.8, + topK: 12, + frequencyPenalty: 0.3, + presencePenalty: 0.4, + seed: 42, + stop: ["done"], + }, + }), + ) + + expect(prepared.body.generationConfig).toEqual({ + maxOutputTokens: 40, + temperature: 0.2, + topP: 0.8, + topK: 12, + frequencyPenalty: 0.3, + presencePenalty: 0.4, + seed: 42, + stopSequences: ["done"], + thinkingConfig: undefined, + }) + }), + ) + it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { const prepared = yield* compileRequest( @@ -350,6 +390,48 @@ describe("Gemini route", () => { }), ) + it.effect("preserves nested empty object tool schemas", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [ + { + name: "configure", + description: "Configure the operation", + inputSchema: { + type: "object", + required: ["options"], + properties: { + options: { type: "object", description: "Optional provider settings", properties: {} }, + }, + }, + }, + ], + }), + ) + + expect(prepared.body.tools).toEqual([ + { + functionDeclarations: [ + { + name: "configure", + description: "Configure the operation", + parameters: { + type: "object", + required: ["options"], + properties: { + options: { type: "object", description: "Optional provider settings", properties: {} }, + }, + }, + }, + ], + }, + ]) + }), + ) + it.effect("parses text, reasoning, and usage stream fixtures", () => Effect.gen(function* () { const body = sseEvents( @@ -536,6 +618,44 @@ describe("Gemini route", () => { }), ) + it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: gemini3, + messages: [ + Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }), + ], + }), + ) + + expect(prepared.body.contents).toEqual([ + { + role: "model", + parts: [ + { + functionCall: { id: undefined, name: "lookup", args: { query: "weather" } }, + thoughtSignature: "skip_thought_signature_validator", + }, + ], + }, + { + role: "user", + parts: [ + { + functionResponse: { + id: undefined, + name: "lookup", + response: { name: "lookup", content: "done" }, + }, + }, + ], + }, + ]) + }), + ) + it.effect("emits streamed tool calls and maps finish reason", () => Effect.gen(function* () { const body = sseEvents({ From 5708881aa7f9fb89ef345575f5ef94442635b8b3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 7 Aug 2026 16:43:34 +0000 Subject: [PATCH 2/4] docs(ai): clarify Gemini signature fallback --- packages/ai/src/protocols/gemini.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index ddb896791e42..79e2c0e78496 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -29,8 +29,10 @@ const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" -// Model IDs are open-ended, so unknown Gemini aliases inherit the newest supported request behavior. -const usesGemini3Features = (modelID: string) => { +// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by +// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call +// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior. +const requiresThoughtSignatureFallback = (modelID: string) => { if (!/(^|\/)gemini-/i.test(modelID)) return false if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false @@ -295,6 +297,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR if (message.role === "assistant") { const parts: Array> = [] + // Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid. let hasSignedToolCall = false for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) @@ -314,7 +317,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR ...lowered, thoughtSignature: signature ?? - (usesGemini3Features(request.model.id) && !hasSignedToolCall + (requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall ? SKIP_THOUGHT_SIGNATURE_VALIDATOR : undefined), }) From 1fa711e338aac9593727904c01350782e321a439 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 7 Aug 2026 16:58:13 +0000 Subject: [PATCH 3/4] fix(ai): preserve Gemini type unions --- packages/ai/src/protocols/gemini.ts | 5 ++- .../src/protocols/utils/gemini-tool-schema.ts | 12 +++++- packages/ai/test/provider/gemini.test.ts | 43 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 79e2c0e78496..c4ad386b17e0 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -217,8 +217,9 @@ interface ParserState { // keys on non-object scalars. Mirrors OpenCode's historical Gemini rules. // // 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect: -// drop empty objects, derive `nullable: true` from `type: [..., "null"]`, -// coerce `const` to `[const]` enum, recurse properties/items, propagate +// drop empty root parameter schemas while preserving nested empty objects, +// expand type arrays into `anyOf`, derive `nullable: true` from null members, +// coerce `const` to `[const]` enum, recurse properties/items, and propagate // only an allowlisted set of keys (description, required, format, type, // properties, items, allOf, anyOf, oneOf, minLength). Anything outside the // allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped. diff --git a/packages/ai/src/protocols/utils/gemini-tool-schema.ts b/packages/ai/src/protocols/utils/gemini-tool-schema.ts index 649b207ea4e7..cc63f2cc6c06 100644 --- a/packages/ai/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/ai/src/protocols/utils/gemini-tool-schema.ts @@ -64,12 +64,13 @@ const emptyObjectSchema = (schema: Record) => const projectNode = (schema: unknown, nested = false): Record | undefined => { if (!isRecord(schema)) return undefined if (!nested && emptyObjectSchema(schema)) return undefined + const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined return Object.fromEntries( [ ["description", schema.description], ["required", schema.required], ["format", schema.format], - ["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type], + ["type", types ? (types.length === 0 ? "null" : undefined) : schema.type], ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined], ["enum", schema.const !== undefined ? [schema.const] : schema.enum], [ @@ -87,7 +88,14 @@ const projectNode = (schema: unknown, nested = false): Record | : projectNode(schema.items, true), ], ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined], - ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map((item) => projectNode(item, true)) : undefined], + [ + "anyOf", + Array.isArray(schema.anyOf) + ? schema.anyOf.map((item) => projectNode(item, true)) + : types && types.length > 0 + ? types.map((type) => ({ type })) + : undefined, + ], ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined], ["minLength", schema.minLength], ].filter((entry) => entry[1] !== undefined), diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 37953b2e27ad..e1150dab8424 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -432,6 +432,49 @@ describe("Gemini route", () => { }), ) + it.effect("projects Gemini type arrays without narrowing their allowed values", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [ + { + name: "filter", + description: "Filter values", + inputSchema: { + type: "object", + properties: { + status: { type: ["number", "string"], description: "Status filter" }, + maybe: { type: ["string", "null"] }, + nothing: { type: ["null"] }, + }, + }, + }, + ], + }), + ) + + expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({ + type: "object", + properties: { + status: { + description: "Status filter", + anyOf: [{ type: "number" }, { type: "string" }], + }, + maybe: { + nullable: true, + anyOf: [{ type: "string" }], + }, + nothing: { + type: "null", + nullable: true, + }, + }, + }) + }), + ) + it.effect("parses text, reasoning, and usage stream fixtures", () => Effect.gen(function* () { const body = sseEvents( From 0440c1009a1cf0ac89939cae56fafdfbf52aad8e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 7 Aug 2026 17:12:51 +0000 Subject: [PATCH 4/4] fix(ai): normalize nullable Gemini schemas --- packages/ai/src/protocols/gemini.ts | 5 +++-- .../src/protocols/utils/gemini-tool-schema.ts | 20 +++++++++++++++---- packages/ai/test/provider/gemini.test.ts | 9 +++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index c4ad386b17e0..b84bb16fa2ed 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -221,8 +221,9 @@ interface ParserState { // expand type arrays into `anyOf`, derive `nullable: true` from null members, // coerce `const` to `[const]` enum, recurse properties/items, and propagate // only an allowlisted set of keys (description, required, format, type, -// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the -// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped. +// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength). +// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is +// silently dropped. // // Sanitize runs first, then project. The implementation lives in // `utils/gemini-tool-schema` so this protocol keeps the same shape as the other diff --git a/packages/ai/src/protocols/utils/gemini-tool-schema.ts b/packages/ai/src/protocols/utils/gemini-tool-schema.ts index cc63f2cc6c06..991dfdfbf96c 100644 --- a/packages/ai/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/ai/src/protocols/utils/gemini-tool-schema.ts @@ -65,13 +65,22 @@ const projectNode = (schema: unknown, nested = false): Record | if (!isRecord(schema)) return undefined if (!nested && emptyObjectSchema(schema)) return undefined const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined - return Object.fromEntries( + const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined + const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false + const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf + const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined + const result = Object.fromEntries( [ ["description", schema.description], ["required", schema.required], ["format", schema.format], ["type", types ? (types.length === 0 ? "null" : undefined) : schema.type], - ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined], + [ + "nullable", + (Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf + ? true + : undefined, + ], ["enum", schema.const !== undefined ? [schema.const] : schema.enum], [ "properties", @@ -90,8 +99,10 @@ const projectNode = (schema: unknown, nested = false): Record | ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined], [ "anyOf", - Array.isArray(schema.anyOf) - ? schema.anyOf.map((item) => projectNode(item, true)) + anyOfTypes + ? hasNullAnyOf && anyOfTypes.length === 1 + ? undefined + : anyOfTypes.map((item) => projectNode(item, true)) : types && types.length > 0 ? types.map((type) => ({ type })) : undefined, @@ -100,6 +111,7 @@ const projectNode = (schema: unknown, nested = false): Record | ["minLength", schema.minLength], ].filter((entry) => entry[1] !== undefined), ) + return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result } export const convert = (schema: unknown) => projectNode(sanitizeNode(schema)) diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index e1150dab8424..42d7754a5906 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -448,6 +448,8 @@ describe("Gemini route", () => { status: { type: ["number", "string"], description: "Status filter" }, maybe: { type: ["string", "null"] }, nothing: { type: ["null"] }, + explicit: { anyOf: [{ type: "string" }, { type: "null" }] }, + choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] }, }, }, }, @@ -468,6 +470,13 @@ describe("Gemini route", () => { }, nothing: { type: "null", + }, + explicit: { + type: "string", + nullable: true, + }, + choice: { + anyOf: [{ type: "string" }, { type: "number" }], nullable: true, }, },