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
This commit is contained in:
Vincent Koc
2026-03-21 22:27:24 -07:00
committed by GitHub
parent f783101735
commit c96a12aeb9
24 changed files with 401 additions and 12 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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}

View File

@@ -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: {

View File

@@ -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`.

View File

@@ -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["<provider>/<model>"].params.fastMode`
4. Fallback: `off`
3. Per-agent default (`agents.list[].fastModeDefault`)
4. Per-model config: `agents.defaults.models["<provider>/<model>"].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

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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<SessionEntry, "fastMode"> | 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) {

View File

@@ -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}.` },

View File

@@ -169,6 +169,7 @@ export async function buildStatusReply(params: {
cfg,
provider,
model,
agentId: statusAgentId,
sessionEntry,
}).enabled;
const statusText = buildStatusMessage({

View File

@@ -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;

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -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<OpenClawConfig["agents"]>["defaults"];
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
let commandsStatusPromise: Promise<typeof import("./commands-status.runtime.js")> | null = null;
let directiveLevelsPromise: Promise<typeof import("./directive-handling.levels.js")> | null = null;
@@ -69,6 +71,7 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string;
agentDir: string;
agentCfg: AgentDefaults;
agentEntry?: AgentEntry;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
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(),
});

View File

@@ -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,

View File

@@ -517,6 +517,7 @@ export async function runPreparedReply(
cfg,
provider,
model,
agentId,
sessionEntry,
}).enabled,
verboseLevel: resolvedVerboseLevel,

View File

@@ -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<string, unknown> = {}) => ({
const makeEntry = (overrides: Partial<SessionEntry> = {}): 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", () => {

View File

@@ -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;
};

View File

@@ -214,6 +214,12 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -63,6 +63,9 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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;

View File

@@ -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(),

View File

@@ -521,6 +521,7 @@ export async function runCronIsolatedAgentTurn(params: {
cfg: cfgWithAgentDefaults,
provider: providerOverride,
model: modelOverride,
agentId,
sessionEntry: cronSession.sessionEntry,
}).enabled,
verboseLevel: resolvedVerboseLevel,