mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 05:20:23 +00:00
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:
@@ -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}.` },
|
||||
|
||||
@@ -169,6 +169,7 @@ export async function buildStatusReply(params: {
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
agentId: statusAgentId,
|
||||
sessionEntry,
|
||||
}).enabled;
|
||||
const statusText = buildStatusMessage({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -517,6 +517,7 @@ export async function runPreparedReply(
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
agentId,
|
||||
sessionEntry,
|
||||
}).enabled,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user