diff --git a/CHANGELOG.md b/CHANGELOG.md index 51be42c34df..af678994d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai - Agents/sessions: persist the runtime-resolved context budget from embedded agent runs, so Codex GPT-5.5 sessions keep the catalog/runtime context cap instead of falling back to the generic 200k status value. Fixes #71294. Thanks @tud0r. - Agents/tools: fail runs before model submission when explicit tool allowlists resolve to no callable tools, preventing text-only hallucinated tool results for missing tools such as plugin commands that were not registered. Fixes #71292. Thanks @steipete. - Agents/embedded: skip provider submission when an embedded run has no prompt, replay history, or prompt-local images, preventing empty OpenAI Responses requests from surfacing provider errors into user channels. Fixes #71130. Thanks @steipete. +- Providers/Google: map `/think adaptive` to Gemini dynamic thinking instead of a fixed medium/high budget, using Gemini 3's provider default and Gemini 2.5's `thinkingBudget: -1`. Fixes #71316. Thanks @steipete. - Providers/MiniMax: keep M2.7 chat model metadata text-only so image tool requests route through `MiniMax-VL-01` instead of the Anthropic-compatible chat endpoint. Fixes #71296. Thanks @ilker-cevikkaya. - Discord/replies: run `message_sending` plugin hooks for Discord reply delivery, including DM targets, so plugins can transform or cancel outbound Discord replies consistently with other channels. Fixes #59350. (#71094) Thanks @wei840222. - Control UI/commands: carry provider-owned thinking option ids/labels in session rows and defaults so fresh sessions show and accept dynamic modes such as `adaptive`, `xhigh`, and `max`. Fixes #71269. Thanks @Young-Khalil. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index f7c8f694b6c..30faa6321ad 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -14,7 +14,7 @@ title: "Thinking levels" - medium → “think harder” - high → “ultrathink” (max budget) - xhigh → “ultrathink+” (GPT-5.2+ and Codex models, plus Anthropic Claude Opus 4.7 effort) - - adaptive → provider-managed adaptive thinking (supported for Claude 4.6 on Anthropic/Bedrock and Anthropic Claude Opus 4.7) + - adaptive → provider-managed adaptive thinking (supported for Claude 4.6 on Anthropic/Bedrock, Anthropic Claude Opus 4.7, and Google Gemini dynamic thinking) - max → provider max reasoning (currently Anthropic Claude Opus 4.7) - `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`. - `highest` maps to `high`. @@ -27,6 +27,7 @@ title: "Thinking levels" - Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting. - Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path. - OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value. + - Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family. - MiniMax (`minimax/*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from MiniMax's non-native Anthropic stream format. - Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`). - Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`. diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 66d14fe1fd0..400ef9cdf89 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -180,6 +180,40 @@ describe("google provider plugin hooks", () => { runCase(cliProvider, "google-gemini-cli"); }); + it("advertises adaptive thinking for Gemini dynamic thinking", async () => { + const { providers } = await registerProviderPlugin({ + plugin: googleProviderPlugin, + id: "google", + name: "Google Provider", + }); + const provider = requireRegisteredProvider(providers, "google"); + expect(provider.resolveThinkingProfile).toBeDefined(); + const resolveThinkingProfile = provider.resolveThinkingProfile!; + const gemini3Profile = resolveThinkingProfile({ + provider: "google", + modelId: "gemini-3.1-pro-preview", + } as never); + const gemini25Profile = resolveThinkingProfile({ + provider: "google", + modelId: "gemini-2.5-flash", + } as never); + + expect(gemini3Profile?.levels).toEqual([ + { id: "off" }, + { id: "low" }, + { id: "adaptive" }, + { id: "high" }, + ]); + expect(gemini25Profile?.levels).toEqual([ + { id: "off" }, + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "adaptive" }, + { id: "high" }, + ]); + }); + it("shares Gemini replay and stream hooks across Google provider variants", async () => { const { providers } = await registerProviderPlugin({ plugin: googleProviderPlugin, diff --git a/extensions/google/provider-hooks.ts b/extensions/google/provider-hooks.ts index a1a095b54d9..7100bcf3ea4 100644 --- a/extensions/google/provider-hooks.ts +++ b/extensions/google/provider-hooks.ts @@ -12,8 +12,15 @@ export const GOOGLE_GEMINI_PROVIDER_HOOKS = { resolveThinkingProfile: ({ modelId }: ProviderDefaultThinkingPolicyContext) => ({ levels: isGoogleGemini3ProModel(modelId) - ? [{ id: "off" }, { id: "low" }, { id: "high" }] - : [{ id: "off" }, { id: "minimal" }, { id: "low" }, { id: "medium" }, { id: "high" }], + ? [{ id: "off" }, { id: "low" }, { id: "adaptive" }, { id: "high" }] + : [ + { id: "off" }, + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "adaptive" }, + { id: "high" }, + ], }) satisfies ProviderThinkingProfile, wrapStreamFn: createGoogleThinkingStreamWrapper, }; diff --git a/extensions/google/thinking-api.ts b/extensions/google/thinking-api.ts index a13526e90cb..fbb47dfdc59 100644 --- a/extensions/google/thinking-api.ts +++ b/extensions/google/thinking-api.ts @@ -1,6 +1,7 @@ export { createGoogleThinkingPayloadWrapper, createGoogleThinkingStreamWrapper, + isGoogleGemini25ThinkingBudgetModel, isGoogleGemini3FlashModel, isGoogleGemini3ProModel, isGoogleGemini3ThinkingLevelModel, diff --git a/extensions/google/thinking.test.ts b/extensions/google/thinking.test.ts index d05f5cbc9e5..bd4dadf49b7 100644 --- a/extensions/google/thinking.test.ts +++ b/extensions/google/thinking.test.ts @@ -10,7 +10,7 @@ describe("google thinking policy", () => { ["minimal", "LOW"], ["low", "LOW"], ["medium", "HIGH"], - ["adaptive", "HIGH"], + ["adaptive", undefined], ["high", "HIGH"], ["xhigh", "HIGH"], ] as const)("maps Gemini 3 Pro thinking level %s to %s", (thinkingLevel, expected) => { @@ -40,7 +40,7 @@ describe("google thinking policy", () => { ["minimal", "MINIMAL"], ["low", "LOW"], ["medium", "MEDIUM"], - ["adaptive", "MEDIUM"], + ["adaptive", undefined], ["high", "HIGH"], ["xhigh", "HIGH"], ] as const)("maps Gemini 3 Flash thinking level %s to %s", (thinkingLevel, expected) => { @@ -53,7 +53,7 @@ describe("google thinking policy", () => { }); it.each([ - [-1, "MINIMAL"], + [-1, undefined], [0, "MINIMAL"], [2048, "LOW"], [8192, "MEDIUM"], @@ -98,6 +98,43 @@ describe("google thinking policy", () => { }); }); + it("keeps Gemini 3 adaptive thinking provider-dynamic instead of forcing a fixed level", () => { + const payload = { + generationConfig: { + thinkingConfig: { thinkingBudget: 8192, includeThoughts: true }, + }, + }; + + sanitizeGoogleThinkingPayload({ + payload, + modelId: "gemini-3-flash-preview", + thinkingLevel: "adaptive", + }); + + expect(payload.generationConfig.thinkingConfig).toEqual({ + includeThoughts: true, + }); + }); + + it("maps Gemini 2.5 adaptive thinking to dynamic thinkingBudget", () => { + const payload = { + config: { + thinkingConfig: { thinkingBudget: 8192, includeThoughts: true }, + }, + }; + + sanitizeGoogleThinkingPayload({ + payload, + modelId: "gemini-2.5-flash", + thinkingLevel: "adaptive", + }); + + expect(payload.config.thinkingConfig).toEqual({ + includeThoughts: true, + thinkingBudget: -1, + }); + }); + it("maps Gemma 4 thinking mode without sending thinkingBudget", () => { const payload = { config: { diff --git a/extensions/google/thinking.ts b/extensions/google/thinking.ts index 89aac457cd1..edc0735a5f6 100644 --- a/extensions/google/thinking.ts +++ b/extensions/google/thinking.ts @@ -1,6 +1,7 @@ export { createGoogleThinkingPayloadWrapper, createGoogleThinkingStreamWrapper, + isGoogleGemini25ThinkingBudgetModel, isGoogleGemini3FlashModel, isGoogleGemini3ProModel, isGoogleGemini3ThinkingLevelModel, diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index 902febfef19..e118951c0e4 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -444,6 +444,44 @@ describe("google transport stream", () => { }); }); + it("keeps adaptive Gemini 3 thinking on provider dynamic defaults", () => { + const params = buildGoogleGenerativeAiParams( + buildGeminiModel({ id: "gemini-3-flash-preview" }), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + } as never, + { + reasoning: "adaptive", + } as never, + ); + + expect(params.generationConfig).toMatchObject({ + thinkingConfig: { includeThoughts: true }, + }); + expect(params.generationConfig).not.toMatchObject({ + thinkingConfig: { thinkingLevel: expect.any(String) }, + }); + expect(params.generationConfig).not.toMatchObject({ + thinkingConfig: { thinkingBudget: expect.any(Number) }, + }); + }); + + it("maps adaptive Gemini 2.5 thinking to dynamic thinkingBudget", () => { + const params = buildGoogleGenerativeAiParams( + buildGeminiModel({ id: "gemini-2.5-flash" }), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + } as never, + { + reasoning: "adaptive", + } as never, + ); + + expect(params.generationConfig).toMatchObject({ + thinkingConfig: { includeThoughts: true, thinkingBudget: -1 }, + }); + }); + it("normalizes explicit Gemini 3 Pro thinking levels", () => { const params = buildGoogleGenerativeAiParams( buildGeminiModel({ id: "gemini-3.1-pro-preview" }), diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index 1f1b6041f3c..6db1cb6f167 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -25,6 +25,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim import { parseGeminiAuth } from "./gemini-auth.js"; import { normalizeGoogleApiBaseUrl } from "./provider-policy.js"; import { + isGoogleGemini25ThinkingBudgetModel, isGoogleGemini3FlashModel, isGoogleGemini3ProModel, resolveGoogleGemini3ThinkingLevel, @@ -238,6 +239,10 @@ function getGoogleThinkingBudget( return undefined; } +function isAdaptiveReasoningLevel(value: unknown): value is "adaptive" { + return value === "adaptive"; +} + function resolveGoogleThinkingConfig( model: GoogleTransportModel, options: GoogleTransportOptions | undefined, @@ -268,6 +273,17 @@ function resolveGoogleThinkingConfig( if (!options?.reasoning) { return getDisabledThinkingConfig(model.id); } + if (isAdaptiveReasoningLevel(options.reasoning)) { + if (isGoogleGemini3ProModel(model.id) || isGoogleGemini3FlashModel(model.id)) { + return { includeThoughts: true }; + } + if (isGoogleGemini25ThinkingBudgetModel(model.id)) { + return normalizeGoogleThinkingConfig(model.id, { + includeThoughts: true, + thinkingBudget: -1, + }); + } + } if (isGoogleGemini3ProModel(model.id) || isGoogleGemini3FlashModel(model.id)) { return { includeThoughts: true, diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts b/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts index e7b4c8d58bf..44556ff6b8f 100644 --- a/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts +++ b/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts @@ -97,7 +97,7 @@ describe("sanitizeGoogleThinkingPayload — gemini-2.5-pro zero budget", () => { }); }); - it("fills thinkingLevel for Gemini 3 Flash negative budgets", () => { + it("rewrites Gemini 3 Flash negative budgets when a fixed thinking level is explicit", () => { const payload = { config: { thinkingConfig: { thinkingBudget: -1, includeThoughts: true }, @@ -113,4 +113,37 @@ describe("sanitizeGoogleThinkingPayload — gemini-2.5-pro zero budget", () => { thinkingLevel: "MEDIUM", }); }); + + it("keeps Gemini 3 adaptive thinking on provider dynamic defaults", () => { + const payload = { + config: { + thinkingConfig: { thinkingBudget: 8192, includeThoughts: true }, + }, + }; + sanitizeGoogleThinkingPayload({ + payload, + modelId: "gemini-3-flash-preview", + thinkingLevel: "adaptive", + }); + expect(payload.config.thinkingConfig).toEqual({ + includeThoughts: true, + }); + }); + + it("maps Gemini 2.5 adaptive thinking to thinkingBudget=-1", () => { + const payload = { + config: { + thinkingConfig: { thinkingBudget: 8192, includeThoughts: true }, + }, + }; + sanitizeGoogleThinkingPayload({ + payload, + modelId: "gemini-2.5-flash", + thinkingLevel: "adaptive", + }); + expect(payload.config.thinkingConfig).toEqual({ + includeThoughts: true, + thinkingBudget: -1, + }); + }); }); diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index 9ebf3733574..34135faceb2 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -158,6 +158,10 @@ export function isGoogleThinkingRequiredModel(modelId: string): boolean { return normalizeLowercaseStringOrEmpty(modelId).includes("gemini-2.5-pro"); } +export function isGoogleGemini25ThinkingBudgetModel(modelId: string): boolean { + return /(?:^|\/)gemini-2\.5-/.test(normalizeLowercaseStringOrEmpty(modelId)); +} + export function isGoogleGemini3ProModel(modelId: string): boolean { const normalized = normalizeLowercaseStringOrEmpty(modelId); return /(?:^|\/)gemini-(?:3(?:\.\d+)?-pro|pro-latest)(?:-|$)/.test(normalized); @@ -187,15 +191,19 @@ export function resolveGoogleGemini3ThinkingLevel(params: { case "low": return "LOW"; case "medium": - case "adaptive": case "high": case "max": case "xhigh": return "HIGH"; + case "adaptive": + return undefined; case undefined: break; } if (typeof params.thinkingBudget === "number") { + if (params.thinkingBudget < 0) { + return undefined; + } return params.thinkingBudget <= 2048 ? "LOW" : "HIGH"; } return undefined; @@ -210,18 +218,22 @@ export function resolveGoogleGemini3ThinkingLevel(params: { case "low": return "LOW"; case "medium": - case "adaptive": return "MEDIUM"; case "high": case "max": case "xhigh": return "HIGH"; + case "adaptive": + return undefined; case undefined: break; } if (typeof params.thinkingBudget !== "number") { return undefined; } + if (params.thinkingBudget < 0) { + return undefined; + } if (params.thinkingBudget <= 0) { return "MINIMAL"; } @@ -355,6 +367,29 @@ function sanitizeGoogleThinkingConfigContainer(params: { const thinkingBudget = thinkingConfigObj.thinkingBudget; + if ( + params.thinkingLevel === "adaptive" && + typeof params.modelId === "string" && + isGoogleGemini25ThinkingBudgetModel(params.modelId) + ) { + delete thinkingConfigObj.thinkingLevel; + thinkingConfigObj.thinkingBudget = -1; + return; + } + + if ( + params.thinkingLevel === "adaptive" && + typeof params.modelId === "string" && + isGoogleGemini3ThinkingLevelModel(params.modelId) + ) { + delete thinkingConfigObj.thinkingBudget; + delete thinkingConfigObj.thinkingLevel; + if (Object.keys(thinkingConfigObj).length === 0) { + delete configObj.thinkingConfig; + } + return; + } + if (typeof params.modelId === "string" && isGoogleGemini3ThinkingLevelModel(params.modelId)) { const mappedLevel = resolveGoogleGemini3ThinkingLevel({ modelId: params.modelId,