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

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