Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Steinberger
b1a6dbd2e9 refactor(googlechat): guard API fetches and raw-fetch lint 2026-03-02 04:10:06 +00:00
Peter Steinberger
30ec0139a2 refactor(reasoning): unify thinking precedence resolution 2026-03-02 04:09:59 +00:00
Peter Steinberger
051fba6995 fix(agents): preserve thinking fallback with model defaults 2026-03-02 03:58:10 +00:00
Mark L
28d0576fd1 fix(agents): honor per-model thinking defaults 2026-03-02 03:55:41 +00:00
16 changed files with 609 additions and 173 deletions

View File

@@ -1,10 +1,12 @@
import crypto from "node:crypto";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { getGoogleChatAccessToken } from "./auth.js";
import type { GoogleChatReaction } from "./types.js";
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
const GOOGLE_CHAT_ALLOWED_HOSTNAMES = ["chat.googleapis.com"];
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
headers instanceof Headers
@@ -19,19 +21,49 @@ async function fetchJson<T>(
init: RequestInit,
): Promise<T> {
const token = await getGoogleChatAccessToken(account);
const res = await fetch(url, {
...init,
headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
...init,
headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
policy: { allowedHostnames: GOOGLE_CHAT_ALLOWED_HOSTNAMES },
auditContext: "googlechat.api.json",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Google Chat API ${response.status}: ${text || response.statusText}`);
}
return (await response.json()) as T;
} finally {
await release();
}
return (await res.json()) as T;
}
async function fetchAuthorized(
account: ResolvedGoogleChatAccount,
url: string,
init: RequestInit,
auditContext: string,
): Promise<{ response: Response; release: () => Promise<void> }> {
const token = await getGoogleChatAccessToken(account);
return await fetchWithSsrFGuard({
url,
init: {
...init,
headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`,
},
},
policy: { allowedHostnames: GOOGLE_CHAT_ALLOWED_HOSTNAMES },
auditContext,
});
}
async function fetchOk(
@@ -39,17 +71,14 @@ async function fetchOk(
url: string,
init: RequestInit,
): Promise<void> {
const token = await getGoogleChatAccessToken(account);
const res = await fetch(url, {
...init,
headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
const { response, release } = await fetchAuthorized(account, url, init, "googlechat.api.ok");
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Google Chat API ${response.status}: ${text || response.statusText}`);
}
} finally {
await release();
}
}
@@ -59,52 +88,54 @@ async function fetchBuffer(
init?: RequestInit,
options?: { maxBytes?: number },
): Promise<{ buffer: Buffer; contentType?: string }> {
const token = await getGoogleChatAccessToken(account);
const res = await fetch(url, {
...init,
headers: {
...headersToObject(init?.headers),
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
}
const maxBytes = options?.maxBytes;
const lengthHeader = res.headers.get("content-length");
if (maxBytes && lengthHeader) {
const length = Number(lengthHeader);
if (Number.isFinite(length) && length > maxBytes) {
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
const { response, release } = await fetchAuthorized(
account,
url,
init ?? {},
"googlechat.api.buffer",
);
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Google Chat API ${response.status}: ${text || response.statusText}`);
}
}
if (!maxBytes || !res.body) {
const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") ?? undefined;
const maxBytes = options?.maxBytes;
const lengthHeader = response.headers.get("content-length");
if (maxBytes && lengthHeader) {
const length = Number(lengthHeader);
if (Number.isFinite(length) && length > maxBytes) {
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
}
if (!maxBytes || !response.body) {
const buffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get("content-type") ?? undefined;
return { buffer, contentType };
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
total += value.length;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
chunks.push(Buffer.from(value));
}
const buffer = Buffer.concat(chunks, total);
const contentType = response.headers.get("content-type") ?? undefined;
return { buffer, contentType };
} finally {
await release();
}
const reader = res.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
total += value.length;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
chunks.push(Buffer.from(value));
}
const buffer = Buffer.concat(chunks, total);
const contentType = res.headers.get("content-type") ?? undefined;
return { buffer, contentType };
}
export async function sendGoogleChatMessage(params: {
@@ -183,26 +214,33 @@ export async function uploadGoogleChatAttachment(params: {
Buffer.from(footer, "utf8"),
]);
const token = await getGoogleChatAccessToken(account);
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": `multipart/related; boundary=${boundary}`,
const { response, release } = await fetchAuthorized(
account,
url,
{
method: "POST",
headers: {
"Content-Type": `multipart/related; boundary=${boundary}`,
},
body,
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
"googlechat.api.upload",
);
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Google Chat upload ${response.status}: ${text || response.statusText}`);
}
const payload = (await response.json()) as {
attachmentDataRef?: { attachmentUploadToken?: string };
};
return {
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
};
} finally {
await release();
}
const payload = (await res.json()) as {
attachmentDataRef?: { attachmentUploadToken?: string };
};
return {
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
};
}
export async function downloadGoogleChatMedia(params: {

View File

@@ -21,6 +21,9 @@ const sourceRoots = [
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
// Supports:
// - exact callsite: "path/to/file.ts:42"
// - file scope: "path/to/file.ts" or "path/to/file.ts:*"
const allowedRawFetchCallsites = new Set([
"extensions/bluebubbles/src/types.ts:131",
"extensions/feishu/src/streaming-card.ts:31",
@@ -32,10 +35,6 @@ const allowedRawFetchCallsites = new Set([
"extensions/google-gemini-cli-auth/oauth.ts:447",
"extensions/google-gemini-cli-auth/oauth.ts:507",
"extensions/google-gemini-cli-auth/oauth.ts:575",
"extensions/googlechat/src/api.ts:22",
"extensions/googlechat/src/api.ts:43",
"extensions/googlechat/src/api.ts:63",
"extensions/googlechat/src/api.ts:184",
"extensions/googlechat/src/auth.ts:82",
"extensions/matrix/src/directory-live.ts:41",
"extensions/matrix/src/matrix/client/config.ts:171",
@@ -65,6 +64,20 @@ const allowedRawFetchCallsites = new Set([
"src/slack/monitor/media.ts:108",
]);
function isAllowlistedRawFetchCallsite(callsite) {
if (allowedRawFetchCallsites.has(callsite)) {
return true;
}
const [filePath] = callsite.split(":");
if (!filePath) {
return false;
}
if (allowedRawFetchCallsites.has(filePath)) {
return true;
}
return allowedRawFetchCallsites.has(`${filePath}:*`);
}
function isTestLikeFile(filePath) {
return (
filePath.endsWith(".test.ts") ||
@@ -177,7 +190,7 @@ export async function main() {
const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
for (const line of findRawFetchCallLines(content, filePath)) {
const callsite = `${relPath}:${line}`;
if (allowedRawFetchCallsites.has(callsite)) {
if (isAllowlistedRawFetchCallsite(callsite)) {
continue;
}
violations.push(callsite);

View File

@@ -0,0 +1,15 @@
import type { ModelCatalogEntry } from "./model-catalog.js";
type RequiredModelCatalogFields = Pick<ModelCatalogEntry, "provider" | "id" | "name">;
export function makeModelCatalogEntry(
required: RequiredModelCatalogFields,
optional?: Omit<ModelCatalogEntry, keyof RequiredModelCatalogFields>,
): ModelCatalogEntry {
return {
provider: required.provider,
id: required.id,
name: required.name,
...optional,
};
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { makeModelCatalogEntry } from "./model-catalog.test-helpers.js";
import {
buildAllowedModelSet,
inferUniqueProviderFromConfiguredModels,
@@ -11,6 +12,9 @@ import {
modelKey,
resolveAllowedModelRef,
resolveConfiguredModelRef,
resolveModelThinkingDefault,
supportsReasoningModel,
resolveThinkingDefault,
resolveModelRefFromString,
} from "./model-selection.js";
@@ -256,8 +260,16 @@ describe("model-selection", () => {
} as OpenClawConfig;
const catalog = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
makeModelCatalogEntry({
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
}),
makeModelCatalogEntry({
provider: "openai",
id: "gpt-5.2",
name: "gpt-5.2",
}),
];
const result = buildAllowedModelSet({
@@ -269,7 +281,11 @@ describe("model-selection", () => {
expect(result.allowAny).toBe(false);
expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true);
expect(result.allowedCatalog).toEqual([
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" },
makeModelCatalogEntry({
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "claude-sonnet-4-6",
}),
]);
});
});
@@ -288,8 +304,16 @@ describe("model-selection", () => {
} as OpenClawConfig;
const catalog = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
makeModelCatalogEntry({
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
}),
makeModelCatalogEntry({
provider: "openai",
id: "gpt-5.2",
name: "gpt-5.2",
}),
];
const result = resolveAllowedModelRef({
@@ -470,6 +494,108 @@ describe("model-selection", () => {
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
});
});
describe("resolveThinkingDefault", () => {
it("prefers per-model params.thinking over global thinkingDefault", () => {
const cfg = {
agents: {
defaults: {
thinkingDefault: "low",
models: {
"anthropic/claude-opus-4-6": {
params: { thinking: "high" },
},
},
},
},
} as OpenClawConfig;
expect(
resolveThinkingDefault({
cfg,
provider: "anthropic",
model: "claude-opus-4-6",
catalog: [
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
reasoning: true,
},
],
}),
).toBe("high");
});
});
describe("supportsReasoningModel", () => {
it("detects reasoning support from provider/model catalog entry", () => {
expect(
supportsReasoningModel({
provider: "openrouter",
model: "x-ai/grok-4.1-fast",
catalog: [
{
provider: "openrouter",
id: "x-ai/grok-4.1-fast",
name: "Grok 4.1 Fast",
reasoning: true,
},
],
}),
).toBe(true);
});
it("returns false when model is missing from catalog", () => {
expect(
supportsReasoningModel({
provider: "openrouter",
model: "x-ai/grok-4.1-fast",
catalog: [],
}),
).toBe(false);
});
});
describe("resolveModelThinkingDefault", () => {
it("returns model-derived low thinking when reasoning is supported", () => {
const cfg = {} as OpenClawConfig;
expect(
resolveModelThinkingDefault({
cfg,
provider: "openrouter",
model: "x-ai/grok-4.1-fast",
catalog: [
{
provider: "openrouter",
id: "x-ai/grok-4.1-fast",
name: "Grok 4.1 Fast",
reasoning: true,
},
],
}),
).toBe("low");
});
it("returns undefined when no model-level default applies", () => {
const cfg = { agents: { defaults: { thinkingDefault: "high" } } } as OpenClawConfig;
expect(
resolveModelThinkingDefault({
cfg,
provider: "openai",
model: "gpt-4o-mini",
catalog: [
{
provider: "openai",
id: "gpt-4o-mini",
name: "GPT-4o mini",
reasoning: false,
},
],
}),
).toBeUndefined();
});
});
});
describe("normalizeModelSelection", () => {

View File

@@ -525,17 +525,61 @@ export function resolveThinkingDefault(params: {
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel {
const configured = params.cfg.agents?.defaults?.thinkingDefault;
if (configured) {
return configured;
}
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
return (
resolveModelThinkingDefault({
cfg: params.cfg,
provider: params.provider,
model: params.model,
catalog: params.catalog,
}) ??
params.cfg.agents?.defaults?.thinkingDefault ??
"off"
);
if (candidate?.reasoning) {
}
export function resolveModelThinkingDefault(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel | undefined {
const perModelThinking =
params.cfg.agents?.defaults?.models?.[modelKey(params.provider, params.model)]?.params
?.thinking;
if (
perModelThinking === "off" ||
perModelThinking === "minimal" ||
perModelThinking === "low" ||
perModelThinking === "medium" ||
perModelThinking === "high" ||
perModelThinking === "xhigh"
) {
return perModelThinking;
}
if (
supportsReasoningModel({
provider: params.provider,
model: params.model,
catalog: params.catalog,
})
) {
return "low";
}
return "off";
return undefined;
}
export function supportsReasoningModel(params: {
provider: string;
model: string;
catalog?: ModelCatalogEntry[];
}): boolean {
const key = modelKey(params.provider, params.model);
const candidate = params.catalog?.find(
(entry) =>
(entry.provider === params.provider && entry.id === params.model) ||
(entry.provider === key && entry.id === params.model),
);
return candidate?.reasoning === true;
}
/** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */
@@ -544,13 +588,7 @@ export function resolveReasoningDefault(params: {
model: string;
catalog?: ModelCatalogEntry[];
}): "on" | "off" {
const key = modelKey(params.provider, params.model);
const candidate = params.catalog?.find(
(entry) =>
(entry.provider === params.provider && entry.id === params.model) ||
(entry.provider === key && entry.id === params.model),
);
return candidate?.reasoning === true ? "on" : "off";
return supportsReasoningModel(params) ? "on" : "off";
}
/**

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js";
describe("resolveCurrentDirectiveLevels", () => {
it.each([
{
name: "uses session override first",
sessionEntry: { thinkingLevel: "minimal" },
agentCfg: { thinkingDefault: "low" },
modelDefault: "high",
expectedLevel: "minimal",
expectedModelCalls: 0,
},
{
name: "uses model default when no session override",
sessionEntry: {},
agentCfg: { thinkingDefault: "low" },
modelDefault: "high",
expectedLevel: "high",
expectedModelCalls: 1,
},
{
name: "falls back to global default when model default missing",
sessionEntry: {},
agentCfg: { thinkingDefault: "low" },
modelDefault: undefined,
expectedLevel: "low",
expectedModelCalls: 1,
},
{
name: "falls back to off when no defaults are set",
sessionEntry: {},
agentCfg: {},
modelDefault: undefined,
expectedLevel: "off",
expectedModelCalls: 1,
},
])("$name", async (testCase) => {
const resolveDefaultThinkingLevel = vi.fn().mockResolvedValue(testCase.modelDefault);
const result = await resolveCurrentDirectiveLevels({
sessionEntry: testCase.sessionEntry,
agentCfg: testCase.agentCfg,
resolveDefaultThinkingLevel,
});
expect(result.currentThinkLevel).toBe(testCase.expectedLevel);
expect(resolveDefaultThinkingLevel).toHaveBeenCalledTimes(testCase.expectedModelCalls);
});
});

View File

@@ -1,3 +1,4 @@
import { resolveThinkingLevelByPrecedence } from "../../sessions/thinking-level.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
export async function resolveCurrentDirectiveLevels(params: {
@@ -14,16 +15,18 @@ export async function resolveCurrentDirectiveLevels(params: {
};
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
}): Promise<{
currentThinkLevel: ThinkLevel | undefined;
currentThinkLevel: ThinkLevel;
currentVerboseLevel: VerboseLevel | undefined;
currentReasoningLevel: ReasoningLevel;
currentElevatedLevel: ElevatedLevel | undefined;
}> {
const resolvedDefaultThinkLevel =
(params.sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
(params.agentCfg?.thinkingDefault as ThinkLevel | undefined) ??
(await params.resolveDefaultThinkingLevel());
const currentThinkLevel = resolvedDefaultThinkLevel;
const currentThinkLevel = (
await resolveThinkingLevelByPrecedence({
sessionThinkLevel: params.sessionEntry?.thinkingLevel as ThinkLevel | undefined,
resolveModelDefaultThinkingLevel: params.resolveDefaultThinkingLevel,
globalDefaultThinkLevel: params.agentCfg?.thinkingDefault as ThinkLevel | undefined,
})
).level;
const currentVerboseLevel =
(params.sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
(params.agentCfg?.verboseDefault as VerboseLevel | undefined);

View File

@@ -4,6 +4,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { resolveThinkingLevelByPrecedence } from "../../sessions/thinking-level.js";
import { listChatCommands, shouldHandleTextCommands } from "../commands-registry.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import type { MsgContext, TemplateContext } from "../templating.js";
@@ -338,11 +339,6 @@ export async function resolveReplyDirectives(params: {
groupResolution,
});
const defaultActivation = defaultGroupActivation(requireMention);
const resolvedThinkLevel =
directives.thinkLevel ??
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
const resolvedVerboseLevel =
directives.verboseLevel ??
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
@@ -390,6 +386,14 @@ export async function resolveReplyDirectives(params: {
});
provider = modelState.provider;
model = modelState.model;
const resolvedThinkLevelWithDefault = (
await resolveThinkingLevelByPrecedence({
commandThinkLevel: directives.thinkLevel,
sessionThinkLevel: sessionEntry?.thinkingLevel as ThinkLevel | undefined,
resolveModelDefaultThinkingLevel: () => modelState.resolveModelDefaultThinkingLevel(),
globalDefaultThinkLevel: agentCfg?.thinkingDefault as ThinkLevel | undefined,
})
).level;
// When neither directive nor session set reasoning, default to model capability
// (e.g. OpenRouter with reasoning: true). Skip auto-enabling when thinking is
@@ -398,9 +402,7 @@ export async function resolveReplyDirectives(params: {
const reasoningExplicitlySet =
directives.reasoningLevel !== undefined ||
(sessionEntry?.reasoningLevel !== undefined && sessionEntry?.reasoningLevel !== null);
const effectiveThinkingForReasoning =
resolvedThinkLevel ?? (await modelState.resolveDefaultThinkingLevel());
const thinkingActive = effectiveThinkingForReasoning !== "off";
const thinkingActive = resolvedThinkLevelWithDefault !== "off";
if (!reasoningExplicitlySet && resolvedReasoningLevel === "off" && !thinkingActive) {
resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel();
}
@@ -477,7 +479,7 @@ export async function resolveReplyDirectives(params: {
elevatedAllowed,
elevatedFailures,
defaultActivation,
resolvedThinkLevel,
resolvedThinkLevel: resolvedThinkLevelWithDefault,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,

View File

@@ -1,14 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { makeModelCatalogEntry } from "../../agents/model-catalog.test-helpers.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createModelSelectionState } from "./model-selection.js";
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
{ provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" },
{ provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" },
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
makeModelCatalogEntry({
provider: "anthropic",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
}),
makeModelCatalogEntry({
provider: "inferencer",
id: "deepseek-v3-4bit-mlx",
name: "DeepSeek V3",
}),
makeModelCatalogEntry({ provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }),
makeModelCatalogEntry({ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }),
makeModelCatalogEntry({ provider: "openai", id: "gpt-4o", name: "GPT-4o" }),
]),
}));
@@ -269,7 +278,10 @@ describe("createModelSelectionState resolveDefaultReasoningLevel", () => {
it("returns on when catalog model has reasoning true", async () => {
const { loadModelCatalog } = await import("../../agents/model-catalog.js");
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{ provider: "openrouter", id: "x-ai/grok-4.1-fast", name: "Grok", reasoning: true },
makeModelCatalogEntry(
{ provider: "openrouter", id: "x-ai/grok-4.1-fast", name: "Grok" },
{ reasoning: true },
),
]);
const state = await createModelSelectionState({
cfg: {} as OpenClawConfig,

View File

@@ -7,9 +7,9 @@ import {
type ModelAliasIndex,
modelKey,
normalizeProviderId,
resolveModelThinkingDefault,
resolveModelRefFromString,
resolveReasoningDefault,
resolveThinkingDefault,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
@@ -32,6 +32,7 @@ type ModelSelectionState = {
allowedModelKeys: Set<string>;
allowedModelCatalog: ModelCatalog;
resetModelOverride: boolean;
resolveModelDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
resolveDefaultThinkingLevel: () => Promise<ThinkLevel>;
/** Default reasoning level from model capability: "on" if model has reasoning, else "off". */
resolveDefaultReasoningLevel: () => Promise<"on" | "off">;
@@ -379,25 +380,30 @@ export async function createModelSelectionState(params: {
}
}
let defaultThinkingLevel: ThinkLevel | undefined;
const resolveDefaultThinkingLevel = async () => {
if (defaultThinkingLevel) {
return defaultThinkingLevel;
let modelDefaultThinkingLevel: ThinkLevel | undefined;
let hasResolvedModelDefaultThinkingLevel = false;
const resolveModelDefaultThinkingLevel = async (): Promise<ThinkLevel | undefined> => {
if (hasResolvedModelDefaultThinkingLevel) {
return modelDefaultThinkingLevel;
}
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
if (!catalogForThinking || catalogForThinking.length === 0) {
modelCatalog = await loadModelCatalog({ config: cfg });
catalogForThinking = modelCatalog;
}
const resolved = resolveThinkingDefault({
modelDefaultThinkingLevel = resolveModelThinkingDefault({
cfg,
provider,
model,
catalog: catalogForThinking,
});
defaultThinkingLevel =
resolved ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? "off";
return defaultThinkingLevel;
hasResolvedModelDefaultThinkingLevel = true;
return modelDefaultThinkingLevel;
};
const resolveDefaultThinkingLevel = async () => {
const modelDefault = await resolveModelDefaultThinkingLevel();
return modelDefault ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? "off";
};
const resolveDefaultReasoningLevel = async (): Promise<"on" | "off"> => {
@@ -419,6 +425,7 @@ export async function createModelSelectionState(params: {
allowedModelKeys,
allowedModelCatalog,
resetModelOverride,
resolveModelDefaultThinkingLevel,
resolveDefaultThinkingLevel,
resolveDefaultReasoningLevel,
needsModelCatalog,

View File

@@ -734,6 +734,25 @@ describe("agentCommand", () => {
});
});
it("prefers per-model thinking over global thinkingDefault", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
thinkingDefault: "low",
models: {
"anthropic/claude-opus-4-5": {
params: { thinking: "high" },
},
},
});
await agentCommand({ message: "hi", to: "+1555" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.thinkLevel).toBe("high");
});
});
it("prints JSON payload when requested", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -30,7 +30,7 @@ import {
normalizeProviderId,
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
resolveThinkingDefault,
resolveModelThinkingDefault,
} from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
@@ -72,6 +72,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { resolveThinkingLevelByPrecedence } from "../sessions/thinking-level.js";
import { resolveMessageChannel } from "../utils/message-channel.js";
import { deliverAgentCommandResult } from "./agent/delivery.js";
import { resolveAgentRunContext } from "./agent/run-context.js";
@@ -588,11 +589,7 @@ export async function agentCommand(
});
}
let resolvedThinkLevel =
thinkOnce ??
thinkOverride ??
persistedThinking ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
const commandThinkLevel = thinkOnce ?? thinkOverride;
const resolvedVerboseLevel =
verboseOverride ?? persistedVerbose ?? (agentCfg?.verboseDefault as VerboseLevel | undefined);
@@ -748,21 +745,28 @@ export async function agentCommand(
}
}
if (!resolvedThinkLevel) {
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
if (!catalogForThinking || catalogForThinking.length === 0) {
modelCatalog = await loadModelCatalog({ config: cfg });
catalogForThinking = modelCatalog;
}
resolvedThinkLevel = resolveThinkingDefault({
cfg,
provider,
model,
catalog: catalogForThinking,
});
}
let resolvedThinkLevel = (
await resolveThinkingLevelByPrecedence({
commandThinkLevel,
sessionThinkLevel: persistedThinking,
resolveModelDefaultThinkingLevel: async () => {
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
if (!catalogForThinking || catalogForThinking.length === 0) {
modelCatalog = await loadModelCatalog({ config: cfg });
catalogForThinking = modelCatalog;
}
return resolveModelThinkingDefault({
cfg,
provider,
model,
catalog: catalogForThinking,
});
},
globalDefaultThinkLevel: cfg.agents?.defaults?.thinkingDefault as ThinkLevel | undefined,
})
).level;
if (resolvedThinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
const explicitThink = Boolean(thinkOnce || thinkOverride);
const explicitThink = Boolean(commandThinkLevel);
if (explicitThink) {
throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`);
}

View File

@@ -299,16 +299,15 @@ export async function runCronIsolatedAgentTurn(params: {
}
}
// Resolve thinking level - job thinking > hooks.gmail.thinking > agent default
// Resolve thinking level - job thinking > hooks.gmail.thinking > model/global defaults
const hooksGmailThinking = isGmailHook
? normalizeThinkLevel(params.cfg.hooks?.gmail?.thinking)
: undefined;
const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault);
const jobThink = normalizeThinkLevel(
(params.job.payload.kind === "agentTurn" ? params.job.payload.thinking : undefined) ??
undefined,
);
let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride;
let thinkLevel = jobThink ?? hooksGmailThinking;
if (!thinkLevel) {
thinkLevel = resolveThinkingDefault({
cfg: cfgWithAgentDefaults,

View File

@@ -574,20 +574,15 @@ export const chatHandlers: GatewayRequestHandlers = {
}
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agents?.defaults?.thinkingDefault;
if (configured) {
thinkingLevel = configured;
} else {
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
const { provider, model } = resolveSessionModelRef(cfg, entry, sessionAgentId);
const catalog = await context.loadGatewayModelCatalog();
thinkingLevel = resolveThinkingDefault({
cfg,
provider,
model,
catalog,
});
}
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
const { provider, model } = resolveSessionModelRef(cfg, entry, sessionAgentId);
const catalog = await context.loadGatewayModelCatalog();
thinkingLevel = resolveThinkingDefault({
cfg,
provider,
model,
catalog,
});
}
const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault;
respond(true, {

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import { resolveThinkingLevelByPrecedence } from "./thinking-level.js";
function createModelDefaultResolver(value: ThinkLevel | undefined) {
const fn = vi.fn().mockResolvedValue(value);
return {
fn,
resolve: () => fn() as Promise<ThinkLevel | undefined>,
};
}
describe("resolveThinkingLevelByPrecedence", () => {
it.each([
{
name: "command override wins",
commandThinkLevel: "high" as ThinkLevel,
sessionThinkLevel: "medium" as ThinkLevel,
modelDefault: "low" as ThinkLevel,
globalDefaultThinkLevel: "minimal" as ThinkLevel,
expected: { level: "high", source: "command" },
expectedModelCalls: 0,
},
{
name: "session override wins when command unset",
commandThinkLevel: null,
sessionThinkLevel: "medium" as ThinkLevel,
modelDefault: "low" as ThinkLevel,
globalDefaultThinkLevel: "minimal" as ThinkLevel,
expected: { level: "medium", source: "session" },
expectedModelCalls: 0,
},
{
name: "model default wins when command and session unset",
commandThinkLevel: undefined,
sessionThinkLevel: null,
modelDefault: "low" as ThinkLevel,
globalDefaultThinkLevel: "minimal" as ThinkLevel,
expected: { level: "low", source: "model_default" },
expectedModelCalls: 1,
},
{
name: "global default wins when model default missing",
commandThinkLevel: undefined,
sessionThinkLevel: undefined,
modelDefault: undefined,
globalDefaultThinkLevel: "minimal" as ThinkLevel,
expected: { level: "minimal", source: "global_default" },
expectedModelCalls: 1,
},
{
name: "disabled fallback when everything unset",
commandThinkLevel: undefined,
sessionThinkLevel: undefined,
modelDefault: undefined,
globalDefaultThinkLevel: null,
expected: { level: "off", source: "disabled" },
expectedModelCalls: 1,
},
])("$name", async (testCase) => {
const modelDefaultResolver = createModelDefaultResolver(testCase.modelDefault);
const result = await resolveThinkingLevelByPrecedence({
commandThinkLevel: testCase.commandThinkLevel,
sessionThinkLevel: testCase.sessionThinkLevel,
resolveModelDefaultThinkingLevel: modelDefaultResolver.resolve,
globalDefaultThinkLevel: testCase.globalDefaultThinkLevel,
});
expect(result).toEqual(testCase.expected);
expect(modelDefaultResolver.fn).toHaveBeenCalledTimes(testCase.expectedModelCalls);
});
it("supports custom disabled fallback level", async () => {
const result = await resolveThinkingLevelByPrecedence({
disabledThinkLevel: "minimal",
});
expect(result).toEqual({
level: "minimal",
source: "disabled",
});
});
});

View File

@@ -0,0 +1,33 @@
import type { ThinkLevel } from "../auto-reply/thinking.js";
export type ThinkingLevelSource =
| "command"
| "session"
| "model_default"
| "global_default"
| "disabled";
export async function resolveThinkingLevelByPrecedence(params: {
commandThinkLevel?: ThinkLevel | null;
sessionThinkLevel?: ThinkLevel | null;
resolveModelDefaultThinkingLevel?: () => Promise<ThinkLevel | undefined>;
globalDefaultThinkLevel?: ThinkLevel | null;
disabledThinkLevel?: ThinkLevel;
}): Promise<{ level: ThinkLevel; source: ThinkingLevelSource }> {
if (params.commandThinkLevel) {
return { level: params.commandThinkLevel, source: "command" };
}
if (params.sessionThinkLevel) {
return { level: params.sessionThinkLevel, source: "session" };
}
if (params.resolveModelDefaultThinkingLevel) {
const modelDefault = await params.resolveModelDefaultThinkingLevel();
if (modelDefault) {
return { level: modelDefault, source: "model_default" };
}
}
if (params.globalDefaultThinkLevel) {
return { level: params.globalDefaultThinkLevel, source: "global_default" };
}
return { level: params.disabledThinkLevel ?? "off", source: "disabled" };
}