Compare commits

...

2 Commits

Author SHA1 Message Date
George Pickett
e321cfbea0 Fix: honor /think off for reasoning-capable models (#9564) (thanks @liuy) 2026-02-09 15:48:24 -08:00
Liu Yuan
afbaab42ab Fix: Honor /think off for reasoning-capable models
Problem:
When users execute `/think off`, they still receive `reasoning_content`
from models configured with `reasoning: true` (e.g., GLM-4.7, GLM-4.6,
Kimi K2.5, MiniMax-M2.1).

Expected: `/think off` should completely disable reasoning content.
Actual: Reasoning content is still returned.

Root Cause:
The directive handlers delete `sessionEntry.thinkingLevel` when user
executes `/think off`. This causes the thinking level to become undefined,
and the system falls back to `resolveThinkingDefault()`, which checks the
model catalog and returns "low" for reasoning-capable models, ignoring the
user's explicit intent.

Why We Must Persist "off" (Design Rationale):

1. **Model-dependent defaults**: Unlike other directives where "off" means
   use a global default, `thinkingLevel` has model-dependent defaults:
   - Reasoning-capable models (GLM-4.7, etc.) → default "low"
   - Other models → default "off"

2. **Existing pattern**: The codebase already follows this pattern for
   `elevatedLevel`, which persists "off" explicitly to override defaults
   that may be "on". The comment explains:
   "Persist 'off' explicitly so `/elevated off` actually overrides defaults."

3. **User intent**: When a user explicitly executes `/think off`, they want
   to disable thinking regardless of the model's capabilities. Deleting the
   field breaks this intent by falling back to the model's default.

Solution:
Persist "off" value instead of deleting the field in all internal directive handlers:
- `src/auto-reply/reply/directive-handling.impl.ts`: Directive-only messages
- `src/auto-reply/reply/directive-handling.persist.ts`: Inline directives
- `src/commands/agent.ts`: CLI command-line flags

Gateway API Backward Compatibility:
The original implementation incorrectly mapped `null` to "off" in
`sessions-patch.ts` for consistency with internal handlers. This was a
breaking change because:
- Previously, `null` cleared the override (deleted the field)
- API clients lost the ability to "clear to default" via `null`
- This contradicts standard JSON semantics where `null` means "no value"

Restored original null semantics in `src/gateway/sessions-patch.ts`:
- `null` → delete field, fall back to model default (clear override)
- `"off"` → persist explicit override
- Other values → normalize and persist

This ensures backward compatibility for API clients while fixing the `/think off`
issue in internal handlers.

Signed-off-by: Liu Yuan <namei.unix@gmail.com>
2026-02-09 15:42:25 -08:00
7 changed files with 73 additions and 20 deletions

View File

@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.
- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo.
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.

View File

@@ -309,11 +309,7 @@ export async function handleDirectiveOnly(params: {
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") {
delete sessionEntry.thinkingLevel;
} else {
sessionEntry.thinkingLevel = directives.thinkLevel;
}
sessionEntry.thinkingLevel = directives.thinkLevel;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";

View File

@@ -161,4 +161,39 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text ?? "").not.toContain("Model set to");
expect(result?.text ?? "").not.toContain("failed");
});
it("persists thinkingLevel=off (does not clear)", async () => {
const directives = parseInlineDirectives("/think off");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
thinkingLevel: "low",
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text ?? "").not.toContain("failed");
expect(sessionEntry.thinkingLevel).toBe("off");
expect(sessionStore["agent:main:dm:1"]?.thinkingLevel).toBe("off");
});
});

View File

@@ -82,11 +82,7 @@ export async function persistInlineDirectives(params: {
let updated = false;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") {
delete sessionEntry.thinkingLevel;
} else {
sessionEntry.thinkingLevel = directives.thinkLevel;
}
sessionEntry.thinkingLevel = directives.thinkLevel;
updated = true;
}
if (directives.hasVerboseDirective && directives.verboseLevel) {

View File

@@ -222,11 +222,7 @@ export async function agentCommand(
sessionEntry ?? { sessionId, updatedAt: Date.now() };
const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() };
if (thinkOverride) {
if (thinkOverride === "off") {
delete next.thinkingLevel;
} else {
next.thinkingLevel = thinkOverride;
}
next.thinkingLevel = thinkOverride;
}
applyVerboseOverride(next, verboseOverride);
sessionStore[sessionKey] = next;

View File

@@ -4,6 +4,38 @@ import type { SessionEntry } from "../config/sessions.js";
import { applySessionsPatchToStore } from "./sessions-patch.js";
describe("gateway sessions patch", () => {
test("persists thinkingLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { thinkingLevel: "off" },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.thinkingLevel).toBe("off");
});
test("clears thinkingLevel when patch sets null", async () => {
const store: Record<string, SessionEntry> = {
"agent:main:main": { thinkingLevel: "low" } as SessionEntry,
};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { thinkingLevel: null },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.thinkingLevel).toBeUndefined();
});
test("persists elevatedLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({

View File

@@ -124,6 +124,7 @@ export async function applySessionsPatchToStore(params: {
if ("thinkingLevel" in patch) {
const raw = patch.thinkingLevel;
if (raw === null) {
// Clear the override and fall back to model default
delete next.thinkingLevel;
} else if (raw !== undefined) {
const normalized = normalizeThinkLevel(String(raw));
@@ -134,11 +135,7 @@ export async function applySessionsPatchToStore(params: {
`invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
);
}
if (normalized === "off") {
delete next.thinkingLevel;
} else {
next.thinkingLevel = normalized;
}
next.thinkingLevel = normalized;
}
}