From 8c9ec0724ef5a0023631aeb0802a6d5ff05a2f37 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 15 May 2026 22:33:43 -0700 Subject: [PATCH] fix(agents): honor disabled reasoning in thinking policy (#81454) * fix(agents): honor disabled reasoning in thinking policy * test: refresh thinking policy CI fixtures * test: align thinking policy CI guardrails --------- Co-authored-by: Gio Della-Libera --- CHANGELOG.md | 1 + extensions/google/transport-stream.test.ts | 17 ++++++++++ src/agents/model-selection.test.ts | 32 +++++++++++++++++++ src/agents/model-thinking-default.ts | 13 ++++---- src/auto-reply/thinking.test.ts | 32 +++++++++++++++++++ src/auto-reply/thinking.ts | 10 ++++++ src/gateway/server-startup-log.test.ts | 30 +++++++++++++++++ src/gateway/server-startup-log.ts | 20 +++++++++++- ...in-sdk-package-contract-guardrails.test.ts | 1 + src/plugins/registry.ts | 2 +- 10 files changed, 150 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f03b36afb15..8f77f872302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -675,6 +675,7 @@ Docs: https://docs.openclaw.ai - Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash. - iMessage: stop sending visible `` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte. - Agents/sessions: create configured agent main sessions before first `sessions_send` or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet. +- Google models: honor configured `reasoning: false` when resolving thinking policy, preventing non-thinking Google/Gemma models from advertising `thinking=medium`. Fixes #81424. - gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987. - Gateway protocol: require v4 clients and stream explicit chat `deltaText`/`replace` frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong. - GitHub Copilot: exchange OAuth tokens for Copilot API tokens on image understanding requests and route Gemini image payloads through Chat Completions, fixing Copilot Gemini image descriptions. (#80393, #80442) Thanks @afunnyhy. diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index afeca3057df..33d488b7b24 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -830,6 +830,23 @@ describe("google transport stream", () => { expect(thinkingConfig).not.toHaveProperty("thinkingBudget"); }); + it("does not send thinkingConfig when the resolved Google model disables reasoning", () => { + const params = buildGoogleGenerativeAiParams( + buildGeminiModel({ + id: "gemma-4-26b-a4b-it", + reasoning: false, + }), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + } as never, + { + reasoning: "medium", + }, + ); + + expect(params.generationConfig ?? {}).not.toHaveProperty("thinkingConfig"); + }); + it("omits disabled thinkingBudget=0 for Gemini 2.5 Pro direct payloads", () => { const params = buildGoogleGenerativeAiParams( buildGeminiModel(), diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 9da9d44b2e2..57b83711ac9 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -2128,6 +2128,38 @@ describe("model-selection", () => { }), ).toBe("medium"); }); + + it("honors configured provider models that disable reasoning", () => { + const cfg = { + models: { + providers: { + google: { + api: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + models: [ + { + id: "gemma-4-26b-a4b-it", + name: "Gemma 4 26B", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 8_192, + }, + ], + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveThinkingDefault({ + cfg, + provider: "google", + model: "gemma-4-26b-a4b-it", + }), + ).toBe("off"); + }); }); }); diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts index 2b96da565d7..e325c8c9d54 100644 --- a/src/agents/model-thinking-default.ts +++ b/src/agents/model-thinking-default.ts @@ -19,11 +19,12 @@ export function resolveThinkingDefault(params: { }): ThinkLevel { const normalizedProvider = normalizeProviderId(params.provider); const normalizedModel = normalizeLowercaseStringOrEmpty(params.model).replace(/\./g, "-"); - const catalogCandidate = Array.isArray(params.catalog) - ? params.catalog.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ) - : undefined; + const catalog = Array.isArray(params.catalog) + ? params.catalog + : buildConfiguredModelCatalog({ cfg: params.cfg }); + const catalogCandidate = catalog.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); const configuredModels = params.cfg.agents?.defaults?.models; const canonicalKey = modelKey(params.provider, params.model); const legacyKey = legacyModelKey(params.provider, params.model); @@ -75,7 +76,7 @@ export function resolveThinkingDefault(params: { return resolveThinkingDefaultForModel({ provider: params.provider, model: params.model, - catalog: params.catalog, + catalog, }); } diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 081c95300bc..aa07e77e6cf 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -160,6 +160,38 @@ describe("listThinkingLevels", () => { expect(listThinkingLevelLabels("demo", "demo-model")).toEqual(["off", "on"]); }); + it("treats catalog reasoning=false as an explicit thinking opt-out", () => { + providerRuntimeMocks.resolveProviderThinkingProfile.mockReturnValue({ + levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }], + defaultLevel: "medium", + }); + const catalog = [ + { + provider: "google", + id: "gemma-4-26b-a4b-it", + name: "Gemma 4 26B", + reasoning: false, + }, + ]; + + expect(listThinkingLevels("google", "gemma-4-26b-a4b-it", catalog)).toEqual(["off"]); + expect( + isThinkingLevelSupported({ + provider: "google", + model: "gemma-4-26b-a4b-it", + level: "medium", + catalog, + }), + ).toBe(false); + expect( + resolveThinkingDefaultForModel({ + provider: "google", + model: "gemma-4-26b-a4b-it", + catalog, + }), + ).toBe("off"); + }); + it("passes catalog reasoning into provider thinking profiles for support checks", () => { providerRuntimeMocks.resolveProviderThinkingProfile.mockImplementation(({ context }) => ({ levels: diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 5a139e2a609..e486121f973 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -127,6 +127,13 @@ function buildBaseThinkingProfile(defaultLevel?: ThinkLevel | null): ResolvedThi }; } +function buildOffOnlyThinkingProfile(): ResolvedThinkingProfile { + return { + levels: [{ id: "off", label: "off", rank: THINKING_LEVEL_RANKS.off }], + defaultLevel: "off", + }; +} + function buildBinaryThinkingProfile(defaultLevel?: ThinkLevel | null): ResolvedThinkingProfile { return { levels: [ @@ -159,6 +166,9 @@ export function resolveThinkingProfile(params: { modelId: context.modelId, reasoning: context.reasoning, }; + if (context.reasoning === false) { + return buildOffOnlyThinkingProfile(); + } const pluginProfile = resolveProviderThinkingProfile({ provider: context.normalizedProvider, context: providerContext, diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index d8136fdbee7..c09ba694a3a 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -119,6 +119,36 @@ describe("gateway startup log", () => { ).toBe("thinking=off, fast=on"); }); + it("shows thinking off for configured provider models with reasoning disabled", () => { + expect( + formatAgentModelStartupDetails({ + cfg: { + models: { + providers: { + google: { + api: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + models: [ + { + id: "gemma-4-26b-a4b-it", + name: "Gemma 4 26B", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 8_192, + }, + ], + }, + }, + }, + }, + provider: "google", + model: "gemma-4-26b-a4b-it", + }), + ).toBe("thinking=off, fast=off"); + }); + it("uses default agent mode overrides in the startup model details", () => { expect( formatAgentModelStartupDetails({ diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index 1e355072394..e8990575ba5 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -3,6 +3,7 @@ import { resolveDefaultAgentId, resolveAgentConfig } from "../agents/agent-scope import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; import { + buildConfiguredModelCatalog, resolveConfiguredModelRef, resolveThinkingDefault, legacyModelKey, @@ -98,6 +99,17 @@ function resolveExplicitStartupThinking(params: { ); } +function isConfiguredReasoningDisabled(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): boolean { + return buildConfiguredModelCatalog({ cfg: params.cfg }).some( + (entry) => + entry.provider === params.provider && entry.id === params.model && entry.reasoning === false, + ); +} + export function formatAgentModelStartupDetails(params: { cfg: OpenClawConfig; provider: string; @@ -118,7 +130,13 @@ export function formatAgentModelStartupDetails(params: { provider: params.provider, model: params.model, }); - const thinking = explicitThinking ?? (resolvedThinking === "off" ? "medium" : resolvedThinking); + const thinking = + explicitThinking ?? + (isConfiguredReasoningDisabled(params) + ? "off" + : resolvedThinking === "off" + ? "medium" + : resolvedThinking); const fast = resolveFastModeState({ cfg: params.cfg, provider: params.provider, diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index 1aa74e9a02e..b45c091c6f0 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -304,6 +304,7 @@ function isExtensionTestOrSupportPath(repoRelativePath: string): boolean { /(?:^|\/)test-support\.[cm]?tsx?$/.test(repoRelativePath) || /(?:^|\/)test-helpers\.[cm]?tsx?$/.test(repoRelativePath) || /(?:^|\/)test-harness\.[cm]?tsx?$/.test(repoRelativePath) || + /(?:^|\/)test-runtime\.[cm]?tsx?$/.test(repoRelativePath) || /\.test-support\.[cm]?tsx?$/.test(repoRelativePath) || /\.test-helpers\.[cm]?tsx?$/.test(repoRelativePath) || /\.test-harness\.[cm]?tsx?$/.test(repoRelativePath) || diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index bfda709e3bf..221393cc770 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2411,7 +2411,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } satisfies PluginRuntime["state"]; } if (prop === "config") { - const config = Reflect.get(target, prop, receiver); + const config: PluginRuntime["config"] = Reflect.get(target, prop, receiver); return { ...config, current: () => runWithPluginScope(() => config.current()),