From c96a12aeb9e82fa3d40436ac5e04d4f0bd966cd6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 21 Mar 2026 22:27:24 -0700 Subject: [PATCH] Agents: add per-agent defaults and safe model fallback (#51974) * Agents: add per-agent defaults and safe model fallback * Docs: add per-agent thinking/reasoning/fast defaults to config reference and thinking docs * Format get-reply directives * Auto-reply: guard agent reasoning defaults * Docs: update config baseline --- CHANGELOG.md | 1 + docs/.generated/config-baseline.json | 56 ++++++++++++++ docs/.generated/config-baseline.jsonl | 5 +- docs/gateway/configuration-examples.md | 14 ++++ docs/gateway/configuration-reference.md | 6 ++ docs/tools/thinking.md | 11 ++- src/agents/agent-scope.ts | 6 ++ src/agents/fast-mode.test.ts | 67 +++++++++++++++++ src/agents/fast-mode.ts | 12 ++- src/auto-reply/reply/commands-session.ts | 9 ++- src/auto-reply/reply/commands-status.ts | 1 + .../reply/directive-handling.impl.ts | 1 + .../reply/directive-handling.levels.test.ts | 74 +++++++++++++++++++ .../reply/directive-handling.levels.ts | 16 +++- .../reply/get-reply-directives-apply.ts | 15 ++++ src/auto-reply/reply/get-reply-directives.ts | 15 +++- src/auto-reply/reply/get-reply-run.ts | 1 + src/auto-reply/reply/model-selection.test.ts | 74 ++++++++++++++++++- src/auto-reply/reply/model-selection.ts | 8 +- src/config/schema.help.ts | 6 ++ src/config/schema.labels.ts | 3 + src/config/types.agents.ts | 6 ++ src/config/zod-schema.agent-runtime.ts | 5 ++ src/cron/isolated-agent/run.ts | 1 + 24 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 src/agents/fast-mode.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4bbe95b06..69d449f6c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai - Models/OpenAI: switch the default OpenAI setup model to `openai/gpt-5.4`, keep Codex on `openai-codex/gpt-5.4`, and centralize OpenAI chat, image, TTS, transcription, and embedding defaults in one shared module so future default-model updates stay low-churn. Thanks @vincentkoc. - Memory/plugins: let the active memory plugin register its own system-prompt section while preserving cache-clear and snapshot-load prompt isolation. (#40126) Thanks @jarimustonen. - Control UI/usage: improve usage overview styling, localization, and responsive chat/context-notice presentation, including safer theme color handling and unclipped usage-header menus. (#51951) Thanks @BunsDev. +- Agents: add per-agent thinking/reasoning/fast defaults and auto-revert disallowed model overrides to the agent's default selection. Thanks @xuanmingguo and @vincentkoc. - Control UI/usage: drop the empty session-detail placeholder card so the usage view stays single-column until a real session detail panel is selected. (#52013) Thanks @BunsDev. ### Fixes diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index e27abe104c3..f91e555a124 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -4111,6 +4111,20 @@ "tags": [], "hasChildren": false }, + { + "path": "agents.list.*.fastModeDefault", + "kind": "core", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Agent Fast Mode Default", + "help": "Optional per-agent default for fast mode. Applies when no per-message or session fast-mode override is set.", + "hasChildren": false + }, { "path": "agents.list.*.groupChat", "kind": "core", @@ -5226,6 +5240,25 @@ "tags": [], "hasChildren": false }, + { + "path": "agents.list.*.reasoningDefault", + "kind": "core", + "type": "string", + "required": false, + "enumValues": [ + "on", + "off", + "stream" + ], + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Agent Reasoning Default", + "help": "Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.", + "hasChildren": false + }, { "path": "agents.list.*.runtime", "kind": "core", @@ -6287,6 +6320,29 @@ "tags": [], "hasChildren": false }, + { + "path": "agents.list.*.thinkingDefault", + "kind": "core", + "type": "string", + "required": false, + "enumValues": [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "adaptive" + ], + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Agent Thinking Default", + "help": "Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.", + "hasChildren": false + }, { "path": "agents.list.*.tools", "kind": "core", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 824ec874ab2..c86c65e6d76 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5563} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5566} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -354,6 +354,7 @@ {"recordType":"path","path":"agents.list.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.agentDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.default","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"agents.list.*.fastModeDefault","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Fast Mode Default","help":"Optional per-agent default for fast mode. Applies when no per-message or session fast-mode override is set.","hasChildren":false} {"recordType":"path","path":"agents.list.*.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -463,6 +464,7 @@ {"recordType":"path","path":"agents.list.*.name","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.params","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.params.*","kind":"core","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"agents.list.*.reasoningDefault","kind":"core","type":"string","required":false,"enumValues":["on","off","stream"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Reasoning Default","help":"Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.","hasChildren":false} {"recordType":"path","path":"agents.list.*.runtime","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Runtime","help":"Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.","hasChildren":true} {"recordType":"path","path":"agents.list.*.runtime.acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Runtime","help":"ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.","hasChildren":true} {"recordType":"path","path":"agents.list.*.runtime.acp.agent","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Harness Agent","help":"Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).","hasChildren":false} @@ -561,6 +563,7 @@ {"recordType":"path","path":"agents.list.*.subagents.model.fallbacks.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.subagents.model.primary","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.subagents.thinking","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"agents.list.*.thinkingDefault","kind":"core","type":"string","required":false,"enumValues":["off","minimal","low","medium","high","xhigh","adaptive"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Thinking Default","help":"Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.","hasChildren":false} {"recordType":"path","path":"agents.list.*.tools","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.tools.allow","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.tools.allow.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index e412f5b9d91..f279cb23e57 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -303,6 +303,20 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. }, }, }, + list: [ + { + id: "main", + default: true, + thinkingDefault: "high", // per-agent thinking override + reasoningDefault: "on", // per-agent reasoning visibility + fastModeDefault: false, // per-agent fast mode + }, + { + id: "quick", + fastModeDefault: true, // this agent always runs fast + thinkingDefault: "off", + }, + ], }, tools: { diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 11ea717513a..0e21e35538f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1372,6 +1372,9 @@ scripts/sandbox-browser-setup.sh # optional browser image workspace: "~/.openclaw/workspace", agentDir: "~/.openclaw/agents/main/agent", model: "anthropic/claude-opus-4-6", // or { primary, fallbacks } + thinkingDefault: "high", // per-agent thinking level override + reasoningDefault: "on", // per-agent reasoning visibility override + fastModeDefault: false, // per-agent fast mode override params: { cacheRetention: "none" }, // overrides matching defaults.models params by key identity: { name: "Samantha", @@ -1407,6 +1410,9 @@ scripts/sandbox-browser-setup.sh # optional browser image - `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default. - `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. - `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog. +- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set. +- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set. +- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set. - `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions. - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 045911c92b2..c8f49cc0706 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -28,8 +28,9 @@ title: "Thinking Levels" 1. Inline directive on the message (applies only to that message). 2. Session override (set by sending a directive-only message). -3. Global default (`agents.defaults.thinkingDefault` in config). -4. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise. +3. Per-agent default (`agents.list[].thinkingDefault` in config). +4. Global default (`agents.defaults.thinkingDefault` in config). +5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise. ## Setting a session default @@ -50,8 +51,9 @@ title: "Thinking Levels" - OpenClaw resolves fast mode in this order: 1. Inline/directive-only `/fast on|off` 2. Session override - 3. Per-model config: `agents.defaults.models["/"].params.fastMode` - 4. Fallback: `off` + 3. Per-agent default (`agents.list[].fastModeDefault`) + 4. Per-model config: `agents.defaults.models["/"].params.fastMode` + 5. Fallback: `off` - For `openai/*`, fast mode applies the OpenAI fast profile: `service_tier=priority` when supported, plus low reasoning effort and low text verbosity. - For `openai-codex/*`, fast mode applies the same low-latency profile on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths. - For direct `anthropic/*` API-key requests, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`. @@ -76,6 +78,7 @@ title: "Thinking Levels" - `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning. - Alias: `/reason`. - Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level. +- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then fallback (`off`). ## Related diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5425b033dca..e895128f460 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -30,6 +30,9 @@ type ResolvedAgentConfig = { workspace?: string; agentDir?: string; model?: AgentEntry["model"]; + thinkingDefault?: AgentEntry["thinkingDefault"]; + reasoningDefault?: AgentEntry["reasoningDefault"]; + fastModeDefault?: AgentEntry["fastModeDefault"]; skills?: AgentEntry["skills"]; memorySearch?: AgentEntry["memorySearch"]; humanDelay?: AgentEntry["humanDelay"]; @@ -132,6 +135,9 @@ export function resolveAgentConfig( typeof entry.model === "string" || (entry.model && typeof entry.model === "object") ? entry.model : undefined, + thinkingDefault: entry.thinkingDefault, + reasoningDefault: entry.reasoningDefault, + fastModeDefault: entry.fastModeDefault, skills: Array.isArray(entry.skills) ? entry.skills : undefined, memorySearch: entry.memorySearch, humanDelay: entry.humanDelay, diff --git a/src/agents/fast-mode.test.ts b/src/agents/fast-mode.test.ts new file mode 100644 index 00000000000..e4ec70edd22 --- /dev/null +++ b/src/agents/fast-mode.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveFastModeState } from "./fast-mode.js"; + +describe("resolveFastModeState", () => { + it("prefers session overrides", () => { + const state = resolveFastModeState({ + cfg: {} as OpenClawConfig, + provider: "openai", + model: "gpt-4o", + sessionEntry: { fastMode: true }, + }); + + expect(state.enabled).toBe(true); + expect(state.source).toBe("session"); + }); + + it("uses agent fastModeDefault when present", () => { + const cfg = { + agents: { + list: [{ id: "alpha", fastModeDefault: true }], + }, + } as OpenClawConfig; + + const state = resolveFastModeState({ + cfg, + provider: "openai", + model: "gpt-4o", + agentId: "alpha", + }); + + expect(state.enabled).toBe(true); + expect(state.source).toBe("agent"); + }); + + it("falls back to model config when agent default is absent", () => { + const cfg = { + agents: { + defaults: { + models: { + "openai/gpt-4o": { params: { fastMode: true } }, + }, + }, + }, + } as OpenClawConfig; + + const state = resolveFastModeState({ + cfg, + provider: "openai", + model: "gpt-4o", + }); + + expect(state.enabled).toBe(true); + expect(state.source).toBe("config"); + }); + + it("defaults to off when unset", () => { + const state = resolveFastModeState({ + cfg: {} as OpenClawConfig, + provider: "openai", + model: "gpt-4o", + }); + + expect(state.enabled).toBe(false); + expect(state.source).toBe("default"); + }); +}); diff --git a/src/agents/fast-mode.ts b/src/agents/fast-mode.ts index f3f96090cf6..4b238e088bd 100644 --- a/src/agents/fast-mode.ts +++ b/src/agents/fast-mode.ts @@ -1,10 +1,11 @@ import { normalizeFastMode } from "../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; +import { resolveAgentConfig } from "./agent-scope.js"; export type FastModeState = { enabled: boolean; - source: "session" | "config" | "default"; + source: "session" | "agent" | "config" | "default"; }; export function resolveFastModeParam( @@ -41,6 +42,7 @@ export function resolveFastModeState(params: { cfg: OpenClawConfig | undefined; provider: string; model: string; + agentId?: string; sessionEntry?: Pick | undefined; }): FastModeState { const sessionOverride = normalizeFastMode(params.sessionEntry?.fastMode); @@ -48,6 +50,14 @@ export function resolveFastModeState(params: { return { enabled: sessionOverride, source: "session" }; } + const agentDefault = + params.agentId && params.cfg + ? resolveAgentConfig(params.cfg, params.agentId)?.fastModeDefault + : undefined; + if (typeof agentDefault === "boolean") { + return { enabled: agentDefault, source: "agent" }; + } + const configuredRaw = resolveConfiguredFastModeRaw(params); const configured = normalizeFastMode(configuredRaw as string | boolean | null | undefined); if (configured !== undefined) { diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 29f85050a43..d897a56db02 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -357,10 +357,17 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand cfg: params.cfg, provider: params.provider, model: params.model, + agentId: params.agentId, sessionEntry: params.sessionEntry, }); const suffix = - state.source === "config" ? " (config)" : state.source === "default" ? " (default)" : ""; + state.source === "agent" + ? " (agent)" + : state.source === "config" + ? " (config)" + : state.source === "default" + ? " (default)" + : ""; return { shouldContinue: false, reply: { text: `⚙️ Current fast mode: ${state.enabled ? "on" : "off"}${suffix}.` }, diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index f802a7c6050..6391d9e9d1a 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -169,6 +169,7 @@ export async function buildStatusReply(params: { cfg, provider, model, + agentId: statusAgentId, sessionEntry, }).enabled; const statusText = buildStatusMessage({ diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index a994a3ccea6..423f54d6fb6 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -137,6 +137,7 @@ export async function handleDirectiveOnly( cfg: params.cfg, provider: resolvedProvider, model: resolvedModel, + agentId: activeAgentId, sessionEntry, }); const effectiveFastMode = directives.fastMode ?? currentFastMode ?? fastModeState.enabled; diff --git a/src/auto-reply/reply/directive-handling.levels.test.ts b/src/auto-reply/reply/directive-handling.levels.test.ts index 204d2685005..b47fa247f2b 100644 --- a/src/auto-reply/reply/directive-handling.levels.test.ts +++ b/src/auto-reply/reply/directive-handling.levels.test.ts @@ -33,4 +33,78 @@ describe("resolveCurrentDirectiveLevels", () => { expect(result.currentThinkLevel).toBe("minimal"); expect(resolveDefaultThinkingLevel).not.toHaveBeenCalled(); }); + + it("prefers session fastMode over agent default", async () => { + const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue("low"); + + const result = await resolveCurrentDirectiveLevels({ + sessionEntry: { + fastMode: true, + }, + agentEntry: { + fastModeDefault: false, + }, + resolveDefaultThinkingLevel, + }); + + expect(result.currentFastMode).toBe(true); + }); + + it("falls back to agent fastModeDefault when session override is absent", async () => { + const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue("low"); + + const result = await resolveCurrentDirectiveLevels({ + sessionEntry: {}, + agentEntry: { + fastModeDefault: true, + }, + resolveDefaultThinkingLevel, + }); + + expect(result.currentFastMode).toBe(true); + }); + + it("prefers session reasoningLevel over agent default", async () => { + const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue("low"); + + const result = await resolveCurrentDirectiveLevels({ + sessionEntry: { + reasoningLevel: "on", + }, + agentEntry: { + reasoningDefault: "off", + }, + resolveDefaultThinkingLevel, + }); + + expect(result.currentReasoningLevel).toBe("on"); + }); + + it("falls back to agent reasoningDefault when session override is absent", async () => { + const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue("off"); + + const result = await resolveCurrentDirectiveLevels({ + sessionEntry: {}, + agentEntry: { + reasoningDefault: "stream", + }, + resolveDefaultThinkingLevel, + }); + + expect(result.currentReasoningLevel).toBe("stream"); + }); + + it("skips agent reasoningDefault when thinking is active", async () => { + const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue("low"); + + const result = await resolveCurrentDirectiveLevels({ + sessionEntry: {}, + agentEntry: { + reasoningDefault: "stream", + }, + resolveDefaultThinkingLevel, + }); + + expect(result.currentReasoningLevel).toBe("off"); + }); }); diff --git a/src/auto-reply/reply/directive-handling.levels.ts b/src/auto-reply/reply/directive-handling.levels.ts index b62e77c3501..42f9fcc224a 100644 --- a/src/auto-reply/reply/directive-handling.levels.ts +++ b/src/auto-reply/reply/directive-handling.levels.ts @@ -8,6 +8,10 @@ export async function resolveCurrentDirectiveLevels(params: { reasoningLevel?: unknown; elevatedLevel?: unknown; }; + agentEntry?: { + fastModeDefault?: unknown; + reasoningDefault?: unknown; + }; agentCfg?: { thinkingDefault?: unknown; verboseDefault?: unknown; @@ -27,12 +31,20 @@ export async function resolveCurrentDirectiveLevels(params: { (params.agentCfg?.thinkingDefault as ThinkLevel | undefined); const currentThinkLevel = resolvedDefaultThinkLevel; const currentFastMode = - typeof params.sessionEntry?.fastMode === "boolean" ? params.sessionEntry.fastMode : undefined; + typeof params.sessionEntry?.fastMode === "boolean" + ? params.sessionEntry.fastMode + : typeof params.agentEntry?.fastModeDefault === "boolean" + ? params.agentEntry.fastModeDefault + : undefined; const currentVerboseLevel = (params.sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (params.agentCfg?.verboseDefault as VerboseLevel | undefined); + const sessionReasoningLevel = params.sessionEntry?.reasoningLevel as ReasoningLevel | undefined; const currentReasoningLevel = - (params.sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; + sessionReasoningLevel ?? + (currentThinkLevel === "off" + ? ((params.agentEntry?.reasoningDefault as ReasoningLevel | undefined) ?? "off") + : "off"); const currentElevatedLevel = (params.sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? (params.agentCfg?.elevatedDefault as ElevatedLevel | undefined); diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 741e791b8c5..c6c1ab7650a 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; import type { MsgContext } from "../templating.js"; import type { ElevatedLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; @@ -11,6 +12,7 @@ import type { createModelSelectionState } from "./model-selection.js"; import type { TypingController } from "./typing.js"; type AgentDefaults = NonNullable["defaults"]; +type AgentEntry = NonNullable["list"]>[number]; let commandsStatusPromise: Promise | null = null; let directiveLevelsPromise: Promise | null = null; @@ -69,6 +71,7 @@ export async function applyInlineDirectiveOverrides(params: { agentId: string; agentDir: string; agentCfg: AgentDefaults; + agentEntry?: AgentEntry; sessionEntry: SessionEntry; sessionStore: Record; sessionKey: string; @@ -102,6 +105,7 @@ export async function applyInlineDirectiveOverrides(params: { agentId, agentDir, agentCfg, + agentEntry, sessionEntry, sessionStore, sessionKey, @@ -156,6 +160,16 @@ export async function applyInlineDirectiveOverrides(params: { let directiveAck: ReplyPayload | undefined; + if (modelState.resetModelOverride) { + enqueueSystemEvent( + `Model override not allowed for this agent; reverted to ${initialModelLabel}.`, + { + sessionKey, + contextKey: `model:reset:${initialModelLabel}`, + }, + ); + } + if (!command.isAuthorizedSender) { directives = clearInlineDirectives(directives.cleaned); } @@ -184,6 +198,7 @@ export async function applyInlineDirectiveOverrides(params: { await loadDirectiveLevels() ).resolveCurrentDirectiveLevels({ sessionEntry, + agentEntry, agentCfg, resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), }); diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 23c178d56cd..f1ed8ba4072 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -1,3 +1,4 @@ +import { listAgentEntries } from "../../agents/agent-scope.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; @@ -5,6 +6,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status import type { SkillCommandSpec } from "../../agents/skills.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { shouldHandleTextCommands } from "../commands-text-routing.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; @@ -167,6 +169,9 @@ export async function resolveReplyDirectives(params: { opts, skillFilter, } = params; + const agentEntry = listAgentEntries(cfg).find( + (entry) => normalizeAgentId(entry.id) === normalizeAgentId(agentId), + ); let provider = initialProvider; let model = initialModel; @@ -387,6 +392,7 @@ export async function resolveReplyDirectives(params: { cfg, provider, model, + agentId, sessionEntry, }).enabled; @@ -398,6 +404,8 @@ export async function resolveReplyDirectives(params: { directives.reasoningLevel ?? (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; + const agentReasoningDefault = agentEntry?.reasoningDefault as ReasoningLevel | undefined; + const hasAgentReasoningDefault = agentReasoningDefault !== undefined; const resolvedElevatedLevel = elevatedAllowed ? (directives.elevatedLevel ?? (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? @@ -458,7 +466,11 @@ export async function resolveReplyDirectives(params: { (sessionEntry?.reasoningLevel !== undefined && sessionEntry?.reasoningLevel !== null); const thinkingActive = resolvedThinkLevelWithDefault !== "off"; if (!reasoningExplicitlySet && resolvedReasoningLevel === "off" && !thinkingActive) { - resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel(); + if (hasAgentReasoningDefault) { + resolvedReasoningLevel = agentReasoningDefault; + } else { + resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel(); + } } logDirectiveStage("reasoning-default-resolved", `reasoning=${resolvedReasoningLevel}`); @@ -483,6 +495,7 @@ export async function resolveReplyDirectives(params: { agentId, agentDir, agentCfg, + agentEntry, sessionEntry, sessionStore, sessionKey, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 4e6c4c9f4a9..71de418e623 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -517,6 +517,7 @@ export async function runPreparedReply( cfg, provider, model, + agentId, sessionEntry, }).enabled, verboseLevel: resolvedVerboseLevel, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index eeff1851786..f0599cecc66 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.js"; import { createModelSelectionState } from "./model-selection.js"; vi.mock("../../agents/model-catalog.js", () => ({ @@ -64,6 +65,41 @@ describe("createModelSelectionState catalog loading", () => { expect(loadModelCatalog).not.toHaveBeenCalled(); }); + it("prefers per-agent thinkingDefault over model and global defaults", async () => { + vi.mocked(loadModelCatalog).mockClear(); + const cfg = { + agents: { + defaults: { + thinkingDefault: "low", + models: { + "openai-codex/gpt-5.4": { + params: { thinking: "high" }, + }, + }, + }, + list: [ + { + id: "alpha", + thinkingDefault: "minimal", + }, + ], + }, + } as OpenClawConfig; + + const state = await createModelSelectionState({ + cfg, + agentId: "alpha", + agentCfg: cfg.agents?.defaults, + defaultProvider: "openai-codex", + defaultModel: "gpt-5.4", + provider: "openai-codex", + model: "gpt-5.4", + hasModelDirective: false, + }); + + await expect(state.resolveDefaultThinkingLevel()).resolves.toBe("minimal"); + }); + it("loads the full catalog for explicit model directives", async () => { vi.mocked(loadModelCatalog).mockClear(); const cfg = { @@ -90,7 +126,7 @@ describe("createModelSelectionState catalog loading", () => { }); }); -const makeEntry = (overrides: Record = {}) => ({ +const makeEntry = (overrides: Partial = {}): SessionEntry => ({ sessionId: "session-id", updatedAt: Date.now(), ...overrides, @@ -380,6 +416,42 @@ describe("createModelSelectionState respects session model override", () => { expect(state.model).toBe("grok-4.20-reasoning"); expect(state.resetModelOverride).toBe(false); }); + + it("clears disallowed model overrides and falls back to the default", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-4o" }, + models: { + "openai/gpt-4o": {}, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:telegram:direct:1"; + const sessionEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o-mini", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "openai", + defaultModel: "gpt-4o", + provider: "openai", + model: "gpt-4o", + hasModelDirective: false, + }); + + expect(state.resetModelOverride).toBe(true); + expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); + }); }); describe("createModelSelectionState resolveDefaultReasoningLevel", () => { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index fc3483f774c..abce2b8b704 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -1,3 +1,4 @@ +import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; @@ -343,6 +344,7 @@ export async function createModelSelectionState(params: { let allowedModelCatalog: ModelCatalog = configuredModelCatalog; let modelCatalog: ModelCatalog | null = null; let resetModelOverride = false; + const agentEntry = params.agentId ? resolveAgentConfig(cfg, params.agentId) : undefined; if (needsModelCatalog) { modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); @@ -461,8 +463,12 @@ export async function createModelSelectionState(params: { model, catalog: catalogForThinking, }); + const agentThinkingDefault = agentEntry?.thinkingDefault as ThinkLevel | undefined; defaultThinkingLevel = - resolved ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? "off"; + agentThinkingDefault ?? + resolved ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? + "off"; return defaultThinkingLevel; }; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f562914dd64..44975c978a9 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -214,6 +214,12 @@ export const FIELD_HELP: Record = { "Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", "agents.list": "Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.", + "agents.list[].thinkingDefault": + "Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.", + "agents.list[].reasoningDefault": + "Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.", + "agents.list[].fastModeDefault": + "Optional per-agent default for fast mode. Applies when no per-message or session fast-mode override is set.", "agents.list[].runtime": "Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.", "agents.list[].runtime.type": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 659f3b94308..ced8a025129 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -63,6 +63,9 @@ export const FIELD_LABELS: Record = { "agents.list[].runtime.acp.backend": "Agent ACP Backend", "agents.list[].runtime.acp.mode": "Agent ACP Mode", "agents.list[].runtime.acp.cwd": "Agent ACP Working Directory", + "agents.list[].thinkingDefault": "Agent Thinking Default", + "agents.list[].reasoningDefault": "Agent Reasoning Default", + "agents.list[].fastModeDefault": "Agent Fast Mode Default", agents: "Agents", "agents.defaults": "Agent Defaults", "agents.list": "Agent List", diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..9f63c411f40 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -65,6 +65,12 @@ export type AgentConfig = { workspace?: string; agentDir?: string; model?: AgentModelConfig; + /** Optional per-agent default thinking level (overrides agents.defaults.thinkingDefault). */ + thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; + /** Optional per-agent default reasoning visibility. */ + reasoningDefault?: "on" | "off" | "stream"; + /** Optional per-agent default for fast mode. */ + fastModeDefault?: boolean; /** Optional allowlist of skills for this agent (omit = all skills; empty = none). */ skills?: string[]; memorySearch?: MemorySearchConfig; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 10f0f8637e9..4ceda9e3985 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -767,6 +767,11 @@ export const AgentEntrySchema = z workspace: z.string().optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), + thinkingDefault: z + .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"]) + .optional(), + reasoningDefault: z.enum(["on", "off", "stream"]).optional(), + fastModeDefault: z.boolean().optional(), skills: z.array(z.string()).optional(), memorySearch: MemorySearchSchema, humanDelay: HumanDelaySchema.optional(), diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index affde75d08f..7fde539b3e1 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -521,6 +521,7 @@ export async function runCronIsolatedAgentTurn(params: { cfg: cfgWithAgentDefaults, provider: providerOverride, model: modelOverride, + agentId, sessionEntry: cronSession.sessionEntry, }).enabled, verboseLevel: resolvedVerboseLevel,