Просмотр исходного кода

fix(ai): preserve Gemini agent loop parity (#41109)

Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
opencode-agent[bot] 1 неделя назад
Родитель
Сommit
db3b54a30d

+ 37 - 5
packages/ai/src/protocols/gemini.ts

@@ -25,8 +25,20 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
 
 const ADAPTER = "gemini"
 const MEDIA_MIMES = new Set<string>(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"
 
+// 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
+  return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
+}
+
 export interface OptionsInput {
   readonly [key: string]: unknown
   readonly cachedContent?: string
@@ -145,6 +157,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),
 })
@@ -202,11 +217,13 @@ 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.
+//    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
@@ -282,6 +299,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
 
     if (message.role === "assistant") {
       const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
+      // 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"]))
           return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
@@ -294,7 +313,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 ??
+              (requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
+                ? SKIP_THOUGHT_SIGNATURE_VALIDATOR
+                : undefined),
+          })
+          if (signature !== undefined) hasSignedToolCall = true
           continue
         }
       }
@@ -388,6 +417,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,
   }

+ 31 - 11
packages/ai/src/protocols/utils/gemini-tool-schema.ts

@@ -61,37 +61,57 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
   (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
   !schema.additionalProperties
 
-const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
+const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
   if (!isRecord(schema)) return undefined
-  if (emptyObjectSchema(schema)) return undefined
-  return Object.fromEntries(
+  if (!nested && emptyObjectSchema(schema)) return undefined
+  const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
+  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", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
-      ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
+      ["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
+      [
+        "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",
         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((item) => projectNode(item, true)) : undefined],
+      [
+        "anyOf",
+        anyOfTypes
+          ? hasNullAnyOf && anyOfTypes.length === 1
+            ? undefined
+            : anyOfTypes.map((item) => projectNode(item, true))
+          : types && types.length > 0
+            ? types.map((type) => ({ type }))
+            : undefined,
       ],
-      ["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],
+      ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
       ["minLength", schema.minLength],
     ].filter((entry) => entry[1] !== undefined),
   )
+  return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
 }
 
 export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))

+ 172 - 0
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,100 @@ 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("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"] },
+                  explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
+                  choice: { anyOf: [{ type: "string" }, { type: "number" }, { 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",
+          },
+          explicit: {
+            type: "string",
+            nullable: true,
+          },
+          choice: {
+            anyOf: [{ type: "string" }, { type: "number" }],
+            nullable: true,
+          },
+        },
+      })
+    }),
+  )
+
   it.effect("parses text, reasoning, and usage stream fixtures", () =>
     Effect.gen(function* () {
       const body = sseEvents(
@@ -536,6 +670,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({