From d3b92bd50580739cf379d301b2c1ebdafc3b3eb3 Mon Sep 17 00:00:00 2001 From: Steven Mendez Date: Tue, 28 Jul 2026 05:01:36 -0600 Subject: [PATCH] fix(google): Gemini 3.x uses native thought parts, not tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini 3.x streams reasoning as native `thought` parts and the Google transport already routes them into thinking blocks (`part.thought === true`, with `includeThoughts: true` requested in `thinkingConfig`). Forcing the provider into `tagged` mode on top of that asked the model to wrap its reasoning in `...`; instead of following the instruction it narrated it, so the chain-of-thought — including the injected system rules and internal tool names — reached users verbatim in the text stream. The reasoning channel stayed empty on every affected turn. Resolve `native` for Gemini 3.x on both paths that force `tagged` for Google (the `google-gemini` provider family hook and the built-in provider map), behind one shared predicate so the two cannot drift. Older Gemini models, which do not emit native thought parts, keep `tagged`. With no modelId in context the built-in map also keeps `tagged`. Co-Authored-By: Claude Opus 5 --- extensions/google/index.test.ts | 10 ++++++++ src/plugin-sdk/provider-model-shared.test.ts | 16 +++++++++++++ src/plugin-sdk/provider-model-shared.ts | 8 +++++-- src/plugins/provider-replay-helpers.ts | 16 +++++++++++++ src/utils/provider-utils.test.ts | 25 ++++++++++++++++++++ src/utils/provider-utils.ts | 3 ++- 6 files changed, 75 insertions(+), 3 deletions(-) diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index b0cdec99cba31..e8b67c2bba749 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -59,12 +59,22 @@ describe("google provider plugin hooks", () => { allowSyntheticToolResults: true, }); + // Gemini 3.x emits native `thought` parts; asking it for tags on + // top made it narrate the tag instruction instead of following it. expect( provider.resolveReasoningOutputMode?.({ provider: "google", modelApi: "google-generative-ai", modelId: "gemini-3.1-pro-preview", } as never), + ).toBe("native"); + + expect( + provider.resolveReasoningOutputMode?.({ + provider: "google", + modelApi: "google-generative-ai", + modelId: "gemini-2.5-flash", + } as never), ).toBe("tagged"); const sanitized = await Promise.resolve( diff --git a/src/plugin-sdk/provider-model-shared.test.ts b/src/plugin-sdk/provider-model-shared.test.ts index caf9a4d9b1f69..cc11612cb5fd6 100644 --- a/src/plugin-sdk/provider-model-shared.test.ts +++ b/src/plugin-sdk/provider-model-shared.test.ts @@ -85,6 +85,8 @@ describe("buildProviderReplayFamilyHooks", () => { }, { family: "google-gemini" as const, + // Gemini 3.x emits native `thought` parts — tagging on top made it + // narrate the tag instruction instead of following it. ctx: { provider: "google", modelApi: "google-generative-ai", @@ -95,6 +97,20 @@ describe("buildProviderReplayFamilyHooks", () => { allowSyntheticToolResults: true, }, hasSanitizeReplayHistory: true, + reasoningMode: "native", + }, + { + family: "google-gemini" as const, + ctx: { + provider: "google", + modelApi: "google-generative-ai", + modelId: "gemini-2.5-flash", + }, + match: { + validateGeminiTurns: true, + allowSyntheticToolResults: true, + }, + hasSanitizeReplayHistory: true, reasoningMode: "tagged", }, { diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index f621239556393..eefd48fc04423 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -11,6 +11,8 @@ import { buildOpenAICompatibleReplayPolicy, buildPassthroughGeminiSanitizingReplayPolicy, buildStrictAnthropicReplayPolicy, + hasNativeGeminiThoughtParts, + resolveGoogleGeminiReasoningOutputMode, resolveTaggedReasoningOutputMode, sanitizeGoogleGeminiReplayHistory, } from "../plugins/provider-replay-helpers.js"; @@ -80,6 +82,8 @@ export { buildNativeAnthropicReplayPolicyForModel, buildOpenAICompatibleReplayPolicy, buildPassthroughGeminiSanitizingReplayPolicy, + hasNativeGeminiThoughtParts, + resolveGoogleGeminiReasoningOutputMode, resolveTaggedReasoningOutputMode, sanitizeGoogleGeminiReplayHistory, buildStrictAnthropicReplayPolicy, @@ -219,8 +223,8 @@ export function buildProviderReplayFamilyHooks( buildReplayPolicy: () => buildGoogleGeminiReplayPolicy(), sanitizeReplayHistory: (ctx: ProviderSanitizeReplayHistoryContext) => sanitizeGoogleGeminiReplayHistory(ctx), - resolveReasoningOutputMode: (_ctx: ProviderReasoningOutputModeContext) => - resolveTaggedReasoningOutputMode(), + resolveReasoningOutputMode: (ctx: ProviderReasoningOutputModeContext) => + resolveGoogleGeminiReasoningOutputMode(ctx), }; case "passthrough-gemini": return { diff --git a/src/plugins/provider-replay-helpers.ts b/src/plugins/provider-replay-helpers.ts index 2180dbd4d24cf..a2aae2662ca8e 100644 --- a/src/plugins/provider-replay-helpers.ts +++ b/src/plugins/provider-replay-helpers.ts @@ -227,3 +227,19 @@ export function sanitizeGoogleGeminiReplayHistory( export function resolveTaggedReasoningOutputMode(): ProviderReasoningOutputMode { return "tagged"; } + +/** + * Gemini 3.x streams reasoning as native `thought` parts, which the Google + * transport already routes into thinking blocks. Asking it for `` tags + * on top of that makes it narrate the tag instruction itself instead of + * following it, and the chain-of-thought lands in the text stream verbatim. + */ +export function hasNativeGeminiThoughtParts(modelId: string | undefined | null): boolean { + return /gemini-3(?:\.\d+)?-(?:pro|flash)/.test(normalizeLowercaseStringOrEmpty(modelId)); +} + +export function resolveGoogleGeminiReasoningOutputMode( + ctx: ProviderReplayPolicyContext, +): ProviderReasoningOutputMode { + return hasNativeGeminiThoughtParts(ctx.modelId) ? "native" : resolveTaggedReasoningOutputMode(); +} diff --git a/src/utils/provider-utils.test.ts b/src/utils/provider-utils.test.ts index dc81d202f3a1b..0da3d33a17973 100644 --- a/src/utils/provider-utils.test.ts +++ b/src/utils/provider-utils.test.ts @@ -30,6 +30,31 @@ describe("resolveReasoningOutputMode", () => { }, ); + // Gemini 3.x streams native `thought` parts. Asking it for tags on top + // made it narrate the tag instruction as prose, leaking the chain-of-thought + // (and internal tool names) into the answer. + it.each([ + ["gemini-3.6-flash", "native"], + ["gemini-3-flash", "native"], + ["gemini-3.1-pro-preview", "native"], + ["gemini-2.5-flash", "tagged"], + ["gemini-2.5-flash-lite", "tagged"], + ] as const)("built-in map resolves %s to %s", (modelId, expected) => { + expect( + resolveReasoningOutputMode({ + provider: "google-generative-ai", + modelId, + workspaceDir: process.cwd(), + }), + ).toBe(expected); + }); + + it("keeps the built-in tagged mode when no model is known", () => { + expect( + resolveReasoningOutputMode({ provider: "google-generative-ai", workspaceDir: process.cwd() }), + ).toBe("tagged"); + }); + it.each([ ["google", "tagged"], ["Google", "tagged"], diff --git a/src/utils/provider-utils.ts b/src/utils/provider-utils.ts index d0e3a15aa6867..d534ce09ac75f 100644 --- a/src/utils/provider-utils.ts +++ b/src/utils/provider-utils.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { hasNativeGeminiThoughtParts } from "../plugins/provider-replay-helpers.js"; import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import { resolveProviderReasoningOutputModeWithPlugin } from "../plugins/provider-runtime.js"; import { @@ -51,7 +52,7 @@ export function resolveReasoningOutputMode(params: { const builtInMode = BUILTIN_REASONING_OUTPUT_MODES[normalized as keyof typeof BUILTIN_REASONING_OUTPUT_MODES]; if (builtInMode) { - return builtInMode; + return hasNativeGeminiThoughtParts(params.modelId) ? "native" : builtInMode; } // Keep a tiny built-in fallback for non-plugin Google surfaces.