Files
openclaw/src/auto-reply/reply/directive-handling.persist.ts
Liu Yuan 97b3ee7ec0 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 16:08:15 -08:00

243 lines
8.2 KiB
TypeScript

import type { OpenClawConfig } from "../../config/config.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
import {
resolveAgentDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import {
buildModelAliasIndex,
type ModelAliasIndex,
modelKey,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { resolveProfileOverride } from "./directive-handling.auth.js";
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
export async function persistInlineDirectives(params: {
directives: InlineDirectives;
effectiveModelDirective?: string;
cfg: OpenClawConfig;
agentDir?: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
elevatedEnabled: boolean;
elevatedAllowed: boolean;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
provider: string;
model: string;
initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string;
agentCfg: NonNullable<OpenClawConfig["agents"]>["defaults"] | undefined;
}): Promise<{ provider: string; model: string; contextTokens: number }> {
const {
directives,
cfg,
sessionEntry,
sessionStore,
sessionKey,
storePath,
elevatedEnabled,
elevatedAllowed,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
initialModelLabel,
formatModelSwitchEvent,
agentCfg,
} = params;
let { provider, model } = params;
const activeAgentId = sessionKey
? resolveSessionAgentId({ sessionKey, config: cfg })
: resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, activeAgentId);
if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel =
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel = (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
let updated = false;
if (directives.hasThinkDirective && directives.thinkLevel) {
sessionEntry.thinkingLevel = directives.thinkLevel;
updated = true;
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
updated = true;
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") {
delete sessionEntry.reasoningLevel;
} else {
sessionEntry.reasoningLevel = directives.reasoningLevel;
}
reasoningChanged =
reasoningChanged ||
(directives.reasoningLevel !== prevReasoningLevel &&
directives.reasoningLevel !== undefined);
updated = true;
}
if (
directives.hasElevatedDirective &&
directives.elevatedLevel &&
elevatedEnabled &&
elevatedAllowed
) {
// Persist "off" explicitly so inline `/elevated off` overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
updated = true;
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
updated = true;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
updated = true;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
updated = true;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
updated = true;
}
}
const modelDirective =
directives.hasModelDirective && params.effectiveModelDirective
? params.effectiveModelDirective
: undefined;
if (modelDirective) {
const resolved = resolveModelRefFromString({
raw: modelDirective,
defaultProvider,
aliasIndex,
});
if (resolved) {
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
let profileOverride: string | undefined;
if (directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: resolved.ref.provider,
cfg,
agentDir,
});
if (profileResolved.error) {
throw new Error(profileResolved.error);
}
profileOverride = profileResolved.profileId;
}
const isDefault =
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
const { updated: modelUpdated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
},
profileOverride,
});
provider = resolved.ref.provider;
model = resolved.ref.model;
const nextLabel = `${provider}/${model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, resolved.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
updated = updated || modelUpdated;
}
}
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
updated = true;
}
if (updated) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
});
}
}
}
return {
provider,
model,
contextTokens: agentCfg?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS,
};
}
export function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: string }): {
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
} {
const mainModel = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider,
});
return { defaultProvider, defaultModel, aliasIndex };
}