feat: wire multi-agent config and routing

Co-authored-by: Mark Pors <1078320+pors@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-09 12:44:23 +00:00
parent 81beda0772
commit 7b81d97ec2
189 changed files with 4340 additions and 2903 deletions
+27 -32
View File
@@ -11,10 +11,8 @@ describe("resolveAgentConfig", () => {
it("should return undefined when agent id does not exist", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
const result = resolveAgentConfig(cfg, "nonexistent");
@@ -23,15 +21,16 @@ describe("resolveAgentConfig", () => {
it("should return basic agent config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main Agent",
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
},
},
],
},
};
const result = resolveAgentConfig(cfg, "main");
@@ -40,6 +39,9 @@ describe("resolveAgentConfig", () => {
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
identity: undefined,
groupChat: undefined,
subagents: undefined,
sandbox: undefined,
tools: undefined,
});
@@ -47,9 +49,10 @@ describe("resolveAgentConfig", () => {
it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -57,13 +60,9 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "work");
@@ -73,25 +72,22 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
});
});
it("should return agent-specific tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "restricted");
@@ -103,9 +99,10 @@ describe("resolveAgentConfig", () => {
it("should return both sandbox and tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
family: {
agents: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all",
@@ -116,7 +113,7 @@ describe("resolveAgentConfig", () => {
deny: ["bash"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "family");
@@ -126,10 +123,8 @@ describe("resolveAgentConfig", () => {
it("should normalize agent id", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
// Should normalize to "main" (default)
+57 -33
View File
@@ -11,6 +11,24 @@ import {
import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"];
sandbox?: AgentEntry["sandbox"];
tools?: AgentEntry["tools"];
};
let defaultAgentWarned = false;
export function resolveAgentIdFromSessionKey(
sessionKey?: string | null,
): string {
@@ -18,46 +36,51 @@ export function resolveAgentIdFromSessionKey(
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
}
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const agents = listAgents(cfg);
if (agents.length === 0) return DEFAULT_AGENT_ID;
const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true;
console.warn(
"Multiple agents marked default=true; using the first entry as default.",
);
}
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
}
function resolveAgentEntry(
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
export function resolveAgentConfig(
cfg: ClawdbotConfig,
agentId: string,
):
| {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
subagents?: {
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
workspaceAccess?: "none" | "ro" | "rw";
scope?: "session" | "agent" | "shared";
perSession?: boolean;
workspaceRoot?: string;
tools?: {
allow?: string[];
deny?: string[];
};
};
tools?: {
allow?: string[];
deny?: string[];
};
}
| undefined {
): ResolvedAgentConfig | undefined {
const id = normalizeAgentId(agentId);
const agents = cfg.routing?.agents;
if (!agents || typeof agents !== "object") return undefined;
const entry = agents[id];
if (!entry || typeof entry !== "object") return undefined;
const entry = resolveAgentEntry(cfg, id);
if (!entry) return undefined;
return {
name: typeof entry.name === "string" ? entry.name : undefined,
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
identity: entry.identity,
groupChat: entry.groupChat,
subagents:
typeof entry.subagents === "object" && entry.subagents
? entry.subagents
@@ -71,9 +94,10 @@ export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
if (configured) return resolveUserPath(configured);
if (id === DEFAULT_AGENT_ID) {
const legacy = cfg.agent?.workspace?.trim();
if (legacy) return resolveUserPath(legacy);
const defaultAgentId = resolveDefaultAgentId(cfg);
if (id === defaultAgentId) {
const fallback = cfg.agents?.defaults?.workspace?.trim();
if (fallback) return resolveUserPath(fallback);
return DEFAULT_AGENT_WORKSPACE_DIR;
}
return path.join(os.homedir(), `clawd-${id}`);
+4 -1
View File
@@ -925,7 +925,10 @@ export function resolveAuthProfileOrder(params: {
// Still put preferredProfile first if specified
if (preferredProfile && ordered.includes(preferredProfile)) {
return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)];
return [
preferredProfile,
...ordered.filter((e) => e !== preferredProfile),
];
}
return ordered;
}
+5 -3
View File
@@ -108,7 +108,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -134,7 +134,9 @@ function buildSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone);
const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
@@ -143,7 +145,7 @@ function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo: {
host: "clawdbot",
+1 -1
View File
@@ -46,7 +46,7 @@ describe("gateway tool", () => {
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
const raw = '{\n agent: { workspace: "~/clawd" }\n}\n';
const raw = '{\n agents: { defaults: { workspace: "~/clawd" } }\n}\n';
await tool.execute("call2", {
action: "config.apply",
raw,
+21 -15
View File
@@ -52,18 +52,20 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main",
subagents: {
allowAgents: ["research"],
},
},
research: {
{
id: "research",
name: "Research",
},
},
],
},
};
@@ -87,20 +89,23 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
research: {
{
id: "research",
name: "Research",
},
coder: {
{
id: "coder",
name: "Coder",
},
},
],
},
};
@@ -131,14 +136,15 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
},
],
},
};
+20 -16
View File
@@ -314,14 +314,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["beta"],
},
},
},
],
},
};
@@ -365,14 +366,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
},
],
},
};
@@ -416,14 +418,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["Research"],
},
},
},
],
},
};
@@ -467,14 +470,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["alpha"],
},
},
},
],
},
};
+5 -5
View File
@@ -34,7 +34,7 @@ function buildAllowedModelKeys(
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = (() => {
const modelMap = cfg?.agent?.models ?? {};
const modelMap = cfg?.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null;
@@ -85,7 +85,7 @@ function resolveImageFallbackCandidates(params: {
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false);
} else {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { primary?: string }
| string
| undefined;
@@ -95,7 +95,7 @@ function resolveImageFallbackCandidates(params: {
}
const imageFallbacks = (() => {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { fallbacks?: string[] }
| string
| undefined;
@@ -142,7 +142,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
const model = params.cfg?.agent?.model as
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
| undefined;
@@ -253,7 +253,7 @@ export async function runWithImageModelFallback<T>(params: {
});
if (candidates.length === 0) {
throw new Error(
"No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.",
"No image model configured. Set agents.defaults.imageModel.primary or agents.defaults.imageModel.fallbacks.",
);
}
+6 -4
View File
@@ -18,9 +18,11 @@ const catalog = [
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agent: {
models: {
"openai/gpt-4": { alias: "gpt4" },
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
},
},
},
} as ClawdbotConfig;
@@ -41,7 +43,7 @@ describe("buildAllowedModelSet", () => {
it("includes the default model when no allowlist is set", () => {
const cfg = {
agent: {},
agents: { defaults: {} },
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
+5 -5
View File
@@ -65,7 +65,7 @@ export function buildModelAliasIndex(params: {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
const rawModels = params.cfg.agent?.models ?? {};
const rawModels = params.cfg.agents?.defaults?.models ?? {};
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
if (!parsed) continue;
@@ -109,7 +109,7 @@ export function resolveConfiguredModelRef(params: {
defaultModel: string;
}): ModelRef {
const rawModel = (() => {
const raw = params.cfg.agent?.model as
const raw = params.cfg.agents?.defaults?.model as
| { primary?: string }
| string
| undefined;
@@ -128,7 +128,7 @@ export function resolveConfiguredModelRef(params: {
aliasIndex,
});
if (resolved) return resolved.ref;
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated.
// TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
return { provider: "anthropic", model: trimmed };
}
return { provider: params.defaultProvider, model: params.defaultModel };
@@ -145,7 +145,7 @@ export function buildAllowedModelSet(params: {
allowedKeys: Set<string>;
} {
const rawAllowlist = (() => {
const modelMap = params.cfg.agent?.models ?? {};
const modelMap = params.cfg.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
const allowAny = rawAllowlist.length === 0;
@@ -203,7 +203,7 @@ export function resolveThinkingDefault(params: {
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel {
const configured = params.cfg.agent?.thinkingDefault;
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,
@@ -110,12 +110,14 @@ describe("resolveExtraParams", () => {
it("respects explicit thinking config from user (disable thinking)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
},
},
},
},
@@ -136,12 +138,14 @@ describe("resolveExtraParams", () => {
it("preserves other params while adding thinking config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
},
},
},
},
@@ -164,13 +168,15 @@ describe("resolveExtraParams", () => {
it("does not override explicit thinking config even if partial", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
},
},
},
},
@@ -214,12 +220,14 @@ describe("resolveExtraParams", () => {
it("passes through params for non-GLM models without modification", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
},
},
},
},
@@ -264,7 +272,7 @@ describe("resolveExtraParams", () => {
it("handles config with empty models gracefully", () => {
const result = resolveExtraParams({
cfg: { agent: { models: {} } },
cfg: { agents: { defaults: { models: {} } } },
provider: "zai",
modelId: "glm-4.7",
});
@@ -280,12 +288,14 @@ describe("resolveExtraParams", () => {
it("model alias lookup uses exact provider/model key", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
},
},
},
},
@@ -307,11 +317,13 @@ describe("resolveExtraParams", () => {
it("treats thinking: null as explicit config (no auto-enable)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
},
},
},
},
@@ -374,11 +386,13 @@ describe("resolveExtraParams", () => {
it("thinkLevel: 'off' still passes through explicit config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
},
},
},
},
+16 -15
View File
@@ -105,7 +105,7 @@ import { loadWorkspaceBootstrapFiles } from "./workspace.js";
* - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn
*
* Users can override via config:
* agent.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
* agents.defaults.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
*
* Or disable via runtime flag: --thinking off
*
@@ -119,7 +119,7 @@ export function resolveExtraParams(params: {
thinkLevel?: string;
}): Record<string, unknown> | undefined {
const modelKey = `${params.provider}/${params.modelId}`;
const modelConfig = params.cfg?.agent?.models?.[modelKey];
const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey];
let extraParams = modelConfig?.params ? { ...modelConfig.params } : undefined;
// Auto-enable thinking for ZAI GLM-4.x models when not explicitly configured
@@ -200,10 +200,10 @@ function resolveContextWindowTokens(params: {
if (fromModelsConfig) return fromModelsConfig;
const fromAgentConfig =
typeof params.cfg?.agent?.contextTokens === "number" &&
Number.isFinite(params.cfg.agent.contextTokens) &&
params.cfg.agent.contextTokens > 0
? Math.floor(params.cfg.agent.contextTokens)
typeof params.cfg?.agents?.defaults?.contextTokens === "number" &&
Number.isFinite(params.cfg.agents.defaults.contextTokens) &&
params.cfg.agents.defaults.contextTokens > 0
? Math.floor(params.cfg.agents.defaults.contextTokens)
: undefined;
if (fromAgentConfig) return fromAgentConfig;
@@ -217,7 +217,7 @@ function buildContextPruningExtension(params: {
modelId: string;
model: Model<Api> | undefined;
}): { additionalExtensionPaths?: string[] } {
const raw = params.cfg?.agent?.contextPruning;
const raw = params.cfg?.agents?.defaults?.contextPruning;
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
const settings = computeEffectiveSettings(raw);
@@ -254,7 +254,7 @@ export type EmbeddedPiRunMeta = {
};
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -844,7 +844,7 @@ export async function compactEmbeddedPiSession(params: {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -865,7 +865,7 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -875,7 +875,7 @@ export async function compactEmbeddedPiSession(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1157,7 +1157,7 @@ export async function runEmbeddedPiAgent(params: {
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -1178,7 +1178,7 @@ export async function runEmbeddedPiAgent(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -1188,7 +1188,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1444,7 +1444,8 @@ export async function runEmbeddedPiAgent(params: {
}
const fallbackConfigured =
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) >
0;
const authFailure = isAuthAssistantError(lastAssistant);
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
+48 -46
View File
@@ -6,18 +6,17 @@ import type { SandboxDockerConfig } from "./sandbox.js";
describe("Agent-specific tool filtering", () => {
it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
},
},
],
},
};
@@ -36,22 +35,21 @@ describe("Agent-specific tool filtering", () => {
it("should apply agent-specific tool policy", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"], // Agent override: only read
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
@@ -71,20 +69,22 @@ describe("Agent-specific tool filtering", () => {
it("should allow different tool policies for different agents", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
// No tools restriction - all tools available
},
family: {
{
id: "family",
workspace: "~/clawd-family",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit", "process"],
},
},
},
],
},
};
@@ -116,20 +116,19 @@ describe("Agent-specific tool filtering", () => {
it("should prefer agent-specific tool policy over global", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["browser"], // Global deny
},
tools: {
deny: ["browser"], // Global deny
},
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
tools: {
deny: ["bash", "process"], // Agent deny (override)
},
},
},
],
},
};
@@ -149,19 +148,16 @@ describe("Agent-specific tool filtering", () => {
it("should work with sandbox tools filtering", () => {
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
@@ -172,6 +168,14 @@ describe("Agent-specific tool filtering", () => {
deny: ["bash", "write"],
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
},
},
},
};
@@ -216,10 +220,8 @@ describe("Agent-specific tool filtering", () => {
it("should run bash synchronously when process is denied", async () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["process"],
},
tools: {
deny: ["process"],
},
};
+2 -2
View File
@@ -171,7 +171,7 @@ describe("createClawdbotCodingTools", () => {
sessionKey: "agent:main:subagent:test",
// Intentionally partial config; only fields used by pi-tools are provided.
config: {
agent: {
tools: {
subagents: {
tools: {
// Policy matching is case-insensitive
@@ -325,7 +325,7 @@ describe("createClawdbotCodingTools", () => {
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
config: { tools: { deny: ["browser"] } },
});
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
+2 -2
View File
@@ -429,7 +429,7 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
];
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
const configured = cfg?.agent?.subagents?.tools;
const configured = cfg?.tools?.subagents?.tools;
const deny = [
...DEFAULT_SUBAGENT_TOOL_DENY,
...(Array.isArray(configured?.deny) ? configured.deny : []),
@@ -466,7 +466,7 @@ function resolveEffectiveToolPolicy(params: {
? resolveAgentConfig(params.config, agentId)
: undefined;
const hasAgentTools = agentConfig?.tools !== undefined;
const globalTools = params.config?.agent?.tools;
const globalTools = params.config?.tools;
return {
agentId,
policy: hasAgentTools ? agentConfig?.tools : globalTools,
+123 -104
View File
@@ -56,18 +56,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
},
},
routing: {
agents: {
main: {
workspace: "~/clawd",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
list: [
{
id: "main",
workspace: "~/clawd",
},
],
},
};
@@ -85,18 +86,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -106,7 +108,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -133,18 +135,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -154,7 +157,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -182,19 +185,20 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -205,7 +209,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -224,21 +228,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all", // Global default
scope: "agent",
agents: {
defaults: {
sandbox: {
mode: "all", // Global default
scope: "agent",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // Agent override
},
},
},
],
},
};
@@ -256,21 +261,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "off", // Global default
agents: {
defaults: {
sandbox: {
mode: "off", // Global default
},
},
},
routing: {
agents: {
family: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // Agent override
scope: "agent",
},
},
},
],
},
};
@@ -288,22 +294,23 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "session", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "session", // Global default
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
scope: "agent", // Agent override
},
},
},
],
},
};
@@ -322,16 +329,17 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
},
},
},
routing: {
agents: {
isolated: {
list: [
{
id: "isolated",
workspace: "~/clawd-isolated",
sandbox: {
mode: "all",
@@ -339,7 +347,7 @@ describe("Agent-specific sandbox config", () => {
workspaceRoot: "/tmp/isolated-sandboxes", // Agent override
},
},
},
],
},
};
@@ -359,28 +367,30 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "non-main",
scope: "session",
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // main: no sandbox
},
},
family: {
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // family: always sandbox
scope: "agent",
},
},
},
],
},
};
@@ -406,29 +416,38 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read"],
deny: ["bash"],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
},
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
};
+17 -11
View File
@@ -22,7 +22,10 @@ import {
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
import {
resolveAgentConfig,
resolveAgentIdFromSessionKey,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
@@ -345,15 +348,14 @@ export function resolveSandboxConfigForAgent(
cfg?: ClawdbotConfig,
agentId?: string,
): SandboxConfig {
const agent = cfg?.agent?.sandbox;
const agent = cfg?.agents?.defaults?.sandbox;
// Agent-specific sandbox config overrides global
let agentSandbox: typeof agent | undefined;
if (agentId && cfg?.routing?.agents) {
const agentConfig = cfg.routing.agents[agentId];
if (agentConfig && typeof agentConfig === "object") {
agentSandbox = agentConfig.sandbox;
}
const agentConfig =
cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
if (agentConfig?.sandbox) {
agentSandbox = agentConfig.sandbox;
}
const scope = resolveSandboxScope({
@@ -382,9 +384,13 @@ export function resolveSandboxConfigForAgent(
}),
tools: {
allow:
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
agentConfig?.tools?.sandbox?.tools?.allow ??
cfg?.tools?.sandbox?.tools?.allow ??
DEFAULT_TOOL_ALLOW,
deny:
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
agentConfig?.tools?.sandbox?.tools?.deny ??
cfg?.tools?.sandbox?.tools?.deny ??
DEFAULT_TOOL_DENY,
},
prune: resolveSandboxPruneConfig({
scope,
@@ -1059,7 +1065,7 @@ export async function resolveSandboxContext(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
@@ -1133,7 +1139,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
+1 -1
View File
@@ -24,7 +24,7 @@ let listenerStarted = false;
function resolveArchiveAfterMs() {
const cfg = loadConfig();
const minutes = cfg.agent?.subagents?.archiveAfterMinutes ?? 60;
const minutes = cfg.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60;
if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
return Math.max(1, Math.floor(minutes)) * 60_000;
}
+1 -1
View File
@@ -8,7 +8,7 @@ const normalizeNumber = (value: unknown): number | undefined =>
: undefined;
export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number {
const raw = normalizeNumber(cfg?.agent?.timeoutSeconds);
const raw = normalizeNumber(cfg?.agents?.defaults?.timeoutSeconds);
const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS;
return Math.max(seconds, 1);
}
+8 -10
View File
@@ -55,19 +55,17 @@ export function createAgentsListTool(opts?: {
.map((value) => normalizeAgentId(value)),
);
const configuredAgents = cfg.routing?.agents ?? {};
const configuredIds = Object.keys(configuredAgents).map((key) =>
normalizeAgentId(key),
const configuredAgents = Array.isArray(cfg.agents?.list)
? cfg.agents?.list
: [];
const configuredIds = configuredAgents.map((entry) =>
normalizeAgentId(entry.id),
);
const configuredNameMap = new Map<string, string>();
for (const [key, value] of Object.entries(configuredAgents)) {
if (!value || typeof value !== "object") continue;
const name =
typeof (value as { name?: unknown }).name === "string"
? ((value as { name?: string }).name?.trim() ?? "")
: "";
for (const entry of configuredAgents) {
const name = entry?.name?.trim() ?? "";
if (!name) continue;
configuredNameMap.set(normalizeAgentId(key), name);
configuredNameMap.set(normalizeAgentId(entry.id), name);
}
const allowed = new Set<string>();
+3 -3
View File
@@ -23,7 +23,7 @@ import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image.";
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
const imageModel = cfg?.agent?.imageModel as
const imageModel = cfg?.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
@@ -45,7 +45,7 @@ function pickMaxBytes(
) {
return Math.floor(maxBytesMb * 1024 * 1024);
}
const configured = cfg?.agent?.mediaMaxMb;
const configured = cfg?.agents?.defaults?.mediaMaxMb;
if (
typeof configured === "number" &&
Number.isFinite(configured) &&
@@ -141,7 +141,7 @@ export function createImageTool(options?: {
label: "Image",
name: "image",
description:
"Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.",
"Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL.",
parameters: Type.Object({
prompt: Type.Optional(Type.String()),
image: Type.String(),
+4 -5
View File
@@ -25,7 +25,7 @@ const SessionsHistoryToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
async function isSpawnedSessionAllowed(params: {
@@ -97,7 +97,7 @@ export function createSessionsHistoryTool(opts?: {
}
}
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -126,14 +126,13 @@ export function createSessionsHistoryTool(opts?: {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.",
"Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history denied by routing.agentToAgent.allow.",
error: "Agent-to-agent history denied by tools.agentToAgent.allow.",
});
}
}
@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -32,7 +32,7 @@ describe("sessions_list gating", () => {
});
});
it("filters out other agents when routing.agentToAgent.enabled is false", async () => {
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({
+2 -2
View File
@@ -53,7 +53,7 @@ const SessionsListToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
export function createSessionsListTool(opts?: {
@@ -126,7 +126,7 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -25,7 +25,7 @@ describe("sessions_send gating", () => {
callGatewayMock.mockReset();
});
it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => {
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main",
agentProvider: "whatsapp",
+4 -4
View File
@@ -54,7 +54,7 @@ export function createSessionsSendTool(opts?: {
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
@@ -126,7 +126,7 @@ export function createSessionsSendTool(opts?: {
mainKey,
});
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -156,7 +156,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.",
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
sessionKey: displayKey,
});
}
@@ -165,7 +165,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by routing.agentToAgent.allow.",
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
sessionKey: displayKey,
});
}
+20 -12
View File
@@ -85,9 +85,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -140,9 +142,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -185,9 +189,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -239,9 +245,11 @@ describe("block streaming", () => {
blockReplyTimeoutMs: 10,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
+234 -148
View File
@@ -78,11 +78,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
},
},
whatsapp: { allowFrom: ["*"] },
@@ -108,9 +110,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -138,11 +142,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
routing: {
messages: {
queue: {
mode: "collect",
debounceMs: 1500,
@@ -174,10 +180,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -198,9 +206,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -232,9 +242,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -270,9 +282,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -303,9 +317,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -330,9 +346,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -352,10 +370,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -376,9 +396,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -399,10 +421,12 @@ describe("directive behavior", () => {
{ Body: "/verbose", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
verboseDefault: "on",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
verboseDefault: "on",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -423,9 +447,11 @@ describe("directive behavior", () => {
{ Body: "/reasoning", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -452,10 +478,14 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevatedDefault: "on",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -486,13 +516,17 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
sandbox: { mode: "off" },
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
sandbox: { mode: "off" },
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
@@ -520,9 +554,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -552,9 +590,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -585,9 +627,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -613,9 +659,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -644,9 +692,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -677,9 +727,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -690,9 +742,11 @@ describe("directive behavior", () => {
{ Body: "/queue reset", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -749,9 +803,11 @@ describe("directive behavior", () => {
ctx,
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -810,9 +866,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -825,9 +883,11 @@ describe("directive behavior", () => {
ctx,
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -853,12 +913,14 @@ describe("directive behavior", () => {
{ Body: "/model", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -883,12 +945,14 @@ describe("directive behavior", () => {
{ Body: "/model status", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -913,12 +977,14 @@ describe("directive behavior", () => {
{ Body: "/model list", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -943,12 +1009,14 @@ describe("directive behavior", () => {
{ Body: "/model", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -972,11 +1040,13 @@ describe("directive behavior", () => {
{ Body: "/model list", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
},
},
},
session: { store: storePath },
@@ -999,12 +1069,14 @@ describe("directive behavior", () => {
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -1030,12 +1102,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1081,12 +1155,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1112,12 +1188,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1151,12 +1229,14 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
whatsapp: {
@@ -1204,9 +1284,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1242,9 +1324,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1004"] },
},
@@ -57,9 +57,11 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
+5 -3
View File
@@ -53,9 +53,11 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
+6 -4
View File
@@ -50,13 +50,15 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string, queue?: Record<string, unknown>) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
routing: queue ? { queue } : undefined,
messages: queue ? { queue } : undefined,
};
}
+124 -63
View File
@@ -25,13 +25,18 @@ const usageMocks = vi.hoisted(() => ({
vi.mock("../infra/provider-usage.js", () => usageMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
compactEmbeddedPiSession,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionKey,
} from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
@@ -61,9 +66,11 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -345,9 +352,11 @@ describe("trigger handling", () => {
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
@@ -381,9 +390,13 @@ describe("trigger handling", () => {
it("allows approved sender to toggle elevated mode", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -420,9 +433,13 @@ describe("trigger handling", () => {
it("rejects elevated toggles when disabled", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+1000"] },
@@ -467,9 +484,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -510,9 +531,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -545,9 +570,13 @@ describe("trigger handling", () => {
it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -589,9 +618,13 @@ describe("trigger handling", () => {
it("allows elevated directive in direct chats without mentions", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -635,9 +668,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -668,9 +705,11 @@ describe("trigger handling", () => {
it("falls back to discord dm allowFrom for elevated approval", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
discord: {
dm: {
@@ -708,9 +747,13 @@ describe("trigger handling", () => {
it("treats explicit discord elevated allowlist as override", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { discord: [] },
},
@@ -799,9 +842,12 @@ describe("trigger handling", () => {
});
const cfg = makeCfg(home);
cfg.agent = {
...cfg.agent,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
},
};
await getReplyFromConfig(
@@ -941,15 +987,17 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
routing: {
messages: {
groupChat: {},
},
session: { store: join(home, "sessions.json") },
@@ -985,9 +1033,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1024,9 +1074,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1056,9 +1108,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
@@ -1083,9 +1137,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
@@ -1124,9 +1180,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1229,12 +1287,14 @@ describe("trigger handling", () => {
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
},
whatsapp: {
@@ -1272,10 +1332,11 @@ describe("trigger handling", () => {
ctx,
cfg.session?.mainKey,
);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: cfg.agent.workspace,
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
});
expect(sandbox).not.toBeNull();
if (!sandbox) {
+5 -5
View File
@@ -212,7 +212,7 @@ export async function getReplyFromConfig(
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const cfg = configOverride ?? loadConfig();
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const agentCfg = cfg.agent;
const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
cfg,
@@ -239,7 +239,7 @@ export async function getReplyFromConfig(
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;
const agentDir = resolveAgentDir(cfg, agentId);
@@ -257,7 +257,7 @@ export async function getReplyFromConfig(
opts?.onTypingController?.(typing);
let transcribedText: string | undefined;
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) {
if (cfg.audio?.transcription && isAudio(ctx.MediaType)) {
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
if (transcribed?.text) {
transcribedText = transcribed.text;
@@ -329,7 +329,7 @@ export async function getReplyFromConfig(
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
),
);
const configuredAliases = Object.values(cfg.agent?.models ?? {})
const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
@@ -391,7 +391,7 @@ export async function getReplyFromConfig(
sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ??
"";
const elevatedConfig = agentCfg?.elevated;
const elevatedConfig = cfg.tools?.elevated;
const discordElevatedFallback =
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const elevatedEnabled = elevatedConfig?.enabled !== false;
+1 -1
View File
@@ -34,7 +34,7 @@ export function resolveBlockStreamingChunking(
} {
const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, providerKey);
const chunkCfg = cfg?.agent?.blockStreamingChunk;
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
const maxRequested = Math.max(
1,
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),
+6 -5
View File
@@ -163,18 +163,19 @@ export async function buildStatusReply(params: {
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const agentDefaults = cfg.agents?.defaults ?? {};
const statusText = buildStatusMessage({
config: cfg,
agent: {
...cfg.agent,
...agentDefaults,
model: {
...cfg.agent?.model,
...agentDefaults.model,
primary: `${provider}/${model}`,
},
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
thinkingDefault: agentDefaults.thinkingDefault,
verboseDefault: agentDefaults.verboseDefault,
elevatedDefault: agentDefaults.elevatedDefault,
},
sessionEntry,
sessionKey,
+18 -12
View File
@@ -23,6 +23,7 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveAgentIdFromSessionKey,
@@ -363,16 +364,16 @@ export async function handleDirectiveOnly(params: {
currentElevatedLevel,
} = params;
const runtimeIsSandboxed = (() => {
const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") return false;
const sessionKey = params.sessionKey?.trim();
if (!sessionKey) return false;
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
if (sandboxCfg.mode === "off") return false;
const mainKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
if (sandboxMode === "all") return true;
if (sandboxCfg.mode === "all") return true;
return sessionKey !== mainKey;
})();
const shouldHintDirectRuntime =
@@ -394,7 +395,9 @@ export async function handleDirectiveOnly(params: {
provider: string;
id: string;
}> = [];
for (const raw of Object.keys(params.cfg.agent?.models ?? {})) {
for (const raw of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider,
@@ -851,7 +854,7 @@ export async function persistInlineDirectives(params: {
model: string;
initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string;
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg: NonNullable<ClawdbotConfig["agents"]>["defaults"] | undefined;
}): Promise<{ provider: string; model: string; contextTokens: number }> {
const {
directives,
@@ -1007,13 +1010,16 @@ export function resolveDefaultModel(params: {
agentModelOverride && agentModelOverride.length > 0
? {
...params.cfg,
agent: {
...params.cfg.agent,
model: {
...(typeof params.cfg.agent?.model === "object"
? params.cfg.agent.model
: undefined),
primary: agentModelOverride,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model: {
...(typeof params.cfg.agents?.defaults?.model === "object"
? params.cfg.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
},
},
}
+11 -6
View File
@@ -9,7 +9,7 @@ import {
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({
routing: {
messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
},
});
@@ -23,7 +23,7 @@ describe("mention helpers", () => {
it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({
routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
@@ -31,11 +31,16 @@ describe("mention helpers", () => {
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
routing: {
messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
agents: {
work: { mentionPatterns: ["\\bworkbot\\b"] },
},
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
},
},
"work",
+47 -6
View File
@@ -1,23 +1,62 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
const patterns: string[] = [];
const name = identity?.name?.trim();
if (name) {
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
const re = parts.length ? parts.join(String.raw`\s+`) : escapeRegExp(name);
patterns.push(String.raw`\b@?${re}\b`);
}
const emoji = identity?.emoji?.trim();
if (emoji) {
patterns.push(escapeRegExp(emoji));
}
return patterns;
}
const BACKSPACE_CHAR = "\u0008";
function normalizeMentionPattern(pattern: string): string {
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
return pattern.split(BACKSPACE_CHAR).join("\\b");
}
function normalizeMentionPatterns(patterns: string[]): string[] {
return patterns.map(normalizeMentionPattern);
}
function resolveMentionPatterns(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string[] {
if (!cfg) return [];
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
return agentConfig.mentionPatterns ?? [];
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
const agentGroupChat = agentConfig?.groupChat;
if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
return agentGroupChat.mentionPatterns ?? [];
}
return cfg.routing?.groupChat?.mentionPatterns ?? [];
const globalGroupChat = cfg.messages?.groupChat;
if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
return globalGroupChat.mentionPatterns ?? [];
}
const derived = deriveMentionPatterns(agentConfig?.identity);
return derived.length > 0 ? derived : [];
}
export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): RegExp[] {
const patterns = resolveMentionPatterns(cfg, agentId);
const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
return patterns
.map((pattern) => {
try {
@@ -66,7 +105,9 @@ export function stripMentions(
agentId?: string,
): string {
let result = text;
const patterns = resolveMentionPatterns(cfg, agentId);
const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");
+6 -2
View File
@@ -33,7 +33,9 @@ type ModelSelectionState = {
export async function createModelSelectionState(params: {
cfg: ClawdbotConfig;
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -201,7 +203,9 @@ export function resolveModelDirectiveSelection(params: {
}
export function resolveContextTokens(params: {
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
model: string;
}): number {
return (
+1 -1
View File
@@ -553,7 +553,7 @@ export function resolveQueueSettings(params: {
inlineOptions?: Partial<QueueSettings>;
}): QueueSettings {
const providerKey = params.provider?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue;
const queueCfg = params.cfg.messages?.queue;
const providerModeRaw =
providerKey && queueCfg?.byProvider
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
+8 -2
View File
@@ -35,7 +35,9 @@ import type {
VerboseLevel,
} from "./thinking.js";
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
type AgentConfig = Partial<
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
>;
export const formatTokenCount = formatTokenCountShared;
@@ -188,7 +190,11 @@ export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({
cfg: { agent: args.agent ?? {} },
cfg: {
agents: {
defaults: args.agent ?? {},
},
} as ClawdbotConfig,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
+3 -3
View File
@@ -37,8 +37,8 @@ describe("transcribeInboundAudio", () => {
vi.stubGlobal("fetch", fetchMock);
const cfg = {
routing: {
transcribeAudio: {
audio: {
transcription: {
command: ["echo", "{{MediaPath}}"],
timeoutSeconds: 5,
},
@@ -64,7 +64,7 @@ describe("transcribeInboundAudio", () => {
it("returns undefined when no transcription command", async () => {
const { transcribeInboundAudio } = await import("./transcription.js");
const res = await transcribeInboundAudio(
{ routing: {} } as never,
{ audio: {} } as never,
{} as never,
runtime as never,
);
+1 -1
View File
@@ -18,7 +18,7 @@ export async function transcribeInboundAudio(
ctx: MsgContext,
runtime: RuntimeEnv,
): Promise<{ text: string } | undefined> {
const transcriber = cfg.routing?.transcribeAudio;
const transcriber = cfg.audio?.transcription;
if (!transcriber?.command?.length) return undefined;
const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000);
+6 -2
View File
@@ -267,10 +267,14 @@ export function registerModelsCli(program: Command) {
.option("--no-probe", "Skip live probes; list free candidates only")
.option("--yes", "Accept defaults without prompting", false)
.option("--no-input", "Disable prompts (use defaults)")
.option("--set-default", "Set agent.model to the first selection", false)
.option(
"--set-default",
"Set agents.defaults.model to the first selection",
false,
)
.option(
"--set-image",
"Set agent.imageModel to the first image selection",
"Set agents.defaults.imageModel to the first image selection",
false,
)
.option("--json", "Output JSON", false)
+2 -2
View File
@@ -191,7 +191,7 @@ export function buildProgram() {
.description("Initialize ~/.clawdbot/clawdbot.json and the agent workspace")
.option(
"--workspace <dir>",
"Agent workspace directory (default: ~/clawd; stored as agent.workspace)",
"Agent workspace directory (default: ~/clawd; stored as agents.defaults.workspace)",
)
.option("--wizard", "Run the interactive onboarding wizard", false)
.option("--non-interactive", "Run the wizard without prompts", false)
@@ -1163,7 +1163,7 @@ Examples:
clawdbot sessions --json # machine-readable output
clawdbot sessions --store ./tmp/sessions.json
Shows token usage per session when the agent reports it; set agent.contextTokens to see % of your model window.`,
Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
+20 -4
View File
@@ -1,5 +1,9 @@
import chalk from "chalk";
import type { Command } from "commander";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import {
buildWorkspaceSkillStatus,
type SkillStatusEntry,
@@ -363,7 +367,10 @@ export function registerSkillsCli(program: Command) {
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsList(report, opts));
} catch (err) {
@@ -380,7 +387,10 @@ export function registerSkillsCli(program: Command) {
.action(async (name, opts) => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillInfo(report, name, opts));
} catch (err) {
@@ -396,7 +406,10 @@ export function registerSkillsCli(program: Command) {
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsCheck(report, opts));
} catch (err) {
@@ -409,7 +422,10 @@ export function registerSkillsCli(program: Command) {
skills.action(async () => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsList(report, {}));
} catch (err) {
+5 -3
View File
@@ -29,9 +29,11 @@ const configSpy = vi.spyOn(configModule, "loadConfig");
function mockConfig(storePath: string, overrides?: Partial<ClawdbotConfig>) {
configSpy.mockReturnValue({
agent: {
timeoutSeconds: 600,
...overrides?.agent,
agents: {
defaults: {
timeoutSeconds: 600,
...overrides?.agents?.defaults,
},
},
session: {
store: storePath,
+1 -1
View File
@@ -80,7 +80,7 @@ function parseTimeoutSeconds(opts: {
const raw =
opts.timeout !== undefined
? Number.parseInt(String(opts.timeout), 10)
: (opts.cfg.agent?.timeoutSeconds ?? 600);
: (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600);
if (Number.isNaN(raw) || raw <= 0) {
throw new Error("--timeout must be a positive integer (seconds)");
}
+18 -12
View File
@@ -53,19 +53,21 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function mockConfig(
home: string,
storePath: string,
routingOverrides?: Partial<NonNullable<ClawdbotConfig["routing"]>>,
agentOverrides?: Partial<NonNullable<ClawdbotConfig["agent"]>>,
agentOverrides?: Partial<
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
>,
telegramOverrides?: Partial<NonNullable<ClawdbotConfig["telegram"]>>,
) {
configSpy.mockReturnValue({
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
models: { "anthropic/claude-opus-4-5": {} },
workspace: path.join(home, "clawd"),
...agentOverrides,
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
models: { "anthropic/claude-opus-4-5": {} },
workspace: path.join(home, "clawd"),
...agentOverrides,
},
},
session: { store: storePath, mainKey: "main" },
routing: routingOverrides ? { ...routingOverrides } : undefined,
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
});
}
@@ -153,11 +155,15 @@ describe("agentCommand", () => {
});
});
it("uses provider/model from agent.model", async () => {
it("uses provider/model from agents.defaults.model.primary", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, {
model: "openai/gpt-4.1-mini",
mockConfig(home, store, {
model: { primary: "openai/gpt-4.1-mini" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
});
await agentCommand({ message: "hi", to: "+1555" }, runtime);
@@ -269,7 +275,7 @@ describe("agentCommand", () => {
it("passes through telegram accountId when delivering", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
mockConfig(home, store, undefined, { botToken: "t-1" });
const deps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi
+2 -2
View File
@@ -181,13 +181,13 @@ export async function agentCommand(
}
const cfg = loadConfig();
const agentCfg = cfg.agent;
const agentCfg = cfg.agents?.defaults;
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
const agentDir = resolveAgentDir(cfg, sessionAgentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;
+49 -44
View File
@@ -1,9 +1,9 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
applyAgentBindings,
applyAgentConfig,
@@ -12,27 +12,32 @@ import {
} from "./agents.js";
describe("agents helpers", () => {
it("buildAgentSummaries includes default + routing agents", () => {
it("buildAgentSummaries includes default + configured agents", () => {
const cfg: ClawdbotConfig = {
agent: { workspace: "/main-ws", model: { primary: "anthropic/claude" } },
routing: {
defaultAgentId: "work",
agents: {
work: {
agents: {
defaults: {
workspace: "/main-ws",
model: { primary: "anthropic/claude" },
},
list: [
{ id: "main" },
{
id: "work",
default: true,
name: "Work",
workspace: "/work-ws",
agentDir: "/state/agents/work/agent",
model: "openai/gpt-4.1",
},
},
bindings: [
{
agentId: "work",
match: { provider: "whatsapp", accountId: "biz" },
},
{ agentId: "main", match: { provider: "telegram" } },
],
},
bindings: [
{
agentId: "work",
match: { provider: "whatsapp", accountId: "biz" },
},
{ agentId: "main", match: { provider: "telegram" } },
],
};
const summaries = buildAgentSummaries(cfg);
@@ -40,7 +45,7 @@ describe("agents helpers", () => {
const work = summaries.find((summary) => summary.id === "work");
expect(main).toBeTruthy();
expect(main?.workspace).toBe(path.resolve("/main-ws"));
expect(main?.workspace).toBe(path.join(os.homedir(), "clawd-main"));
expect(main?.bindings).toBe(1);
expect(main?.model).toBe("anthropic/claude");
expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe(
@@ -57,10 +62,8 @@ describe("agents helpers", () => {
it("applyAgentConfig merges updates", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
work: { workspace: "/old-ws", model: "anthropic/claude" },
},
agents: {
list: [{ id: "work", workspace: "/old-ws", model: "anthropic/claude" }],
},
};
@@ -71,7 +74,7 @@ describe("agents helpers", () => {
agentDir: "/state/work/agent",
});
const work = next.routing?.agents?.work;
const work = next.agents?.list?.find((agent) => agent.id === "work");
expect(work?.name).toBe("Work");
expect(work?.workspace).toBe("/new-ws");
expect(work?.agentDir).toBe("/state/work/agent");
@@ -80,14 +83,12 @@ describe("agents helpers", () => {
it("applyAgentBindings skips duplicates and reports conflicts", () => {
const cfg: ClawdbotConfig = {
routing: {
bindings: [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
},
],
},
bindings: [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
},
],
};
const result = applyAgentBindings(cfg, [
@@ -108,32 +109,36 @@ describe("agents helpers", () => {
expect(result.added).toHaveLength(1);
expect(result.skipped).toHaveLength(1);
expect(result.conflicts).toHaveLength(1);
expect(result.config.routing?.bindings).toHaveLength(2);
expect(result.config.bindings).toHaveLength(2);
});
it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => {
const cfg: ClawdbotConfig = {
routing: {
defaultAgentId: "work",
agents: {
work: { workspace: "/work-ws" },
home: { workspace: "/home-ws" },
},
bindings: [
{ agentId: "work", match: { provider: "whatsapp" } },
{ agentId: "home", match: { provider: "telegram" } },
agents: {
list: [
{ id: "work", default: true, workspace: "/work-ws" },
{ id: "home", workspace: "/home-ws" },
],
},
bindings: [
{ agentId: "work", match: { provider: "whatsapp" } },
{ agentId: "home", match: { provider: "telegram" } },
],
tools: {
agentToAgent: { enabled: true, allow: ["work", "home"] },
},
};
const result = pruneAgentConfig(cfg, "work");
expect(result.config.routing?.agents?.work).toBeUndefined();
expect(result.config.routing?.agents?.home).toBeTruthy();
expect(result.config.routing?.bindings).toHaveLength(1);
expect(result.config.routing?.bindings?.[0]?.agentId).toBe("home");
expect(result.config.routing?.agentToAgent?.allow).toEqual(["home"]);
expect(result.config.routing?.defaultAgentId).toBe(DEFAULT_AGENT_ID);
expect(
result.config.agents?.list?.some((agent) => agent.id === "work"),
).toBe(false);
expect(
result.config.agents?.list?.some((agent) => agent.id === "home"),
).toBe(true);
expect(result.config.bindings).toHaveLength(1);
expect(result.config.bindings?.[0]?.agentId).toBe("home");
expect(result.config.tools?.agentToAgent?.allow).toEqual(["home"]);
expect(result.removedBindings).toBe(1);
expect(result.removedAllow).toBe(1);
});
+105 -74
View File
@@ -1,9 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
@@ -114,6 +114,10 @@ type AgentBinding = {
};
};
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type AgentIdentity = {
name?: string;
emoji?: string;
@@ -140,15 +144,32 @@ function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv {
return { ...runtime, log: () => {} };
}
function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
}
function findAgentEntryIndex(list: AgentEntry[], agentId: string): number {
const id = normalizeAgentId(agentId);
return list.findIndex((entry) => normalizeAgentId(entry.id) === id);
}
function resolveAgentName(cfg: ClawdbotConfig, agentId: string) {
return cfg.routing?.agents?.[agentId]?.name?.trim() || undefined;
const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
);
return entry?.name?.trim() || undefined;
}
function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
if (agentId !== DEFAULT_AGENT_ID) {
return cfg.routing?.agents?.[agentId]?.model?.trim() || undefined;
}
const raw = cfg.agent?.model;
const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
);
if (entry?.model?.trim()) return entry.model.trim();
const raw = cfg.agents?.defaults?.model;
if (typeof raw === "string") return raw;
return raw?.primary?.trim() || undefined;
}
@@ -183,37 +204,33 @@ function loadAgentIdentity(workspace: string): AgentIdentity | null {
}
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
const defaultAgentId = normalizeAgentId(
cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const agentIds = new Set<string>([
DEFAULT_AGENT_ID,
defaultAgentId,
...Object.keys(cfg.routing?.agents ?? {}),
]);
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
const configuredAgents = listAgentEntries(cfg);
const orderedIds =
configuredAgents.length > 0
? configuredAgents.map((agent) => normalizeAgentId(agent.id))
: [defaultAgentId];
const bindingCounts = new Map<string, number>();
for (const binding of cfg.routing?.bindings ?? []) {
for (const binding of cfg.bindings ?? []) {
const agentId = normalizeAgentId(binding.agentId);
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
}
const ordered = [
DEFAULT_AGENT_ID,
...[...agentIds]
.filter((id) => id !== DEFAULT_AGENT_ID)
.sort((a, b) => a.localeCompare(b)),
];
const ordered = orderedIds.filter(
(id, index) => orderedIds.indexOf(id) === index,
);
return ordered.map((id) => {
const workspace = resolveAgentWorkspaceDir(cfg, id);
const identity = loadAgentIdentity(workspace);
const fallbackIdentity = id === defaultAgentId ? cfg.identity : undefined;
const identityName = identity?.name ?? fallbackIdentity?.name?.trim();
const identityEmoji = identity?.emoji ?? fallbackIdentity?.emoji?.trim();
const configIdentity = configuredAgents.find(
(agent) => normalizeAgentId(agent.id) === id,
)?.identity;
const identityName = identity?.name ?? configIdentity?.name?.trim();
const identityEmoji = identity?.emoji ?? configIdentity?.emoji?.trim();
const identitySource = identity
? "identity"
: fallbackIdentity && (identityName || identityEmoji)
: configIdentity && (identityName || identityEmoji)
? "config"
: undefined;
return {
@@ -242,22 +259,34 @@ export function applyAgentConfig(
},
): ClawdbotConfig {
const agentId = normalizeAgentId(params.agentId);
const existing = cfg.routing?.agents?.[agentId] ?? {};
const name = params.name?.trim();
const list = listAgentEntries(cfg);
const index = findAgentEntryIndex(list, agentId);
const base = index >= 0 ? list[index] : { id: agentId };
const nextEntry: AgentEntry = {
...base,
...(name ? { name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.model ? { model: params.model } : {}),
};
const nextList = [...list];
if (index >= 0) {
nextList[index] = nextEntry;
} else {
if (
nextList.length === 0 &&
agentId !== normalizeAgentId(resolveDefaultAgentId(cfg))
) {
nextList.push({ id: resolveDefaultAgentId(cfg) });
}
nextList.push(nextEntry);
}
return {
...cfg,
routing: {
...cfg.routing,
agents: {
...cfg.routing?.agents,
[agentId]: {
...existing,
...(name ? { name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.model ? { model: params.model } : {}),
},
},
agents: {
...cfg.agents,
list: nextList,
},
};
}
@@ -283,7 +312,7 @@ export function applyAgentBindings(
skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} {
const existing = cfg.routing?.bindings ?? [];
const existing = cfg.bindings ?? [];
const existingMatchMap = new Map<string, string>();
for (const binding of existing) {
const key = bindingMatchKey(binding.match);
@@ -320,10 +349,7 @@ export function applyAgentBindings(
return {
config: {
...cfg,
routing: {
...cfg.routing,
bindings: [...existing, ...added],
},
bindings: [...existing, ...added],
},
added,
skipped,
@@ -340,39 +366,41 @@ export function pruneAgentConfig(
removedAllow: number;
} {
const id = normalizeAgentId(agentId);
const agents = { ...cfg.routing?.agents };
delete agents[id];
const nextAgents = Object.keys(agents).length > 0 ? agents : undefined;
const agents = listAgentEntries(cfg);
const nextAgentsList = agents.filter(
(entry) => normalizeAgentId(entry.id) !== id,
);
const nextAgents = nextAgentsList.length > 0 ? nextAgentsList : undefined;
const bindings = cfg.routing?.bindings ?? [];
const bindings = cfg.bindings ?? [];
const filteredBindings = bindings.filter(
(binding) => normalizeAgentId(binding.agentId) !== id,
);
const allow = cfg.routing?.agentToAgent?.allow ?? [];
const allow = cfg.tools?.agentToAgent?.allow ?? [];
const filteredAllow = allow.filter((entry) => entry !== id);
const nextRouting = {
...cfg.routing,
...(nextAgents ? { agents: nextAgents } : {}),
...(nextAgents ? {} : { agents: undefined }),
bindings: filteredBindings.length > 0 ? filteredBindings : undefined,
agentToAgent: cfg.routing?.agentToAgent
? {
...cfg.routing.agentToAgent,
const nextAgentsConfig = cfg.agents
? { ...cfg.agents, list: nextAgents }
: nextAgents
? { list: nextAgents }
: undefined;
const nextTools = cfg.tools?.agentToAgent
? {
...cfg.tools,
agentToAgent: {
...cfg.tools.agentToAgent,
allow: filteredAllow.length > 0 ? filteredAllow : undefined,
}
: undefined,
defaultAgentId:
normalizeAgentId(cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID) === id
? DEFAULT_AGENT_ID
: cfg.routing?.defaultAgentId,
};
},
}
: cfg.tools;
return {
config: {
...cfg,
routing: nextRouting,
agents: nextAgentsConfig,
bindings: filteredBindings.length > 0 ? filteredBindings : undefined,
tools: nextTools,
},
removedBindings: bindings.length - filteredBindings.length,
removedAllow: allow.length - filteredAllow.length,
@@ -632,7 +660,7 @@ export async function agentsListCommand(
const summaries = buildAgentSummaries(cfg);
const bindingMap = new Map<string, AgentBinding[]>();
for (const binding of cfg.routing?.bindings ?? []) {
for (const binding of cfg.bindings ?? []) {
const agentId = normalizeAgentId(binding.agentId);
const list = bindingMap.get(agentId) ?? [];
list.push(binding as AgentBinding);
@@ -818,7 +846,7 @@ export async function agentsAddCommand(
if (agentId !== nameInput) {
runtime.log(`Normalized agent id to "${agentId}".`);
}
if (cfg.routing?.agents?.[agentId]) {
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) {
runtime.error(`Agent "${agentId}" already exists.`);
runtime.exit(1);
return;
@@ -856,7 +884,9 @@ export async function agentsAddCommand(
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
skipBootstrap: Boolean(bindingResult.config.agent?.skipBootstrap),
skipBootstrap: Boolean(
bindingResult.config.agents?.defaults?.skipBootstrap,
),
agentId,
});
@@ -920,7 +950,9 @@ export async function agentsAddCommand(
await prompter.note(`Normalized id to "${agentId}".`, "Agent id");
}
const existingAgent = cfg.routing?.agents?.[agentId];
const existingAgent = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === agentId,
);
if (existingAgent) {
const shouldUpdate = await prompter.confirm({
message: `Agent "${agentId}" already exists. Update it?`,
@@ -1005,8 +1037,7 @@ export async function agentsAddCommand(
if (selection.length > 0) {
const wantsBindings = await prompter.confirm({
message:
"Route selected providers to this agent now? (routing.bindings)",
message: "Route selected providers to this agent now? (bindings)",
initialValue: false,
});
if (wantsBindings) {
@@ -1033,7 +1064,7 @@ export async function agentsAddCommand(
} else {
await prompter.note(
[
"Routing unchanged. Add routing.bindings when you're ready.",
"Routing unchanged. Add bindings when you're ready.",
"Docs: https://docs.clawd.bot/concepts/multi-agent",
].join("\n"),
"Routing",
@@ -1044,7 +1075,7 @@ export async function agentsAddCommand(
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap),
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
agentId,
});
@@ -1091,7 +1122,7 @@ export async function agentsDeleteCommand(
return;
}
if (!cfg.routing?.agents?.[agentId]) {
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) {
runtime.error(`Agent "${agentId}" not found.`);
runtime.exit(1);
return;
+36 -27
View File
@@ -65,13 +65,16 @@ export async function warnIfModelConfigLooksOff(
agentModelOverride && agentModelOverride.length > 0
? {
...config,
agent: {
...config.agent,
model: {
...(typeof config.agent?.model === "object"
? config.agent.model
: undefined),
primary: agentModelOverride,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
model: {
...(typeof config.agents?.defaults?.model === "object"
? config.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
},
},
}
@@ -92,7 +95,7 @@ export async function warnIfModelConfigLooksOff(
);
if (!known) {
warnings.push(
`Model not found: ${ref.provider}/${ref.model}. Update agent.model or run /models list.`,
`Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`,
);
}
}
@@ -111,7 +114,7 @@ export async function warnIfModelConfigLooksOff(
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) {
warnings.push(
`Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
`Detected OpenAI Codex OAuth. Consider setting agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
);
}
}
@@ -454,30 +457,36 @@ export async function applyAuthChoice(params: {
const modelKey = "google-antigravity/claude-opus-4-5-thinking";
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
models: {
...nextConfig.agent?.models,
[modelKey]: nextConfig.agent?.models?.[modelKey] ?? {},
agents: {
...nextConfig.agents,
defaults: {
...nextConfig.agents?.defaults,
models: {
...nextConfig.agents?.defaults?.models,
[modelKey]:
nextConfig.agents?.defaults?.models?.[modelKey] ?? {},
},
},
},
};
if (params.setDefaultModel) {
const existingModel = nextConfig.agents?.defaults?.model;
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
model: {
...(nextConfig.agent?.model &&
"fallbacks" in
(nextConfig.agent.model as Record<string, unknown>)
? {
fallbacks: (
nextConfig.agent.model as { fallbacks?: string[] }
).fallbacks,
}
: undefined),
primary: modelKey,
agents: {
...nextConfig.agents,
defaults: {
...nextConfig.agents?.defaults,
model: {
...(existingModel &&
"fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: modelKey,
},
},
},
};
+57 -42
View File
@@ -625,26 +625,32 @@ async function promptAuthConfig(
mode: "oauth",
});
// Set default model to Claude Opus 4.5 via Antigravity
const existingDefaults = next.agents?.defaults;
const existingModel = existingDefaults?.model;
const existingModels = existingDefaults?.models;
next = {
...next,
agent: {
...next.agent,
model: {
...(next.agent?.model &&
"fallbacks" in (next.agent.model as Record<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "google-antigravity/claude-opus-4-5-thinking",
},
models: {
...next.agent?.models,
"google-antigravity/claude-opus-4-5-thinking":
next.agent?.models?.[
"google-antigravity/claude-opus-4-5-thinking"
] ?? {},
agents: {
...next.agents,
defaults: {
...existingDefaults,
model: {
...(existingModel &&
"fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "google-antigravity/claude-opus-4-5-thinking",
},
models: {
...existingModels,
"google-antigravity/claude-opus-4-5-thinking":
existingModels?.[
"google-antigravity/claude-opus-4-5-thinking"
] ?? {},
},
},
},
};
@@ -714,9 +720,9 @@ async function promptAuthConfig(
}
const currentModel =
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? "");
typeof next.agents?.defaults?.model === "string"
? next.agents?.defaults?.model
: (next.agents?.defaults?.model?.primary ?? "");
const preferAnthropic =
authChoice === "claude-cli" ||
authChoice === "token" ||
@@ -736,23 +742,29 @@ async function promptAuthConfig(
);
const model = String(modelInput ?? "").trim();
if (model) {
const existingDefaults = next.agents?.defaults;
const existingModel = existingDefaults?.model;
const existingModels = existingDefaults?.models;
next = {
...next,
agent: {
...next.agent,
model: {
...(next.agent?.model &&
"fallbacks" in (next.agent.model as Record<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: model,
},
models: {
...next.agent?.models,
[model]: next.agent?.models?.[model] ?? {},
agents: {
...next.agents,
defaults: {
...existingDefaults,
model: {
...(existingModel &&
"fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: model,
},
models: {
...existingModels,
[model]: existingModels?.[model] ?? {},
},
},
},
};
@@ -955,7 +967,7 @@ export async function runConfigureWizard(
{
value: "workspace",
label: "Workspace",
hint: "Set agent workspace + ensure sessions",
hint: "Set default workspace + ensure sessions",
},
{
value: "model",
@@ -999,8 +1011,8 @@ export async function runConfigureWizard(
let nextConfig = { ...baseConfig };
let workspaceDir =
nextConfig.agent?.workspace ??
baseConfig.agent?.workspace ??
nextConfig.agents?.defaults?.workspace ??
baseConfig.agents?.defaults?.workspace ??
DEFAULT_WORKSPACE;
let gatewayPort = resolveGatewayPort(baseConfig);
let gatewayToken: string | undefined;
@@ -1018,9 +1030,12 @@ export async function runConfigureWizard(
);
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
workspace: workspaceDir,
agents: {
...nextConfig.agents,
defaults: {
...nextConfig.agents?.defaults,
workspace: workspaceDir,
},
},
};
await ensureWorkspaceAndSessions(workspaceDir, runtime);
+194 -63
View File
@@ -71,75 +71,184 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
const changes: string[] = [];
let next: ClawdbotConfig = cfg;
const workspace = cfg.agent?.workspace;
const updatedWorkspace = normalizeDefaultWorkspacePath(workspace);
if (updatedWorkspace && updatedWorkspace !== workspace) {
next = {
...next,
agent: {
...next.agent,
workspace: updatedWorkspace,
},
};
changes.push(`Updated agent.workspace → ${updatedWorkspace}`);
}
const defaults = cfg.agents?.defaults;
if (defaults) {
let updatedDefaults = defaults;
let defaultsChanged = false;
const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(workspaceRoot);
if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) {
next = {
...next,
agent: {
...next.agent,
sandbox: {
...next.agent?.sandbox,
const updatedWorkspace = normalizeDefaultWorkspacePath(defaults.workspace);
if (updatedWorkspace && updatedWorkspace !== defaults.workspace) {
updatedDefaults = { ...updatedDefaults, workspace: updatedWorkspace };
defaultsChanged = true;
changes.push(`Updated agents.defaults.workspace → ${updatedWorkspace}`);
}
const sandbox = defaults.sandbox;
if (sandbox) {
let updatedSandbox = sandbox;
let sandboxChanged = false;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(
sandbox.workspaceRoot,
);
if (
updatedWorkspaceRoot &&
updatedWorkspaceRoot !== sandbox.workspaceRoot
) {
updatedSandbox = {
...updatedSandbox,
workspaceRoot: updatedWorkspaceRoot,
},
},
};
changes.push(
`Updated agent.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`,
);
}
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`,
);
}
const dockerImage = cfg.agent?.sandbox?.docker?.image;
const updatedDockerImage = replaceLegacyName(dockerImage);
if (updatedDockerImage && updatedDockerImage !== dockerImage) {
next = {
...next,
agent: {
...next.agent,
sandbox: {
...next.agent?.sandbox,
const dockerImage = sandbox.docker?.image;
const updatedDockerImage = replaceLegacyName(dockerImage);
if (updatedDockerImage && updatedDockerImage !== dockerImage) {
updatedSandbox = {
...updatedSandbox,
docker: {
...next.agent?.sandbox?.docker,
...updatedSandbox.docker,
image: updatedDockerImage,
},
},
},
};
changes.push(`Updated agent.sandbox.docker.image → ${updatedDockerImage}`);
}
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.docker.image → ${updatedDockerImage}`,
);
}
const containerPrefix = cfg.agent?.sandbox?.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) {
next = {
...next,
agent: {
...next.agent,
sandbox: {
...next.agent?.sandbox,
const containerPrefix = sandbox.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (
updatedContainerPrefix &&
updatedContainerPrefix !== containerPrefix
) {
updatedSandbox = {
...updatedSandbox,
docker: {
...next.agent?.sandbox?.docker,
...updatedSandbox.docker,
containerPrefix: updatedContainerPrefix,
},
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`,
);
}
if (sandboxChanged) {
updatedDefaults = { ...updatedDefaults, sandbox: updatedSandbox };
defaultsChanged = true;
}
}
if (defaultsChanged) {
next = {
...next,
agents: {
...next.agents,
defaults: updatedDefaults,
},
},
};
changes.push(
`Updated agent.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`,
);
};
}
}
const list = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
if (list.length > 0) {
let listChanged = false;
const nextList = list.map((agent) => {
let updatedAgent = agent;
let agentChanged = false;
const updatedWorkspace = normalizeDefaultWorkspacePath(agent.workspace);
if (updatedWorkspace && updatedWorkspace !== agent.workspace) {
updatedAgent = { ...updatedAgent, workspace: updatedWorkspace };
agentChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") workspace → ${updatedWorkspace}`,
);
}
const sandbox = agent.sandbox;
if (sandbox) {
let updatedSandbox = sandbox;
let sandboxChanged = false;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(
sandbox.workspaceRoot,
);
if (
updatedWorkspaceRoot &&
updatedWorkspaceRoot !== sandbox.workspaceRoot
) {
updatedSandbox = {
...updatedSandbox,
workspaceRoot: updatedWorkspaceRoot,
};
sandboxChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") sandbox.workspaceRoot → ${updatedWorkspaceRoot}`,
);
}
const dockerImage = sandbox.docker?.image;
const updatedDockerImage = replaceLegacyName(dockerImage);
if (updatedDockerImage && updatedDockerImage !== dockerImage) {
updatedSandbox = {
...updatedSandbox,
docker: {
...updatedSandbox.docker,
image: updatedDockerImage,
},
};
sandboxChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") sandbox.docker.image → ${updatedDockerImage}`,
);
}
const containerPrefix = sandbox.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (
updatedContainerPrefix &&
updatedContainerPrefix !== containerPrefix
) {
updatedSandbox = {
...updatedSandbox,
docker: {
...updatedSandbox.docker,
containerPrefix: updatedContainerPrefix,
},
};
sandboxChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") sandbox.docker.containerPrefix → ${updatedContainerPrefix}`,
);
}
if (sandboxChanged) {
updatedAgent = { ...updatedAgent, sandbox: updatedSandbox };
agentChanged = true;
}
}
if (agentChanged) listChanged = true;
return agentChanged ? updatedAgent : agent;
});
if (listChanged) {
next = {
...next,
agents: {
...next.agents,
list: nextList,
},
};
}
}
return { config: next, changes };
@@ -170,18 +279,40 @@ export async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) {
typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.bind === "string"
? (legacySnapshot.parsed as ClawdbotConfig).gateway?.bind
: undefined;
const agentWorkspace =
typeof (legacySnapshot.parsed as ClawdbotConfig)?.agent?.workspace ===
"string"
? (legacySnapshot.parsed as ClawdbotConfig).agent?.workspace
const parsed = legacySnapshot.parsed as Record<string, unknown>;
const parsedAgents =
parsed.agents && typeof parsed.agents === "object"
? (parsed.agents as Record<string, unknown>)
: undefined;
const parsedDefaults =
parsedAgents?.defaults && typeof parsedAgents.defaults === "object"
? (parsedAgents.defaults as Record<string, unknown>)
: undefined;
const parsedLegacyAgent =
parsed.agent && typeof parsed.agent === "object"
? (parsed.agent as Record<string, unknown>)
: undefined;
const defaultWorkspace =
typeof parsedDefaults?.workspace === "string"
? parsedDefaults.workspace
: undefined;
const legacyWorkspace =
typeof parsedLegacyAgent?.workspace === "string"
? parsedLegacyAgent.workspace
: undefined;
const agentWorkspace = defaultWorkspace ?? legacyWorkspace;
const workspaceLabel = defaultWorkspace
? "agents.defaults.workspace"
: legacyWorkspace
? "agent.workspace"
: "agents.defaults.workspace";
note(
[
`- File exists at ${legacyConfigPath}`,
gatewayMode ? `- gateway.mode: ${gatewayMode}` : undefined,
gatewayBind ? `- gateway.bind: ${gatewayBind}` : undefined,
agentWorkspace ? `- agent.workspace: ${agentWorkspace}` : undefined,
agentWorkspace ? `- ${workspaceLabel}: ${agentWorkspace}` : undefined,
]
.filter(Boolean)
.join("\n"),
+32 -23
View File
@@ -96,12 +96,12 @@ async function dockerImageExists(image: string): Promise<boolean> {
}
function resolveSandboxDockerImage(cfg: ClawdbotConfig): string {
const image = cfg.agent?.sandbox?.docker?.image?.trim();
const image = cfg.agents?.defaults?.sandbox?.docker?.image?.trim();
return image ? image : DEFAULT_SANDBOX_IMAGE;
}
function resolveSandboxBrowserImage(cfg: ClawdbotConfig): string {
const image = cfg.agent?.sandbox?.browser?.image?.trim();
const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim();
return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE;
}
@@ -111,13 +111,16 @@ function updateSandboxDockerImage(
): ClawdbotConfig {
return {
...cfg,
agent: {
...cfg.agent,
sandbox: {
...cfg.agent?.sandbox,
docker: {
...cfg.agent?.sandbox?.docker,
image,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
sandbox: {
...cfg.agents?.defaults?.sandbox,
docker: {
...cfg.agents?.defaults?.sandbox?.docker,
image,
},
},
},
},
@@ -130,13 +133,16 @@ function updateSandboxBrowserImage(
): ClawdbotConfig {
return {
...cfg,
agent: {
...cfg.agent,
sandbox: {
...cfg.agent?.sandbox,
browser: {
...cfg.agent?.sandbox?.browser,
image,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
sandbox: {
...cfg.agents?.defaults?.sandbox,
browser: {
...cfg.agents?.defaults?.sandbox?.browser,
image,
},
},
},
},
@@ -198,7 +204,7 @@ export async function maybeRepairSandboxImages(
runtime: RuntimeEnv,
prompter: DoctorPrompter,
): Promise<ClawdbotConfig> {
const sandbox = cfg.agent?.sandbox;
const sandbox = cfg.agents?.defaults?.sandbox;
const mode = sandbox?.mode ?? "off";
if (!sandbox || mode === "off") return cfg;
@@ -224,7 +230,7 @@ export async function maybeRepairSandboxImages(
: undefined,
updateConfig: (image) => {
next = updateSandboxDockerImage(next, image);
changes.push(`Updated agent.sandbox.docker.image → ${image}`);
changes.push(`Updated agents.defaults.sandbox.docker.image → ${image}`);
},
},
runtime,
@@ -239,7 +245,9 @@ export async function maybeRepairSandboxImages(
buildScript: "scripts/sandbox-browser-setup.sh",
updateConfig: (image) => {
next = updateSandboxBrowserImage(next, image);
changes.push(`Updated agent.sandbox.browser.image → ${image}`);
changes.push(
`Updated agents.defaults.sandbox.browser.image → ${image}`,
);
},
},
runtime,
@@ -255,11 +263,12 @@ export async function maybeRepairSandboxImages(
}
export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) {
const globalSandbox = cfg.agent?.sandbox;
const agents = cfg.routing?.agents ?? {};
const globalSandbox = cfg.agents?.defaults?.sandbox;
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
const warnings: string[] = [];
for (const [agentId, agent] of Object.entries(agents)) {
for (const agent of agents) {
const agentId = agent.id;
const agentSandbox = agent.sandbox;
if (!agentSandbox) continue;
@@ -284,7 +293,7 @@ export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) {
if (overrides.length === 0) continue;
warnings.push(
`- routing.agents.${agentId}.sandbox: ${overrides.join(
`- agents.list (id "${agentId}") sandbox ${overrides.join(
"/",
)} overrides ignored (scope resolves to "shared").`,
);
+2 -4
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { note as clackNote } from "@clack/prompts";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import {
@@ -13,7 +14,6 @@ import {
resolveSessionTranscriptsDirForAgent,
resolveStorePath,
} from "../config/sessions.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const note = (message: string, title?: string) =>
@@ -136,9 +136,7 @@ export async function noteStateIntegrity(
const stateDir = resolveStateDir(env, homedir);
const defaultStateDir = path.join(homedir(), ".clawdbot");
const oauthDir = resolveOAuthDir(env, stateDir);
const agentId = normalizeAgentId(
cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const agentId = resolveDefaultAgentId(cfg);
const sessionsDir = resolveSessionTranscriptsDirForAgent(
agentId,
env,
+4 -2
View File
@@ -186,9 +186,11 @@ describe("doctor legacy state migrations", () => {
expect(result.changes).toEqual([]);
});
it("routes legacy state to routing.defaultAgentId", async () => {
it("routes legacy state to the default agent entry", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = { routing: { defaultAgentId: "alpha" } };
const cfg: ClawdbotConfig = {
agents: { list: [{ id: "alpha", default: true }] },
};
const legacySessionsDir = path.join(root, "sessions");
fs.mkdirSync(legacySessionsDir, { recursive: true });
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
+60 -47
View File
@@ -344,13 +344,15 @@ describe("doctor", () => {
raw: "{}",
parsed: {
gateway: { mode: "local", bind: "loopback" },
agent: {
workspace: "/Users/steipete/clawd",
sandbox: {
workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: {
image: "clawdbot-sandbox",
containerPrefix: "clawdbot-sbx",
agents: {
defaults: {
workspace: "/Users/steipete/clawd",
sandbox: {
workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: {
image: "clawdbot-sandbox",
containerPrefix: "clawdbot-sbx",
},
},
},
},
@@ -358,13 +360,15 @@ describe("doctor", () => {
valid: true,
config: {
gateway: { mode: "local", bind: "loopback" },
agent: {
workspace: "/Users/steipete/clawd",
sandbox: {
workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: {
image: "clawdbot-sandbox",
containerPrefix: "clawdbot-sbx",
agents: {
defaults: {
workspace: "/Users/steipete/clawd",
sandbox: {
workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: {
image: "clawdbot-sandbox",
containerPrefix: "clawdbot-sbx",
},
},
},
},
@@ -411,13 +415,15 @@ describe("doctor", () => {
migrateLegacyConfig.mockReturnValueOnce({
config: {
gateway: { mode: "local", bind: "loopback" },
agent: {
workspace: "/Users/steipete/clawd",
sandbox: {
workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: {
image: "clawdis-sandbox",
containerPrefix: "clawdis-sbx",
agents: {
defaults: {
workspace: "/Users/steipete/clawd",
sandbox: {
workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: {
image: "clawdis-sandbox",
containerPrefix: "clawdis-sbx",
},
},
},
},
@@ -438,11 +444,12 @@ describe("doctor", () => {
string,
unknown
>;
const agent = written.agent as Record<string, unknown>;
const sandbox = agent.sandbox as Record<string, unknown>;
const agents = written.agents as Record<string, unknown>;
const defaults = agents.defaults as Record<string, unknown>;
const sandbox = defaults.sandbox as Record<string, unknown>;
const docker = sandbox.docker as Record<string, unknown>;
expect(agent.workspace).toBe("/Users/steipete/clawd");
expect(defaults.workspace).toBe("/Users/steipete/clawd");
expect(sandbox.workspaceRoot).toBe("/Users/steipete/clawd/sandboxes");
expect(docker.image).toBe("clawdbot-sandbox");
expect(docker.containerPrefix).toBe("clawdbot-sbx");
@@ -456,15 +463,16 @@ describe("doctor", () => {
parsed: {},
valid: true,
config: {
agent: {
sandbox: {
mode: "all",
scope: "shared",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "shared",
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -474,7 +482,7 @@ describe("doctor", () => {
},
},
},
},
],
},
},
issues: [],
@@ -497,7 +505,7 @@ describe("doctor", () => {
([message, title]) =>
title === "Sandbox" &&
typeof message === "string" &&
message.includes("routing.agents.work.sandbox") &&
message.includes('agents.list (id "work") sandbox docker') &&
message.includes('scope resolves to "shared"'),
),
).toBe(true);
@@ -511,7 +519,7 @@ describe("doctor", () => {
parsed: {},
valid: true,
config: {
agent: { workspace: "/Users/steipete/clawd" },
agents: { defaults: { workspace: "/Users/steipete/clawd" } },
},
issues: [],
legacyIssues: [],
@@ -556,22 +564,26 @@ describe("doctor", () => {
exists: true,
raw: "{}",
parsed: {
agent: {
sandbox: {
mode: "non-main",
docker: {
image: "clawdbot-sandbox-common:bookworm-slim",
agents: {
defaults: {
sandbox: {
mode: "non-main",
docker: {
image: "clawdbot-sandbox-common:bookworm-slim",
},
},
},
},
},
valid: true,
config: {
agent: {
sandbox: {
mode: "non-main",
docker: {
image: "clawdbot-sandbox-common:bookworm-slim",
agents: {
defaults: {
sandbox: {
mode: "non-main",
docker: {
image: "clawdbot-sandbox-common:bookworm-slim",
},
},
},
},
@@ -614,8 +626,9 @@ describe("doctor", () => {
string,
unknown
>;
const agent = written.agent as Record<string, unknown>;
const sandbox = agent.sandbox as Record<string, unknown>;
const agents = written.agents as Record<string, unknown>;
const defaults = agents.defaults as Record<string, unknown>;
const sandbox = defaults.sandbox as Record<string, unknown>;
const docker = sandbox.docker as Record<string, unknown>;
expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim");
+12 -10
View File
@@ -4,6 +4,10 @@ import {
note as clackNote,
outro as clackOutro,
} from "@clack/prompts";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -25,7 +29,7 @@ import { collectProvidersStatusIssues } from "../infra/providers-status-issues.j
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath, sleep } from "../utils.js";
import { sleep } from "../utils.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
@@ -69,11 +73,7 @@ import {
shouldSuggestMemorySystem,
} from "./doctor-workspace.js";
import { healthCommand } from "./health.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
printWizardHeader,
} from "./onboard-helpers.js";
import { applyWizardMetadata, printWizardHeader } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
const intro = (message: string) =>
@@ -224,8 +224,9 @@ export async function doctorCommand(
}
}
const workspaceDir = resolveUserPath(
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
if (legacyWorkspace.legacyDirs.length > 0) {
@@ -415,8 +416,9 @@ export async function doctorCommand(
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (options.workspaceSuggestions !== false) {
const workspaceDir = resolveUserPath(
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
noteWorkspaceBackupTip(workspaceDir);
if (await shouldSuggestMemorySystem(workspaceDir)) {
@@ -8,28 +8,28 @@ import {
describe("applyGoogleGeminiModelDefault", () => {
it("sets gemini default when model is unset", () => {
const cfg: ClawdbotConfig = { agent: {} };
const cfg: ClawdbotConfig = { agents: { defaults: {} } };
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
expect(applied.next.agents?.defaults?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("overrides existing model", () => {
const cfg: ClawdbotConfig = {
agent: { model: "anthropic/claude-opus-4-5" },
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
expect(applied.next.agents?.defaults?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("no-ops when already gemini default", () => {
const cfg: ClawdbotConfig = {
agent: { model: GOOGLE_GEMINI_DEFAULT_MODEL },
agents: { defaults: { model: GOOGLE_GEMINI_DEFAULT_MODEL } },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(false);
+14 -7
View File
@@ -17,7 +17,7 @@ export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model)?.trim();
const current = resolvePrimaryModel(cfg.agents?.defaults?.model)?.trim();
if (current === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
@@ -25,12 +25,19 @@ export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): {
return {
next: {
...cfg,
agent: {
...cfg.agent,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: GOOGLE_GEMINI_DEFAULT_MODEL }
: { primary: GOOGLE_GEMINI_DEFAULT_MODEL },
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model:
cfg.agents?.defaults?.model &&
typeof cfg.agents.defaults.model === "object"
? {
...cfg.agents.defaults.model,
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
}
: { primary: GOOGLE_GEMINI_DEFAULT_MODEL },
},
},
},
changed: true,
+24 -8
View File
@@ -57,7 +57,9 @@ function makeRuntime() {
describe("models list/status", () => {
it("models status resolves z.ai alias to canonical zai", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const { modelsStatusCommand } = await import("./models/list.js");
@@ -69,7 +71,9 @@ describe("models list/status", () => {
});
it("models status plain outputs canonical zai model", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const { modelsStatusCommand } = await import("./models/list.js");
@@ -80,7 +84,9 @@ describe("models list/status", () => {
});
it("models list outputs canonical zai key for configured z.ai model", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
@@ -106,7 +112,9 @@ describe("models list/status", () => {
});
it("models list plain outputs canonical zai key", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
@@ -131,7 +139,9 @@ describe("models list/status", () => {
});
it("models list provider filter normalizes z.ai alias", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const models = [
@@ -171,7 +181,9 @@ describe("models list/status", () => {
});
it("models list provider filter normalizes Z.AI alias casing", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const models = [
@@ -211,7 +223,9 @@ describe("models list/status", () => {
});
it("models list provider filter normalizes z-ai alias", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const models = [
@@ -251,7 +265,9 @@ describe("models list/status", () => {
});
it("models list marks auth as unavailable when ZAI key is missing", async () => {
loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } });
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
+16 -10
View File
@@ -39,9 +39,11 @@ describe("models set + fallbacks", () => {
string,
unknown
>;
expect(written.agent).toEqual({
model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} },
expect(written.agents).toEqual({
defaults: {
model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} },
},
});
});
@@ -52,7 +54,7 @@ describe("models set + fallbacks", () => {
raw: "{}",
parsed: {},
valid: true,
config: { agent: { model: { fallbacks: [] } } },
config: { agents: { defaults: { model: { fallbacks: [] } } } },
issues: [],
legacyIssues: [],
});
@@ -67,9 +69,11 @@ describe("models set + fallbacks", () => {
string,
unknown
>;
expect(written.agent).toEqual({
model: { fallbacks: ["zai/glm-4.7"] },
models: { "zai/glm-4.7": {} },
expect(written.agents).toEqual({
defaults: {
model: { fallbacks: ["zai/glm-4.7"] },
models: { "zai/glm-4.7": {} },
},
});
});
@@ -95,9 +99,11 @@ describe("models set + fallbacks", () => {
string,
unknown
>;
expect(written.agent).toEqual({
model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} },
expect(written.agents).toEqual({
defaults: {
model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} },
},
});
});
});
+19 -11
View File
@@ -13,7 +13,7 @@ export async function modelsAliasesListCommand(
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const models = cfg.agent?.models ?? {};
const models = cfg.agents?.defaults?.models ?? {};
const aliases = Object.entries(models).reduce<Record<string, string>>(
(acc, [modelKey, entry]) => {
const alias = entry?.alias?.trim();
@@ -53,7 +53,7 @@ export async function modelsAliasesAddCommand(
const resolved = resolveModelTarget({ raw: modelRaw, cfg: loadConfig() });
const _updated = await updateConfig((cfg) => {
const modelKey = `${resolved.provider}/${resolved.model}`;
const nextModels = { ...cfg.agent?.models };
const nextModels = { ...cfg.agents?.defaults?.models };
for (const [key, entry] of Object.entries(nextModels)) {
const existing = entry?.alias?.trim();
if (existing && existing === alias && key !== modelKey) {
@@ -64,9 +64,12 @@ export async function modelsAliasesAddCommand(
nextModels[modelKey] = { ...existing, alias };
return {
...cfg,
agent: {
...cfg.agent,
models: nextModels,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models: nextModels,
},
},
};
});
@@ -81,7 +84,7 @@ export async function modelsAliasesRemoveCommand(
) {
const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => {
const nextModels = { ...cfg.agent?.models };
const nextModels = { ...cfg.agents?.defaults?.models };
let found = false;
for (const [key, entry] of Object.entries(nextModels)) {
if (entry?.alias?.trim() === alias) {
@@ -95,17 +98,22 @@ export async function modelsAliasesRemoveCommand(
}
return {
...cfg,
agent: {
...cfg.agent,
models: nextModels,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models: nextModels,
},
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (
!updated.agent?.models ||
Object.values(updated.agent.models).every((entry) => !entry?.alias?.trim())
!updated.agents?.defaults?.models ||
Object.values(updated.agents.defaults.models).every(
(entry) => !entry?.alias?.trim(),
)
) {
runtime.log("No aliases configured.");
}
+40 -31
View File
@@ -18,7 +18,7 @@ export async function modelsFallbacksListCommand(
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const fallbacks = cfg.agent?.model?.fallbacks ?? [];
const fallbacks = cfg.agents?.defaults?.model?.fallbacks ?? [];
if (opts.json) {
runtime.log(JSON.stringify({ fallbacks }, null, 2));
@@ -44,13 +44,13 @@ export async function modelsFallbacksAddCommand(
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
const nextModels = { ...cfg.agent?.models };
const nextModels = { ...cfg.agents?.defaults?.models };
if (!nextModels[targetKey]) nextModels[targetKey] = {};
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.model?.fallbacks ?? [];
const existing = cfg.agents?.defaults?.model?.fallbacks ?? [];
const existingKeys = existing
.map((entry) =>
resolveModelRefFromString({
@@ -64,28 +64,31 @@ export async function modelsFallbacksAddCommand(
if (existingKeys.includes(targetKey)) return cfg;
const existingModel = cfg.agent?.model as
const existingModel = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [...existing, targetKey],
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [...existing, targetKey],
},
models: nextModels,
},
models: nextModels,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(
`Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`,
`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`,
);
}
@@ -100,7 +103,7 @@ export async function modelsFallbacksRemoveCommand(
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.model?.fallbacks ?? [];
const existing = cfg.agents?.defaults?.model?.fallbacks ?? [];
const filtered = existing.filter((entry) => {
const resolvedEntry = resolveModelRefFromString({
raw: String(entry ?? ""),
@@ -118,19 +121,22 @@ export async function modelsFallbacksRemoveCommand(
throw new Error(`Fallback not found: ${targetKey}`);
}
const existingModel = cfg.agent?.model as
const existingModel = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: filtered,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: filtered,
},
},
},
};
@@ -138,24 +144,27 @@ export async function modelsFallbacksRemoveCommand(
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(
`Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`,
`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`,
);
}
export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
await updateConfig((cfg) => {
const existingModel = cfg.agent?.model as
const existingModel = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [],
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [],
},
},
},
};
+40 -31
View File
@@ -18,7 +18,7 @@ export async function modelsImageFallbacksListCommand(
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const fallbacks = cfg.agent?.imageModel?.fallbacks ?? [];
const fallbacks = cfg.agents?.defaults?.imageModel?.fallbacks ?? [];
if (opts.json) {
runtime.log(JSON.stringify({ fallbacks }, null, 2));
@@ -44,13 +44,13 @@ export async function modelsImageFallbacksAddCommand(
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
const nextModels = { ...cfg.agent?.models };
const nextModels = { ...cfg.agents?.defaults?.models };
if (!nextModels[targetKey]) nextModels[targetKey] = {};
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.imageModel?.fallbacks ?? [];
const existing = cfg.agents?.defaults?.imageModel?.fallbacks ?? [];
const existingKeys = existing
.map((entry) =>
resolveModelRefFromString({
@@ -64,28 +64,31 @@ export async function modelsImageFallbacksAddCommand(
if (existingKeys.includes(targetKey)) return cfg;
const existingModel = cfg.agent?.imageModel as
const existingModel = cfg.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [...existing, targetKey],
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [...existing, targetKey],
},
models: nextModels,
},
models: nextModels,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(
`Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`,
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
);
}
@@ -100,7 +103,7 @@ export async function modelsImageFallbacksRemoveCommand(
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.imageModel?.fallbacks ?? [];
const existing = cfg.agents?.defaults?.imageModel?.fallbacks ?? [];
const filtered = existing.filter((entry) => {
const resolvedEntry = resolveModelRefFromString({
raw: String(entry ?? ""),
@@ -118,19 +121,22 @@ export async function modelsImageFallbacksRemoveCommand(
throw new Error(`Image fallback not found: ${targetKey}`);
}
const existingModel = cfg.agent?.imageModel as
const existingModel = cfg.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: filtered,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: filtered,
},
},
},
};
@@ -138,24 +144,27 @@ export async function modelsImageFallbacksRemoveCommand(
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(
`Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`,
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
);
}
export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
await updateConfig((cfg) => {
const existingModel = cfg.agent?.imageModel as
const existingModel = cfg.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [],
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
imageModel: {
...(existingModel?.primary
? { primary: existingModel.primary }
: undefined),
fallbacks: [],
},
},
},
};
+5 -3
View File
@@ -63,9 +63,11 @@ const mocks = vi.hoisted(() => {
.mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]),
shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true),
loadConfig: vi.fn().mockReturnValue({
agent: {
model: { primary: "anthropic/claude-opus-4-5", fallbacks: [] },
models: { "anthropic/claude-opus-4-5": { alias: "Opus" } },
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5", fallbacks: [] },
models: { "anthropic/claude-opus-4-5": { alias: "Opus" } },
},
},
models: { providers: {} },
env: { shellEnv: { enabled: true } },
+7 -7
View File
@@ -290,10 +290,10 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
addEntry(resolvedDefault, "default");
const modelConfig = cfg.agent?.model as
const modelConfig = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
const imageModelConfig = cfg.agent?.imageModel as
const imageModelConfig = cfg.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| undefined;
const modelFallbacks =
@@ -333,7 +333,7 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
addEntry(resolved.ref, `img-fallback#${idx + 1}`);
});
for (const key of Object.keys(cfg.agent?.models ?? {})) {
for (const key of Object.keys(cfg.agents?.defaults?.models ?? {})) {
const parsed = parseModelRef(String(key ?? ""), DEFAULT_PROVIDER);
if (!parsed) continue;
addEntry(parsed, "configured");
@@ -623,11 +623,11 @@ export async function modelsStatusCommand(
defaultModel: DEFAULT_MODEL,
});
const modelConfig = cfg.agent?.model as
const modelConfig = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
const imageConfig = cfg.agent?.imageModel as
const imageConfig = cfg.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
@@ -645,14 +645,14 @@ export async function modelsStatusCommand(
: (imageConfig?.primary?.trim() ?? "");
const imageFallbacks =
typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : [];
const aliases = Object.entries(cfg.agent?.models ?? {}).reduce<
const aliases = Object.entries(cfg.agents?.defaults?.models ?? {}).reduce<
Record<string, string>
>((acc, [key, entry]) => {
const alias = entry?.alias?.trim();
if (alias) acc[alias] = key;
return acc;
}, {});
const allowed = Object.keys(cfg.agent?.models ?? {});
const allowed = Object.keys(cfg.agents?.defaults?.models ?? {});
const agentDir = resolveClawdbotAgentDir();
const store = ensureAuthProfileStore();
+11 -8
View File
@@ -327,14 +327,14 @@ export async function modelsScanCommand(
}
const _updated = await updateConfig((cfg) => {
const nextModels = { ...cfg.agent?.models };
const nextModels = { ...cfg.agents?.defaults?.models };
for (const entry of selected) {
if (!nextModels[entry]) nextModels[entry] = {};
}
for (const entry of selectedImages) {
if (!nextModels[entry]) nextModels[entry] = {};
}
const existingImageModel = cfg.agent?.imageModel as
const existingImageModel = cfg.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| undefined;
const nextImageModel =
@@ -346,12 +346,12 @@ export async function modelsScanCommand(
fallbacks: selectedImages,
...(opts.setImage ? { primary: selectedImages[0] } : {}),
}
: cfg.agent?.imageModel;
const existingModel = cfg.agent?.model as
: cfg.agents?.defaults?.imageModel;
const existingModel = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
const agent = {
...cfg.agent,
const defaults = {
...cfg.agents?.defaults,
model: {
...(existingModel?.primary
? { primary: existingModel.primary }
@@ -361,10 +361,13 @@ export async function modelsScanCommand(
},
...(nextImageModel ? { imageModel: nextImageModel } : {}),
models: nextModels,
} satisfies NonNullable<typeof cfg.agent>;
} satisfies NonNullable<NonNullable<typeof cfg.agents>["defaults"]>;
return {
...cfg,
agent,
agents: {
...cfg.agents,
defaults,
},
};
});
+16 -11
View File
@@ -9,26 +9,31 @@ export async function modelsSetImageCommand(
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const key = `${resolved.provider}/${resolved.model}`;
const nextModels = { ...cfg.agent?.models };
const nextModels = { ...cfg.agents?.defaults?.models };
if (!nextModels[key]) nextModels[key] = {};
const existingModel = cfg.agent?.imageModel as
const existingModel = cfg.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
imageModel: {
...(existingModel?.fallbacks
? { fallbacks: existingModel.fallbacks }
: undefined),
primary: key,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
imageModel: {
...(existingModel?.fallbacks
? { fallbacks: existingModel.fallbacks }
: undefined),
primary: key,
},
models: nextModels,
},
models: nextModels,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Image model: ${updated.agent?.imageModel?.primary ?? modelRaw}`);
runtime.log(
`Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`,
);
}
+16 -11
View File
@@ -6,26 +6,31 @@ export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const key = `${resolved.provider}/${resolved.model}`;
const nextModels = { ...cfg.agent?.models };
const nextModels = { ...cfg.agents?.defaults?.models };
if (!nextModels[key]) nextModels[key] = {};
const existingModel = cfg.agent?.model as
const existingModel = cfg.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
return {
...cfg,
agent: {
...cfg.agent,
model: {
...(existingModel?.fallbacks
? { fallbacks: existingModel.fallbacks }
: undefined),
primary: key,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: {
...(existingModel?.fallbacks
? { fallbacks: existingModel.fallbacks }
: undefined),
primary: key,
},
models: nextModels,
},
models: nextModels,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Default model: ${updated.agent?.model?.primary ?? modelRaw}`);
runtime.log(
`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`,
);
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function resolveModelTarget(params: {
export function buildAllowlistSet(cfg: ClawdbotConfig): Set<string> {
const allowed = new Set<string>();
const models = cfg.agent?.models ?? {};
const models = cfg.agents?.defaults?.models ?? {};
for (const raw of Object.keys(models)) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) continue;
+22 -15
View File
@@ -126,7 +126,7 @@ export function applyAuthProfileConfig(
export function applyMinimaxProviderConfig(
cfg: ClawdbotConfig,
): ClawdbotConfig {
const models = { ...cfg.agent?.models };
const models = { ...cfg.agents?.defaults?.models };
models["anthropic/claude-opus-4-5"] = {
...models["anthropic/claude-opus-4-5"],
alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus",
@@ -158,9 +158,12 @@ export function applyMinimaxProviderConfig(
return {
...cfg,
agent: {
...cfg.agent,
models,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
@@ -224,17 +227,21 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const next = applyMinimaxProviderConfig(cfg);
return {
...next,
agent: {
...next.agent,
model: {
...(next.agent?.model &&
"fallbacks" in (next.agent.model as Record<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "lmstudio/minimax-m2.1-gs32",
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(next.agents?.defaults?.model &&
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
? {
fallbacks: (
next.agents.defaults.model as { fallbacks?: string[] }
).fallbacks,
}
: undefined),
primary: "lmstudio/minimax-m2.1-gs32",
},
},
},
};
+6 -6
View File
@@ -36,13 +36,13 @@ export function guardCancel<T>(value: T, runtime: RuntimeEnv): T {
export function summarizeExistingConfig(config: ClawdbotConfig): string {
const rows: string[] = [];
if (config.agent?.workspace)
rows.push(`workspace: ${config.agent.workspace}`);
if (config.agent?.model) {
const defaults = config.agents?.defaults;
if (defaults?.workspace) rows.push(`workspace: ${defaults.workspace}`);
if (defaults?.model) {
const model =
typeof config.agent.model === "string"
? config.agent.model
: config.agent.model.primary;
typeof defaults.model === "string"
? defaults.model
: defaults.model.primary;
if (model) rows.push(`model: ${model}`);
}
if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`);
+12 -5
View File
@@ -96,14 +96,21 @@ export async function runNonInteractiveOnboarding(
}
const workspaceDir = resolveUserPath(
(opts.workspace ?? baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE).trim(),
(
opts.workspace ??
baseConfig.agents?.defaults?.workspace ??
DEFAULT_WORKSPACE
).trim(),
);
let nextConfig: ClawdbotConfig = {
...baseConfig,
agent: {
...baseConfig.agent,
workspace: workspaceDir,
agents: {
...baseConfig.agents,
defaults: {
...baseConfig.agents?.defaults,
workspace: workspaceDir,
},
},
gateway: {
...baseConfig.gateway,
@@ -311,7 +318,7 @@ export async function runNonInteractiveOnboarding(
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap),
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
});
const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
@@ -8,25 +8,29 @@ import {
describe("applyOpenAICodexModelDefault", () => {
it("sets openai-codex default when model is unset", () => {
const cfg: ClawdbotConfig = { agent: {} };
const cfg: ClawdbotConfig = { agents: { defaults: {} } };
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
});
it("sets openai-codex default when model is openai/*", () => {
const cfg: ClawdbotConfig = { agent: { model: "openai/gpt-5.2" } };
const cfg: ClawdbotConfig = {
agents: { defaults: { model: "openai/gpt-5.2" } },
};
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
});
it("does not override openai-codex/*", () => {
const cfg: ClawdbotConfig = { agent: { model: "openai-codex/gpt-5.2" } };
const cfg: ClawdbotConfig = {
agents: { defaults: { model: "openai-codex/gpt-5.2" } },
};
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
@@ -34,7 +38,7 @@ describe("applyOpenAICodexModelDefault", () => {
it("does not override non-openai models", () => {
const cfg: ClawdbotConfig = {
agent: { model: "anthropic/claude-opus-4-5" },
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
};
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false);
+14 -7
View File
@@ -26,19 +26,26 @@ export function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model);
const current = resolvePrimaryModel(cfg.agents?.defaults?.model);
if (!shouldSetOpenAICodexModel(current)) {
return { next: cfg, changed: false };
}
return {
next: {
...cfg,
agent: {
...cfg.agent,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL }
: { primary: OPENAI_CODEX_DEFAULT_MODEL },
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model:
cfg.agents?.defaults?.model &&
typeof cfg.agents.defaults.model === "object"
? {
...cfg.agents.defaults.model,
primary: OPENAI_CODEX_DEFAULT_MODEL,
}
: { primary: OPENAI_CODEX_DEFAULT_MODEL },
},
},
},
changed: true,
+6 -4
View File
@@ -12,10 +12,12 @@ vi.mock("../config/config.js", async (importOriginal) => {
return {
...actual,
loadConfig: () => ({
agent: {
model: { primary: "pi:opus" },
models: { "pi:opus": {} },
contextTokens: 32000,
agents: {
defaults: {
model: { primary: "pi:opus" },
models: { "pi:opus": {} },
contextTokens: 32000,
},
},
}),
};
+1 -1
View File
@@ -169,7 +169,7 @@ export async function sessionsCommand(
defaultModel: DEFAULT_MODEL,
});
const configContextTokens =
cfg.agent?.contextTokens ??
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(resolved.model) ??
DEFAULT_CONTEXT_TOKENS;
const configModel = resolved.model ?? DEFAULT_MODEL;
+11 -8
View File
@@ -48,25 +48,28 @@ export async function setupCommand(
const existingRaw = await readConfigFileRaw();
const cfg = existingRaw.parsed;
const agent = cfg.agent ?? {};
const defaults = cfg.agents?.defaults ?? {};
const workspace =
desiredWorkspace ?? agent.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
desiredWorkspace ?? defaults.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const next: ClawdbotConfig = {
...cfg,
agent: {
...agent,
workspace,
agents: {
...cfg.agents,
defaults: {
...defaults,
workspace,
},
},
};
if (!existingRaw.exists || agent.workspace !== workspace) {
if (!existingRaw.exists || defaults.workspace !== workspace) {
await writeConfigFile(next);
runtime.log(
!existingRaw.exists
? `Wrote ${CONFIG_PATH_CLAWDBOT}`
: `Updated ${CONFIG_PATH_CLAWDBOT} (set agent.workspace)`,
: `Updated ${CONFIG_PATH_CLAWDBOT} (set agents.defaults.workspace)`,
);
} else {
runtime.log(`Config OK: ${CONFIG_PATH_CLAWDBOT}`);
@@ -74,7 +77,7 @@ export async function setupCommand(
const ws = await ensureAgentWorkspace({
dir: workspace,
ensureBootstrapFiles: !next.agent?.skipBootstrap,
ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap,
});
runtime.log(`Workspace OK: ${ws.dir}`);
+1 -1
View File
@@ -86,7 +86,7 @@ export async function getStatusSummary(): Promise<StatusSummary> {
});
const configModel = resolved.model ?? DEFAULT_MODEL;
const configContextTokens =
cfg.agent?.contextTokens ??
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(configModel) ??
DEFAULT_CONTEXT_TOKENS;
+14 -10
View File
@@ -31,18 +31,18 @@ function canonicalizeAgentDir(agentDir: string): string {
function collectReferencedAgentIds(cfg: ClawdbotConfig): string[] {
const ids = new Set<string>();
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
const defaultAgentId =
cfg.routing?.defaultAgentId?.trim() || DEFAULT_AGENT_ID;
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
ids.add(normalizeAgentId(defaultAgentId));
const agents = cfg.routing?.agents;
if (agents && typeof agents === "object") {
for (const id of Object.keys(agents)) {
ids.add(normalizeAgentId(id));
}
for (const entry of agents) {
if (entry?.id) ids.add(normalizeAgentId(entry.id));
}
const bindings = cfg.routing?.bindings;
const bindings = cfg.bindings;
if (Array.isArray(bindings)) {
for (const binding of bindings) {
const id = binding?.agentId;
@@ -61,8 +61,12 @@ function resolveEffectiveAgentDir(
deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string },
): string {
const id = normalizeAgentId(agentId);
const configured = cfg.routing?.agents?.[id]?.agentDir?.trim();
if (configured) return resolveUserPath(configured);
const configured = Array.isArray(cfg.agents?.list)
? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id)
?.agentDir
: undefined;
const trimmed = configured?.trim();
if (trimmed) return resolveUserPath(trimmed);
const root = resolveStateDir(
deps?.env ?? process.env,
deps?.homedir ?? os.homedir,
@@ -102,7 +106,7 @@ export function formatDuplicateAgentDirError(
(d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`,
),
"",
"Fix: remove the shared routing.agents.*.agentDir override (or give each agent its own directory).",
"Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory).",
"If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.",
];
return lines.join("\n");
+197 -58
View File
@@ -80,7 +80,7 @@ describe("config identity defaults", () => {
process.env.HOME = previousHome;
});
it("derives mentionPatterns when identity is set", async () => {
it("does not derive mentionPatterns when identity is set", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -88,9 +88,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
routing: {},
},
null,
2,
@@ -103,13 +113,11 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
});
});
it("defaults ackReaction to identity emoji", async () => {
it("defaults ackReactionScope without setting ackReaction", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -117,7 +125,18 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
},
null,
@@ -130,12 +149,12 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("🦥");
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("defaults ackReaction to 👀 when identity is missing", async () => {
it("keeps ackReaction unset when identity is missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -155,7 +174,7 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("👀");
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
@@ -168,17 +187,22 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
groupChat: { mentionPatterns: ["@clawd"] },
},
],
},
messages: {
responsePrefix: "✅",
},
routing: {
groupChat: { mentionPatterns: ["@clawd"] },
},
},
null,
2,
@@ -191,7 +215,9 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("✅");
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual(["@clawd"]);
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
});
});
@@ -209,7 +235,6 @@ describe("config identity defaults", () => {
// legacy field should be ignored (moved to providers)
textChunkLimit: 9999,
},
routing: {},
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
@@ -251,9 +276,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: { responsePrefix: "" },
routing: {},
},
null,
2,
@@ -277,9 +312,7 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
messages: {},
routing: {},
},
null,
2,
@@ -292,10 +325,8 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.agent).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents).toBeUndefined();
expect(cfg.session).toBeUndefined();
});
});
@@ -308,9 +339,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Clawd", theme: "space lobster", emoji: "🦞" },
agents: {
list: [
{
id: "main",
identity: {
name: "Clawd",
theme: "space lobster",
emoji: "🦞",
},
},
],
},
messages: {},
routing: {},
},
null,
2,
@@ -411,7 +452,7 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({ agent: {} }, null, 2),
JSON.stringify({ agents: { defaults: {} } }, null, 2),
"utf-8",
);
@@ -419,7 +460,7 @@ describe("config pruning defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agent?.contextPruning?.mode).toBe("adaptive");
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive");
});
});
@@ -429,7 +470,11 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({ agent: { contextPruning: { mode: "off" } } }, null, 2),
JSON.stringify(
{ agents: { defaults: { contextPruning: { mode: "off" } } } },
null,
2,
),
"utf-8",
);
@@ -437,7 +482,7 @@ describe("config pruning defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agent?.contextPruning?.mode).toBe("off");
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off");
});
});
});
@@ -850,6 +895,97 @@ describe("legacy config detection", () => {
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { mentionPatterns: ["@clawd"] } },
});
expect(res.changes).toContain(
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
);
expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
expect(res.config?.routing?.groupChat?.mentionPatterns).toBeUndefined();
});
it("migrates routing agentToAgent/queue/transcribeAudio to tools/messages/audio", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: {
agentToAgent: { enabled: true, allow: ["main"] },
queue: { mode: "queue", cap: 3 },
transcribeAudio: { command: ["echo", "hi"], timeoutSeconds: 2 },
},
});
expect(res.changes).toContain(
"Moved routing.agentToAgent → tools.agentToAgent.",
);
expect(res.changes).toContain("Moved routing.queue → messages.queue.");
expect(res.changes).toContain(
"Moved routing.transcribeAudio → audio.transcription.",
);
expect(res.config?.tools?.agentToAgent).toEqual({
enabled: true,
allow: ["main"],
});
expect(res.config?.messages?.queue).toEqual({
mode: "queue",
cap: 3,
});
expect(res.config?.audio?.transcription).toEqual({
command: ["echo", "hi"],
timeoutSeconds: 2,
});
expect(res.config?.routing).toBeUndefined();
});
it("migrates agent config into agents.defaults and tools", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
agent: {
model: "openai/gpt-5.2",
tools: { allow: ["sessions.list"], deny: ["danger"] },
elevated: { enabled: true, allowFrom: { discord: ["user:1"] } },
bash: { timeoutSec: 12 },
sandbox: { tools: { allow: ["browser.open"] } },
subagents: { tools: { deny: ["sandbox"] } },
},
});
expect(res.changes).toContain("Moved agent.tools.allow → tools.allow.");
expect(res.changes).toContain("Moved agent.tools.deny → tools.deny.");
expect(res.changes).toContain("Moved agent.elevated → tools.elevated.");
expect(res.changes).toContain("Moved agent.bash → tools.bash.");
expect(res.changes).toContain(
"Moved agent.sandbox.tools → tools.sandbox.tools.",
);
expect(res.changes).toContain(
"Moved agent.subagents.tools → tools.subagents.tools.",
);
expect(res.changes).toContain("Moved agent → agents.defaults.");
expect(res.config?.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.2",
fallbacks: [],
});
expect(res.config?.tools?.allow).toEqual(["sessions.list"]);
expect(res.config?.tools?.deny).toEqual(["danger"]);
expect(res.config?.tools?.elevated).toEqual({
enabled: true,
allowFrom: { discord: ["user:1"] },
});
expect(res.config?.tools?.bash).toEqual({ timeoutSec: 12 });
expect(res.config?.tools?.sandbox?.tools).toEqual({
allow: ["browser.open"],
});
expect(res.config?.tools?.subagents?.tools).toEqual({
deny: ["sandbox"],
});
expect((res.config as { agent?: unknown }).agent).toBeUndefined();
});
it("rejects telegram.requireMention", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
@@ -1064,7 +1200,7 @@ describe("legacy config detection", () => {
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("agent.model");
expect(res.issues.some((i) => i.path === "agent.model")).toBe(true);
}
});
@@ -1095,22 +1231,25 @@ describe("legacy config detection", () => {
},
});
expect(res.config?.agent?.model?.primary).toBe("anthropic/claude-opus-4-5");
expect(res.config?.agent?.model?.fallbacks).toEqual([
expect(res.config?.agents?.defaults?.model?.primary).toBe(
"anthropic/claude-opus-4-5",
);
expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([
"openai/gpt-4.1-mini",
]);
expect(res.config?.agent?.imageModel?.primary).toBe("openai/gpt-4.1-mini");
expect(res.config?.agent?.imageModel?.fallbacks).toEqual([
expect(res.config?.agents?.defaults?.imageModel?.primary).toBe(
"openai/gpt-4.1-mini",
);
expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([
"anthropic/claude-opus-4-5",
]);
expect(
res.config?.agent?.models?.["anthropic/claude-opus-4-5"],
res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"],
).toMatchObject({ alias: "Opus" });
expect(res.config?.agent?.models?.["openai/gpt-4.1-mini"]).toBeTruthy();
expect(res.config?.agent?.allowedModels).toBeUndefined();
expect(res.config?.agent?.modelAliases).toBeUndefined();
expect(res.config?.agent?.modelFallbacks).toBeUndefined();
expect(res.config?.agent?.imageModelFallbacks).toBeUndefined();
expect(
res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"],
).toBeTruthy();
expect(res.config?.agent).toBeUndefined();
});
it("surfaces legacy issues in snapshot", async () => {
@@ -1135,21 +1274,21 @@ describe("legacy config detection", () => {
});
describe("multi-agent agentDir validation", () => {
it("rejects shared routing.agents.*.agentDir", async () => {
it("rejects shared agents.list agentDir", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const shared = path.join(os.tmpdir(), "clawdbot-shared-agentdir");
const res = validateConfigObject({
routing: {
agents: {
a: { agentDir: shared },
b: { agentDir: shared },
},
agents: {
list: [
{ id: "a", agentDir: shared },
{ id: "b", agentDir: shared },
],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "routing.agents")).toBe(true);
expect(res.issues.some((i) => i.path === "agents.list")).toBe(true);
expect(res.issues[0]?.message).toContain("Duplicate agentDir");
}
});
@@ -1162,13 +1301,13 @@ describe("multi-agent agentDir validation", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
routing: {
agents: {
a: { agentDir: "~/.clawdbot/agents/shared/agent" },
b: { agentDir: "~/.clawdbot/agents/shared/agent" },
},
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
agents: {
list: [
{ id: "a", agentDir: "~/.clawdbot/agents/shared/agent" },
{ id: "b", agentDir: "~/.clawdbot/agents/shared/agent" },
],
},
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
},
null,
2,
+17 -57
View File
@@ -24,56 +24,13 @@ export type SessionDefaultsOptions = {
warnState?: WarnState;
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const identity = cfg.identity;
if (!identity) return cfg;
const name = identity.name?.trim();
const routing = cfg.routing ?? {};
const groupChat = routing.groupChat ?? {};
let mutated = false;
const next: ClawdbotConfig = { ...cfg };
if (name && !groupChat.mentionPatterns) {
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
const re = parts.length ? parts.join("\\s+") : escapeRegExp(name);
const pattern = `\\b@?${re}\\b`;
next.routing = {
...(next.routing ?? routing),
groupChat: { ...groupChat, mentionPatterns: [pattern] },
};
mutated = true;
}
return mutated ? next : cfg;
}
export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const messages = cfg.messages;
const hasAckReaction = messages?.ackReaction !== undefined;
const hasAckScope = messages?.ackReactionScope !== undefined;
if (hasAckReaction && hasAckScope) return cfg;
if (hasAckScope) return cfg;
const fallbackEmoji = cfg.identity?.emoji?.trim() || "👀";
const nextMessages = messages ? { ...messages } : {};
let mutated = false;
if (!hasAckReaction) {
nextMessages.ackReaction = fallbackEmoji;
mutated = true;
}
if (!hasAckScope) {
nextMessages.ackReactionScope = "group-mentions";
mutated = true;
}
if (!mutated) return cfg;
nextMessages.ackReactionScope = "group-mentions";
return {
...cfg,
messages: nextMessages,
@@ -119,7 +76,7 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig {
}
export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const existingAgent = cfg.agent;
const existingAgent = cfg.agents?.defaults;
if (!existingAgent) return cfg;
const existingModels = existingAgent.models ?? {};
if (Object.keys(existingModels).length === 0) return cfg;
@@ -141,9 +98,9 @@ export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
return {
...cfg,
agent: {
...existingAgent,
models: nextModels,
agents: {
...cfg.agents,
defaults: { ...existingAgent, models: nextModels },
},
};
}
@@ -164,18 +121,21 @@ export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
export function applyContextPruningDefaults(
cfg: ClawdbotConfig,
): ClawdbotConfig {
const agent = cfg.agent;
if (!agent) return cfg;
const contextPruning = agent?.contextPruning;
const defaults = cfg.agents?.defaults;
if (!defaults) return cfg;
const contextPruning = defaults?.contextPruning;
if (contextPruning?.mode) return cfg;
return {
...cfg,
agent: {
...agent,
contextPruning: {
...contextPruning,
mode: "adaptive",
agents: {
...cfg.agents,
defaults: {
...defaults,
contextPruning: {
...contextPruning,
mode: "adaptive",
},
},
},
};
+1 -4
View File
@@ -14,7 +14,6 @@ import {
} from "./agent-dirs.js";
import {
applyContextPruningDefaults,
applyIdentityDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
@@ -165,9 +164,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
applyContextPruningDefaults(
applySessionDefaults(
applyLoggingDefaults(
applyMessageDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig),
),
applyMessageDefaults(validated.data as ClawdbotConfig),
),
),
),
+453 -16
View File
@@ -12,53 +12,179 @@ type LegacyConfigMigration = {
apply: (raw: Record<string, unknown>, changes: string[]) => void;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const getRecord = (value: unknown): Record<string, unknown> | null =>
isRecord(value) ? value : null;
const ensureRecord = (
root: Record<string, unknown>,
key: string,
): Record<string, unknown> => {
const existing = root[key];
if (isRecord(existing)) return existing;
const next: Record<string, unknown> = {};
root[key] = next;
return next;
};
const mergeMissing = (
target: Record<string, unknown>,
source: Record<string, unknown>,
) => {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) continue;
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (isRecord(existing) && isRecord(value)) {
mergeMissing(existing, value);
}
}
};
const getAgentsList = (agents: Record<string, unknown> | null) => {
const list = agents?.list;
return Array.isArray(list) ? list : [];
};
const resolveDefaultAgentIdFromRaw = (raw: Record<string, unknown>) => {
const agents = getRecord(raw.agents);
const list = getAgentsList(agents);
const defaultEntry = list.find(
(entry): entry is { id: string } =>
isRecord(entry) &&
entry.default === true &&
typeof entry.id === "string" &&
entry.id.trim() !== "",
);
if (defaultEntry) return defaultEntry.id.trim();
const routing = getRecord(raw.routing);
const routingDefault =
typeof routing?.defaultAgentId === "string"
? routing.defaultAgentId.trim()
: "";
if (routingDefault) return routingDefault;
const firstEntry = list.find(
(entry): entry is { id: string } =>
isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "",
);
if (firstEntry) return firstEntry.id.trim();
return "main";
};
const ensureAgentEntry = (
list: unknown[],
id: string,
): Record<string, unknown> => {
const normalized = id.trim();
const existing = list.find(
(entry): entry is Record<string, unknown> =>
isRecord(entry) &&
typeof entry.id === "string" &&
entry.id.trim() === normalized,
);
if (existing) return existing;
const created: Record<string, unknown> = { id: normalized };
list.push(created);
return created;
};
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
{
path: ["routing", "allowFrom"],
message:
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "bindings"],
message:
"routing.bindings was moved; use top-level bindings instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "agents"],
message:
"routing.agents was moved; use agents.list instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "defaultAgentId"],
message:
"routing.defaultAgentId was moved; use agents.list[].default instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "agentToAgent"],
message:
"routing.agentToAgent was moved; use tools.agentToAgent instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "groupChat", "requireMention"],
message:
'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdbot doctor` to migrate).',
},
{
path: ["routing", "groupChat", "mentionPatterns"],
message:
"routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "queue"],
message:
"routing.queue was moved; use messages.queue instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "transcribeAudio"],
message:
"routing.transcribeAudio was moved; use audio.transcription instead (run `clawdbot doctor` to migrate).",
},
{
path: ["telegram", "requireMention"],
message:
'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).',
},
{
path: ["identity"],
message:
"identity was moved; use agents.list[].identity instead (run `clawdbot doctor` to migrate).",
},
{
path: ["agent"],
message:
"agent.* was moved; use agents.defaults (and tools.* for tool/elevated/bash settings) instead (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "model"],
message:
"agent.model string was replaced by agent.model.primary/fallbacks and agent.models (run `clawdbot doctor` to migrate).",
"agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (run `clawdbot doctor` to migrate).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "imageModel"],
message:
"agent.imageModel string was replaced by agent.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
"agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "allowedModels"],
message:
"agent.allowedModels was replaced by agent.models (run `clawdbot doctor` to migrate).",
"agent.allowedModels was replaced by agents.defaults.models (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "modelAliases"],
message:
"agent.modelAliases was replaced by agent.models.*.alias (run `clawdbot doctor` to migrate).",
"agent.modelAliases was replaced by agents.defaults.models.*.alias (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "modelFallbacks"],
message:
"agent.modelFallbacks was replaced by agent.model.fallbacks (run `clawdbot doctor` to migrate).",
"agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "imageModelFallbacks"],
message:
"agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
},
{
path: ["gateway", "token"],
@@ -236,11 +362,11 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
describe:
"Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
apply: (raw, changes) => {
const agent =
raw.agent && typeof raw.agent === "object"
? (raw.agent as Record<string, unknown>)
: null;
const agentRoot = getRecord(raw.agent);
const defaults = getRecord(getRecord(raw.agents)?.defaults);
const agent = agentRoot ?? defaults;
if (!agent) return;
const label = agentRoot ? "agent" : "agents.defaults";
const legacyModel =
typeof agent.model === "string" ? String(agent.model) : undefined;
@@ -358,26 +484,32 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
agent.models = models;
if (legacyModel !== undefined) {
changes.push("Migrated agent.model string → agent.model.primary.");
changes.push(
`Migrated ${label}.model string → ${label}.model.primary.`,
);
}
if (legacyModelFallbacks.length > 0) {
changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks.");
changes.push(
`Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`,
);
}
if (legacyImageModel !== undefined) {
changes.push(
"Migrated agent.imageModel string → agent.imageModel.primary.",
`Migrated ${label}.imageModel string → ${label}.imageModel.primary.`,
);
}
if (legacyImageModelFallbacks.length > 0) {
changes.push(
"Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.",
`Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`,
);
}
if (legacyAllowed.length > 0) {
changes.push("Migrated agent.allowedModels → agent.models.");
changes.push(`Migrated ${label}.allowedModels → ${label}.models.`);
}
if (Object.keys(legacyAliases).length > 0) {
changes.push("Migrated agent.modelAliases → agent.models.*.alias.");
changes.push(
`Migrated ${label}.modelAliases → ${label}.models.*.alias.`,
);
}
delete agent.allowedModels;
@@ -386,6 +518,311 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
delete agent.imageModelFallbacks;
},
},
{
id: "routing.agents-v2",
describe: "Move routing.agents/defaultAgentId to agents.list",
apply: (raw, changes) => {
const routing = getRecord(raw.routing);
if (!routing) return;
const routingAgents = getRecord(routing.agents);
const agents = ensureRecord(raw, "agents");
const list = getAgentsList(agents);
if (routingAgents) {
for (const [rawId, entryRaw] of Object.entries(routingAgents)) {
const agentId = String(rawId ?? "").trim();
const entry = getRecord(entryRaw);
if (!agentId || !entry) continue;
const target = ensureAgentEntry(list, agentId);
const entryCopy: Record<string, unknown> = { ...entry };
if ("mentionPatterns" in entryCopy) {
const mentionPatterns = entryCopy.mentionPatterns;
const groupChat = ensureRecord(target, "groupChat");
if (groupChat.mentionPatterns === undefined) {
groupChat.mentionPatterns = mentionPatterns;
changes.push(
`Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`,
);
} else {
changes.push(
`Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`,
);
}
delete entryCopy.mentionPatterns;
}
const legacyGroupChat = getRecord(entryCopy.groupChat);
if (legacyGroupChat) {
const groupChat = ensureRecord(target, "groupChat");
mergeMissing(groupChat, legacyGroupChat);
delete entryCopy.groupChat;
}
const legacySandbox = getRecord(entryCopy.sandbox);
if (legacySandbox) {
const sandboxTools = getRecord(legacySandbox.tools);
if (sandboxTools) {
const tools = ensureRecord(target, "tools");
const sandbox = ensureRecord(tools, "sandbox");
const toolPolicy = ensureRecord(sandbox, "tools");
mergeMissing(toolPolicy, sandboxTools);
delete legacySandbox.tools;
changes.push(
`Moved routing.agents.${agentId}.sandbox.tools → agents.list (id "${agentId}").tools.sandbox.tools.`,
);
}
entryCopy.sandbox = legacySandbox;
}
mergeMissing(target, entryCopy);
}
delete routing.agents;
changes.push("Moved routing.agents → agents.list.");
}
const defaultAgentId =
typeof routing.defaultAgentId === "string"
? routing.defaultAgentId.trim()
: "";
if (defaultAgentId) {
const hasDefault = list.some(
(entry): entry is Record<string, unknown> =>
isRecord(entry) && entry.default === true,
);
if (!hasDefault) {
const entry = ensureAgentEntry(list, defaultAgentId);
entry.default = true;
changes.push(
`Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`,
);
} else {
changes.push(
"Removed routing.defaultAgentId (agents.list default already set).",
);
}
delete routing.defaultAgentId;
}
if (list.length > 0) {
agents.list = list;
}
if (Object.keys(routing).length === 0) {
delete raw.routing;
}
},
},
{
id: "routing.config-v2",
describe:
"Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio",
apply: (raw, changes) => {
const routing = getRecord(raw.routing);
if (!routing) return;
if (routing.bindings !== undefined) {
if (raw.bindings === undefined) {
raw.bindings = routing.bindings;
changes.push("Moved routing.bindings → bindings.");
} else {
changes.push("Removed routing.bindings (bindings already set).");
}
delete routing.bindings;
}
if (routing.agentToAgent !== undefined) {
const tools = ensureRecord(raw, "tools");
if (tools.agentToAgent === undefined) {
tools.agentToAgent = routing.agentToAgent;
changes.push("Moved routing.agentToAgent → tools.agentToAgent.");
} else {
changes.push(
"Removed routing.agentToAgent (tools.agentToAgent already set).",
);
}
delete routing.agentToAgent;
}
if (routing.queue !== undefined) {
const messages = ensureRecord(raw, "messages");
if (messages.queue === undefined) {
messages.queue = routing.queue;
changes.push("Moved routing.queue → messages.queue.");
} else {
changes.push("Removed routing.queue (messages.queue already set).");
}
delete routing.queue;
}
const groupChat = getRecord(routing.groupChat);
if (groupChat) {
const historyLimit = groupChat.historyLimit;
if (historyLimit !== undefined) {
const messages = ensureRecord(raw, "messages");
const messagesGroup = ensureRecord(messages, "groupChat");
if (messagesGroup.historyLimit === undefined) {
messagesGroup.historyLimit = historyLimit;
changes.push(
"Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.",
);
} else {
changes.push(
"Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).",
);
}
delete groupChat.historyLimit;
}
const mentionPatterns = groupChat.mentionPatterns;
if (mentionPatterns !== undefined) {
const messages = ensureRecord(raw, "messages");
const messagesGroup = ensureRecord(messages, "groupChat");
if (messagesGroup.mentionPatterns === undefined) {
messagesGroup.mentionPatterns = mentionPatterns;
changes.push(
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
);
} else {
changes.push(
"Removed routing.groupChat.mentionPatterns (messages.groupChat.mentionPatterns already set).",
);
}
delete groupChat.mentionPatterns;
}
if (Object.keys(groupChat).length === 0) {
delete routing.groupChat;
} else {
routing.groupChat = groupChat;
}
}
if (routing.transcribeAudio !== undefined) {
const audio = ensureRecord(raw, "audio");
if (audio.transcription === undefined) {
audio.transcription = routing.transcribeAudio;
changes.push("Moved routing.transcribeAudio → audio.transcription.");
} else {
changes.push(
"Removed routing.transcribeAudio (audio.transcription already set).",
);
}
delete routing.transcribeAudio;
}
if (Object.keys(routing).length === 0) {
delete raw.routing;
}
},
},
{
id: "agent.defaults-v2",
describe: "Move agent config to agents.defaults and tools",
apply: (raw, changes) => {
const agent = getRecord(raw.agent);
if (!agent) return;
const agents = ensureRecord(raw, "agents");
const defaults = getRecord(agents.defaults) ?? {};
const tools = ensureRecord(raw, "tools");
const agentTools = getRecord(agent.tools);
if (agentTools) {
if (tools.allow === undefined && agentTools.allow !== undefined) {
tools.allow = agentTools.allow;
changes.push("Moved agent.tools.allow → tools.allow.");
}
if (tools.deny === undefined && agentTools.deny !== undefined) {
tools.deny = agentTools.deny;
changes.push("Moved agent.tools.deny → tools.deny.");
}
}
const elevated = getRecord(agent.elevated);
if (elevated) {
if (tools.elevated === undefined) {
tools.elevated = elevated;
changes.push("Moved agent.elevated → tools.elevated.");
} else {
changes.push("Removed agent.elevated (tools.elevated already set).");
}
}
const bash = getRecord(agent.bash);
if (bash) {
if (tools.bash === undefined) {
tools.bash = bash;
changes.push("Moved agent.bash → tools.bash.");
} else {
changes.push("Removed agent.bash (tools.bash already set).");
}
}
const sandbox = getRecord(agent.sandbox);
if (sandbox) {
const sandboxTools = getRecord(sandbox.tools);
if (sandboxTools) {
const toolsSandbox = ensureRecord(tools, "sandbox");
const toolPolicy = ensureRecord(toolsSandbox, "tools");
mergeMissing(toolPolicy, sandboxTools);
delete sandbox.tools;
changes.push("Moved agent.sandbox.tools → tools.sandbox.tools.");
}
}
const subagents = getRecord(agent.subagents);
if (subagents) {
const subagentTools = getRecord(subagents.tools);
if (subagentTools) {
const toolsSubagents = ensureRecord(tools, "subagents");
const toolPolicy = ensureRecord(toolsSubagents, "tools");
mergeMissing(toolPolicy, subagentTools);
delete subagents.tools;
changes.push("Moved agent.subagents.tools → tools.subagents.tools.");
}
}
const agentCopy: Record<string, unknown> = structuredClone(agent);
delete agentCopy.tools;
delete agentCopy.elevated;
delete agentCopy.bash;
if (isRecord(agentCopy.sandbox)) delete agentCopy.sandbox.tools;
if (isRecord(agentCopy.subagents)) delete agentCopy.subagents.tools;
mergeMissing(defaults, agentCopy);
agents.defaults = defaults;
raw.agents = agents;
delete raw.agent;
changes.push("Moved agent → agents.defaults.");
},
},
{
id: "identity->agents.list",
describe: "Move identity to agents.list[].identity",
apply: (raw, changes) => {
const identity = getRecord(raw.identity);
if (!identity) return;
const agents = ensureRecord(raw, "agents");
const list = getAgentsList(agents);
const defaultId = resolveDefaultAgentIdFromRaw(raw);
const entry = ensureAgentEntry(list, defaultId);
if (entry.identity === undefined) {
entry.identity = identity;
changes.push(
`Moved identity → agents.list (id "${defaultId}").identity.`,
);
} else {
changes.push("Removed identity (agents.list identity already set).");
}
agents.list = list;
raw.agents = agents;
delete raw.identity;
},
},
];
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
+31 -21
View File
@@ -5,52 +5,62 @@ import type { ClawdbotConfig } from "./types.js";
describe("applyModelDefaults", () => {
it("adds default aliases when models are present", () => {
const cfg = {
agent: {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-5.2": {},
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-5.2": {},
},
},
},
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
"opus",
expect(
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
).toBe("opus");
expect(next.agents?.defaults?.models?.["openai/gpt-5.2"]?.alias).toBe(
"gpt",
);
expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt");
});
it("does not override existing aliases", () => {
const cfg = {
agent: {
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
"Opus",
);
expect(
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
).toBe("Opus");
});
it("respects explicit empty alias disables", () => {
const cfg = {
agent: {
models: {
"google/gemini-3-pro-preview": { alias: "" },
"google/gemini-3-flash-preview": {},
agents: {
defaults: {
models: {
"google/gemini-3-pro-preview": { alias: "" },
"google/gemini-3-flash-preview": {},
},
},
},
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe("");
expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe(
"gemini-flash",
);
expect(
next.agents?.defaults?.models?.["google/gemini-3-pro-preview"]?.alias,
).toBe("");
expect(
next.agents?.defaults?.models?.["google/gemini-3-flash-preview"]?.alias,
).toBe("gemini-flash");
});
});
+1 -1
View File
@@ -7,7 +7,7 @@ describe("config schema", () => {
const res = buildConfigSchema();
const schema = res.schema as { properties?: Record<string, unknown> };
expect(schema.properties?.gateway).toBeTruthy();
expect(schema.properties?.agent).toBeTruthy();
expect(schema.properties?.agents).toBeTruthy();
expect(res.uiHints.gateway?.label).toBe("Gateway");
expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true);
expect(res.version).toBeTruthy();
+38 -36
View File
@@ -24,13 +24,14 @@ export type ConfigSchemaResponse = {
};
const GROUP_LABELS: Record<string, string> = {
identity: "Identity",
wizard: "Wizard",
logging: "Logging",
gateway: "Gateway",
agent: "Agent",
agents: "Agents",
tools: "Tools",
bindings: "Bindings",
audio: "Audio",
models: "Models",
routing: "Routing",
messages: "Messages",
commands: "Commands",
session: "Session",
@@ -52,30 +53,31 @@ const GROUP_LABELS: Record<string, string> = {
};
const GROUP_ORDER: Record<string, number> = {
identity: 10,
wizard: 20,
gateway: 30,
agent: 40,
models: 50,
routing: 60,
messages: 70,
commands: 75,
session: 80,
cron: 90,
hooks: 100,
ui: 110,
browser: 120,
talk: 130,
telegram: 140,
discord: 150,
slack: 155,
signal: 160,
imessage: 170,
whatsapp: 180,
skills: 190,
discovery: 200,
presence: 210,
voicewake: 220,
agents: 40,
tools: 50,
bindings: 55,
audio: 60,
models: 70,
messages: 80,
commands: 85,
session: 90,
cron: 100,
hooks: 110,
ui: 120,
browser: 130,
talk: 140,
telegram: 150,
discord: 160,
slack: 165,
signal: 170,
imessage: 180,
whatsapp: 190,
skills: 200,
discovery: 210,
presence: 220,
voicewake: 230,
logging: 900,
};
@@ -90,14 +92,14 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"agent.workspace": "Workspace",
"agents.defaults.workspace": "Workspace",
"auth.profiles": "Auth Profiles",
"auth.order": "Auth Profile Order",
"agent.models": "Models",
"agent.model.primary": "Primary Model",
"agent.model.fallbacks": "Model Fallbacks",
"agent.imageModel.primary": "Image Model",
"agent.imageModel.fallbacks": "Image Model Fallbacks",
"agents.defaults.models": "Models",
"agents.defaults.model.primary": "Primary Model",
"agents.defaults.model.fallbacks": "Model Fallbacks",
"agents.defaults.imageModel.primary": "Image Model",
"agents.defaults.imageModel.fallbacks": "Image Model Fallbacks",
"commands.native": "Native Commands",
"commands.text": "Text Commands",
"commands.restart": "Allow Restart",
@@ -154,14 +156,14 @@ const FIELD_HELP: Record<string, string> = {
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order":
"Ordered auth profile IDs per provider (used for automatic failover).",
"agent.models":
"agents.defaults.models":
"Configured model catalog (keys are full provider/model IDs).",
"agent.model.primary": "Primary model (provider/model).",
"agent.model.fallbacks":
"agents.defaults.model.primary": "Primary model (provider/model).",
"agents.defaults.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.",
"agent.imageModel.primary":
"agents.defaults.imageModel.primary":
"Optional image model (provider/model) used when the primary model lacks image input.",
"agent.imageModel.fallbacks":
"agents.defaults.imageModel.fallbacks":
"Ordered fallback image models (provider/model).",
"commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).",
+7 -4
View File
@@ -217,12 +217,15 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
export function resolveMainSessionKey(cfg?: {
session?: { scope?: SessionScope; mainKey?: string };
routing?: { defaultAgentId?: string };
agents?: { list?: Array<{ id?: string; default?: boolean }> };
}): string {
if (cfg?.session?.scope === "global") return "global";
const agentId = normalizeAgentId(
cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const agents = cfg?.agents?.list ?? [];
const defaultAgentId =
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
const agentId = normalizeAgentId(defaultAgentId);
const mainKey =
(cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
return buildAgentMainSessionKey({ agentId, mainKey });
+230 -207
View File
@@ -91,6 +91,12 @@ export type AgentElevatedAllowFromConfig = {
webchat?: Array<string | number>;
};
export type IdentityConfig = {
name?: string;
theme?: string;
emoji?: string;
};
export type WhatsAppActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
@@ -762,83 +768,133 @@ export type GroupChatConfig = {
historyLimit?: number;
};
export type RoutingConfig = {
transcribeAudio?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
timeoutSeconds?: number;
export type QueueConfig = {
mode?: QueueMode;
byProvider?: QueueModeByProvider;
debounceMs?: number;
cap?: number;
drop?: QueueDropPolicy;
};
export type AgentToolsConfig = {
allow?: string[];
deny?: string[];
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
groupChat?: GroupChatConfig;
/** Default agent id when no binding matches. Default: "main". */
defaultAgentId?: string;
};
export type ToolsConfig = {
allow?: string[];
deny?: string[];
agentToAgent?: {
/** Enable agent-to-agent messaging tools. Default: false. */
enabled?: boolean;
/** Allowlist of agent ids or patterns (implementation-defined). */
allow?: string[];
};
agents?: Record<
string,
{
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
/** Per-agent override for group mention patterns. */
mentionPatterns?: string[];
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
/** Agent workspace access inside the sandbox. */
workspaceAccess?: "none" | "ro" | "rw";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
workspaceRoot?: string;
/** Docker-specific sandbox overrides for this agent. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser overrides for this agent. */
browser?: SandboxBrowserSettings;
/** Tool allow/deny policy for sandboxed sessions (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
/** Auto-prune overrides for this agent. */
prune?: SandboxPruneSettings;
};
tools?: {
allow?: string[];
deny?: string[];
};
}
>;
bindings?: Array<{
agentId: string;
match: {
provider: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
/** Elevated bash permissions for the host machine. */
elevated?: {
/** Enable or disable elevated mode (default: true). */
enabled?: boolean;
/** Approved senders for /elevated (per-provider allowlists). */
allowFrom?: AgentElevatedAllowFromConfig;
};
/** Bash tool defaults. */
bash?: {
/** Default time (ms) before a bash command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing bash commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
};
/** Sub-agent tool policy defaults (deny wins). */
subagents?: {
tools?: {
allow?: string[];
deny?: string[];
};
}>;
queue?: {
mode?: QueueMode;
byProvider?: QueueModeByProvider;
debounceMs?: number;
cap?: number;
drop?: QueueDropPolicy;
};
/** Sandbox tool policy defaults (deny wins). */
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
};
export type AgentConfig = {
id: string;
default?: boolean;
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: IdentityConfig;
groupChat?: GroupChatConfig;
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
/** Agent workspace access inside the sandbox. */
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
workspaceRoot?: string;
/** Docker-specific sandbox overrides for this agent. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser overrides for this agent. */
browser?: SandboxBrowserSettings;
/** Auto-prune overrides for this agent. */
prune?: SandboxPruneSettings;
};
tools?: AgentToolsConfig;
};
export type AgentsConfig = {
defaults?: AgentDefaultsConfig;
list?: AgentConfig[];
};
export type AgentBinding = {
agentId: string;
match: {
provider: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
};
};
export type AudioConfig = {
transcription?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
timeoutSeconds?: number;
};
};
export type MessagesConfig = {
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "")
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */
@@ -1097,6 +1153,113 @@ export type AgentContextPruningConfig = {
};
};
export type AgentDefaultsConfig = {
/** Primary model and fallbacks (provider/model). */
model?: AgentModelListConfig;
/** Optional image-capable model and fallbacks (provider/model). */
imageModel?: AgentModelListConfig;
/** Model catalog with optional aliases (full provider/model keys). */
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string;
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
skipBootstrap?: boolean;
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string;
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
contextPruning?: AgentContextPruningConfig;
/** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on";
/** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on";
/**
* Block streaming boundary:
* - "text_end": end of each assistant text content block (before tool calls)
* - "message_end": end of the whole assistant message (may include tool blocks)
*/
blockStreamingBreak?: "text_end" | "message_end";
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
blockStreamingChunk?: {
minChars?: number;
maxChars?: number;
breakPreference?: "paragraph" | "newline" | "sentence";
};
timeoutSeconds?: number;
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number;
typingIntervalSeconds?: number;
/** Typing indicator start mode (never|instant|thinking|message). */
typingMode?: TypingMode;
/** Periodic background heartbeat runs. */
heartbeat?: {
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
/** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
prompt?: string;
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
ackMaxChars?: number;
};
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
maxConcurrent?: number;
/** Sub-agent defaults (spawned via sessions_spawn). */
subagents?: {
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
maxConcurrent?: number;
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
archiveAfterMinutes?: number;
};
/** Optional sandbox settings for non-main sessions. */
sandbox?: {
/** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all";
/**
* Agent workspace access inside the sandbox.
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
* - "ro": mount the agent workspace read-only; disables write/edit tools
* - "rw": mount the agent workspace read/write; enables write/edit tools
*/
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */
workspaceRoot?: string;
/** Docker-specific sandbox settings. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser settings. */
browser?: SandboxBrowserSettings;
/** Auto-prune sandbox containers. */
prune?: SandboxPruneSettings;
};
};
export type ClawdbotConfig = {
auth?: AuthConfig;
env?: {
@@ -1115,11 +1278,6 @@ export type ClawdbotConfig = {
| { enabled?: boolean; timeoutMs?: number }
| undefined;
};
identity?: {
name?: string;
theme?: string;
emoji?: string;
};
wizard?: {
lastRunAt?: string;
lastRunVersion?: string;
@@ -1135,145 +1293,10 @@ export type ClawdbotConfig = {
};
skills?: SkillsConfig;
models?: ModelsConfig;
agent?: {
/** Primary model and fallbacks (provider/model). */
model?: AgentModelListConfig;
/** Optional image-capable model and fallbacks (provider/model). */
imageModel?: AgentModelListConfig;
/** Model catalog with optional aliases (full provider/model keys). */
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string;
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
skipBootstrap?: boolean;
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string;
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
contextPruning?: AgentContextPruningConfig;
/** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on";
/** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on";
/**
* Block streaming boundary:
* - "text_end": end of each assistant text content block (before tool calls)
* - "message_end": end of the whole assistant message (may include tool blocks)
*/
blockStreamingBreak?: "text_end" | "message_end";
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
blockStreamingChunk?: {
minChars?: number;
maxChars?: number;
breakPreference?: "paragraph" | "newline" | "sentence";
};
timeoutSeconds?: number;
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number;
typingIntervalSeconds?: number;
/** Typing indicator start mode (never|instant|thinking|message). */
typingMode?: TypingMode;
/** Periodic background heartbeat runs. */
heartbeat?: {
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
/** Delivery target (last|whatsapp|telegram|discord|signal|imessage|msteams|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
prompt?: string;
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
ackMaxChars?: number;
};
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
maxConcurrent?: number;
/** Sub-agent defaults (spawned via sessions_spawn). */
subagents?: {
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
maxConcurrent?: number;
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
archiveAfterMinutes?: number;
/** Tool allow/deny policy for sub-agent sessions (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
};
/** Bash tool defaults. */
bash?: {
/** Default time (ms) before a bash command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing bash commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
};
/** Elevated bash permissions for the host machine. */
elevated?: {
/** Enable or disable elevated mode (default: true). */
enabled?: boolean;
/** Approved senders for /elevated (per-provider allowlists). */
allowFrom?: AgentElevatedAllowFromConfig;
};
/** Optional sandbox settings for non-main sessions. */
sandbox?: {
/** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all";
/**
* Agent workspace access inside the sandbox.
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
* - "ro": mount the agent workspace read-only; disables write/edit tools
* - "rw": mount the agent workspace read/write; enables write/edit tools
*/
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */
workspaceRoot?: string;
/** Docker-specific sandbox settings. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser settings. */
browser?: SandboxBrowserSettings;
/** Tool allow/deny policy (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
/** Auto-prune sandbox containers. */
prune?: SandboxPruneSettings;
};
/** Global tool allow/deny policy for all providers (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
};
routing?: RoutingConfig;
agents?: AgentsConfig;
tools?: ToolsConfig;
bindings?: AgentBinding[];
audio?: AudioConfig;
messages?: MessagesConfig;
commands?: CommandsConfig;
session?: SessionConfig;
+3 -9
View File
@@ -2,11 +2,7 @@ import {
findDuplicateAgentDirs,
formatDuplicateAgentDirError,
} from "./agent-dirs.js";
import {
applyIdentityDefaults,
applyModelDefaults,
applySessionDefaults,
} from "./defaults.js";
import { applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js";
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
import { ClawdbotSchema } from "./zod-schema.js";
@@ -42,7 +38,7 @@ export function validateConfigObject(
ok: false,
issues: [
{
path: "routing.agents",
path: "agents.list",
message: formatDuplicateAgentDirError(duplicates),
},
],
@@ -51,9 +47,7 @@ export function validateConfigObject(
return {
ok: true,
config: applyModelDefaults(
applySessionDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig),
),
applySessionDefaults(validated.data as ClawdbotConfig),
),
};
}
+287 -263
View File
@@ -61,6 +61,14 @@ const GroupChatSchema = z
})
.optional();
const IdentitySchema = z
.object({
name: z.string().optional(),
theme: z.string().optional(),
emoji: z.string().optional(),
})
.optional();
const QueueModeSchema = z.union([
z.literal("steer"),
z.literal("followup"),
@@ -133,6 +141,16 @@ const QueueModeBySurfaceSchema = z
})
.optional();
const QueueSchema = z
.object({
mode: QueueModeSchema.optional(),
byProvider: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(),
})
.optional();
const TranscribeAudioSchema = z
.object({
command: z.array(z.string()),
@@ -554,6 +572,8 @@ const MessagesSchema = z
.object({
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all"])
@@ -667,96 +687,140 @@ const ToolPolicySchema = z
})
.optional();
const RoutingSchema = z
const ElevatedAllowFromSchema = z
.object({
groupChat: GroupChatSchema,
transcribeAudio: TranscribeAudioSchema,
defaultAgentId: z.string().optional(),
whatsapp: z.array(z.string()).optional(),
telegram: z.array(z.union([z.string(), z.number()])).optional(),
discord: z.array(z.union([z.string(), z.number()])).optional(),
slack: z.array(z.union([z.string(), z.number()])).optional(),
signal: z.array(z.union([z.string(), z.number()])).optional(),
imessage: z.array(z.union([z.string(), z.number()])).optional(),
webchat: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional();
const AgentSandboxSchema = z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([z.literal("session"), z.literal("agent"), z.literal("shared")])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
prune: SandboxPruneSchema,
})
.optional();
const AgentToolsSchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
})
.optional(),
})
.optional();
const AgentEntrySchema = z.object({
id: z.string(),
default: z.boolean().optional(),
name: z.string().optional(),
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
identity: IdentitySchema,
groupChat: GroupChatSchema,
subagents: z
.object({
allowAgents: z.array(z.string()).optional(),
})
.optional(),
sandbox: AgentSandboxSchema,
tools: AgentToolsSchema,
});
const ToolsSchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
agentToAgent: z
.object({
enabled: z.boolean().optional(),
allow: z.array(z.string()).optional(),
})
.optional(),
agents: z
.record(
z.string(),
z
.object({
name: z.string().optional(),
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
mentionPatterns: z.array(z.string()).optional(),
subagents: z
.object({
allowAgents: z.array(z.string()).optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("non-main"),
z.literal("all"),
])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
tools: ToolPolicySchema,
prune: SandboxPruneSchema,
})
.optional(),
tools: ToolPolicySchema,
})
.optional(),
)
.optional(),
bindings: z
.array(
z.object({
agentId: z.string(),
match: z.object({
provider: z.string(),
accountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("dm"),
z.literal("group"),
z.literal("channel"),
]),
id: z.string(),
})
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
}),
}),
)
.optional(),
queue: z
elevated: z
.object({
mode: QueueModeSchema.optional(),
byProvider: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(),
enabled: z.boolean().optional(),
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
bash: z
.object({
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
})
.optional(),
subagents: z
.object({
tools: ToolPolicySchema,
})
.optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
})
.optional(),
})
.optional();
const AgentsSchema = z
.object({
defaults: z.lazy(() => AgentDefaultsSchema).optional(),
list: z.array(AgentEntrySchema).optional(),
})
.optional();
const BindingsSchema = z
.array(
z.object({
agentId: z.string(),
match: z.object({
provider: z.string(),
accountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("dm"),
z.literal("group"),
z.literal("channel"),
]),
id: z.string(),
})
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
}),
}),
)
.optional();
const AudioSchema = z
.object({
transcription: TranscribeAudioSchema,
})
.optional();
@@ -832,6 +896,145 @@ const HooksGmailSchema = z
})
.optional();
const AgentDefaultsSchema = z
.object({
model: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
imageModel: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
models: z
.record(
z.string(),
z.object({
alias: z.string().optional(),
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
params: z.record(z.string(), z.unknown()).optional(),
}),
)
.optional(),
workspace: z.string().optional(),
skipBootstrap: z.boolean().optional(),
userTimezone: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
contextPruning: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("adaptive"),
z.literal("aggressive"),
])
.optional(),
keepLastAssistants: z.number().int().nonnegative().optional(),
softTrimRatio: z.number().min(0).max(1).optional(),
hardClearRatio: z.number().min(0).max(1).optional(),
minPrunableToolChars: z.number().int().nonnegative().optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
softTrim: z
.object({
maxChars: z.number().int().nonnegative().optional(),
headChars: z.number().int().nonnegative().optional(),
tailChars: z.number().int().nonnegative().optional(),
})
.optional(),
hardClear: z
.object({
enabled: z.boolean().optional(),
placeholder: z.string().optional(),
})
.optional(),
})
.optional(),
thinkingDefault: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingDefault: z
.union([z.literal("off"), z.literal("on")])
.optional(),
blockStreamingBreak: z
.union([z.literal("text_end"), z.literal("message_end")])
.optional(),
blockStreamingChunk: z
.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([
z.literal("paragraph"),
z.literal("newline"),
z.literal("sentence"),
])
.optional(),
})
.optional(),
timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
.union([
z.literal("never"),
z.literal("instant"),
z.literal("thinking"),
z.literal("message"),
])
.optional(),
heartbeat: HeartbeatSchema,
maxConcurrent: z.number().int().positive().optional(),
subagents: z
.object({
maxConcurrent: z.number().int().positive().optional(),
archiveAfterMinutes: z.number().int().positive().optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
prune: SandboxPruneSchema,
})
.optional(),
})
.optional();
export const ClawdbotSchema = z.object({
env: z
.object({
@@ -845,13 +1048,6 @@ export const ClawdbotSchema = z.object({
})
.catchall(z.string())
.optional(),
identity: z
.object({
name: z.string().optional(),
theme: z.string().optional(),
emoji: z.string().optional(),
})
.optional(),
wizard: z
.object({
lastRunAt: z.string().optional(),
@@ -954,182 +1150,10 @@ export const ClawdbotSchema = z.object({
})
.optional(),
models: ModelsConfigSchema,
agent: z
.object({
model: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
imageModel: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
models: z
.record(
z.string(),
z.object({
alias: z.string().optional(),
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
params: z.record(z.string(), z.unknown()).optional(),
}),
)
.optional(),
workspace: z.string().optional(),
skipBootstrap: z.boolean().optional(),
userTimezone: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
contextPruning: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("adaptive"),
z.literal("aggressive"),
])
.optional(),
keepLastAssistants: z.number().int().nonnegative().optional(),
softTrimRatio: z.number().min(0).max(1).optional(),
hardClearRatio: z.number().min(0).max(1).optional(),
minPrunableToolChars: z.number().int().nonnegative().optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
softTrim: z
.object({
maxChars: z.number().int().nonnegative().optional(),
headChars: z.number().int().nonnegative().optional(),
tailChars: z.number().int().nonnegative().optional(),
})
.optional(),
hardClear: z
.object({
enabled: z.boolean().optional(),
placeholder: z.string().optional(),
})
.optional(),
})
.optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
thinkingDefault: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingDefault: z
.union([z.literal("off"), z.literal("on")])
.optional(),
blockStreamingBreak: z
.union([z.literal("text_end"), z.literal("message_end")])
.optional(),
blockStreamingChunk: z
.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([
z.literal("paragraph"),
z.literal("newline"),
z.literal("sentence"),
])
.optional(),
})
.optional(),
timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
.union([
z.literal("never"),
z.literal("instant"),
z.literal("thinking"),
z.literal("message"),
])
.optional(),
heartbeat: HeartbeatSchema,
maxConcurrent: z.number().int().positive().optional(),
subagents: z
.object({
maxConcurrent: z.number().int().positive().optional(),
archiveAfterMinutes: z.number().int().positive().optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
})
.optional(),
bash: z
.object({
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
})
.optional(),
elevated: z
.object({
enabled: z.boolean().optional(),
allowFrom: z
.object({
whatsapp: z.array(z.string()).optional(),
telegram: z.array(z.union([z.string(), z.number()])).optional(),
discord: z.array(z.union([z.string(), z.number()])).optional(),
slack: z.array(z.union([z.string(), z.number()])).optional(),
signal: z.array(z.union([z.string(), z.number()])).optional(),
imessage: z.array(z.union([z.string(), z.number()])).optional(),
msteams: z.array(z.union([z.string(), z.number()])).optional(),
webchat: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
tools: ToolPolicySchema,
prune: SandboxPruneSchema,
})
.optional(),
})
.optional(),
routing: RoutingSchema,
agents: AgentsSchema,
tools: ToolsSchema,
bindings: BindingsSchema,
audio: AudioSchema,
messages: MessagesSchema,
commands: CommandsSchema,
session: SessionSchema,
+12 -4
View File
@@ -63,9 +63,11 @@ function makeCfg(
overrides: Partial<ClawdbotConfig> = {},
): ClawdbotConfig {
const base: ClawdbotConfig = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: storePath, mainKey: "main" },
} as ClawdbotConfig;
@@ -738,7 +740,13 @@ describe("runCronIsolatedAgentTurn", () => {
});
const cfg = makeCfg(home, storePath);
cfg.agent = { ...cfg.agent, heartbeat: { ackMaxChars: 0 } };
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
heartbeat: { ackMaxChars: 0 },
},
};
const res = await runCronIsolatedAgentTurn({
cfg,
+5 -5
View File
@@ -269,12 +269,11 @@ export async function runCronIsolatedAgentTurn(params: {
sessionKey: string;
lane?: string;
}): Promise<RunCronAgentTurnResult> {
const agentCfg = params.cfg.agent;
const workspaceDirRaw =
params.cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const agentCfg = params.cfg.agents?.defaults;
const workspaceDirRaw = agentCfg?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !params.cfg.agent?.skipBootstrap,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;
@@ -521,7 +520,8 @@ export async function runCronIsolatedAgentTurn(params: {
// This allows cron jobs to silently ack when nothing to report but still deliver
// actual content when there is something to say.
const ackMaxChars =
params.cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
params.cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
const skipHeartbeatDelivery =
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
+39 -17
View File
@@ -3,6 +3,7 @@ import { ChannelType, MessageType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
const reactMock = vi.fn();
const updateLastRouteMock = vi.fn();
const dispatchMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
@@ -10,6 +11,9 @@ const upsertPairingRequestMock = vi.fn();
vi.mock("./send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
reactMessageDiscord: async (...args: unknown[]) => {
reactMock(...args);
},
}));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
@@ -48,11 +52,15 @@ describe("discord tool result dispatch", () => {
it("sends status replies with responsePrefix", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
messages: { responsePrefix: "PFX" },
discord: { dm: { enabled: true, policy: "open" } },
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const runtimeError = vi.fn();
@@ -114,10 +122,14 @@ describe("discord tool result dispatch", () => {
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
@@ -184,15 +196,19 @@ describe("discord tool result dispatch", () => {
it("accepts guild messages when mentionPatterns match", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
messages: { responsePrefix: "PFX" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: true } },
},
routing: {
allowFrom: [],
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
},
} as ReturnType<typeof import("../config/config.js").loadConfig>;
@@ -271,14 +287,18 @@ describe("discord tool result dispatch", () => {
});
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
messages: { responsePrefix: "PFX" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: false } },
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
@@ -377,19 +397,21 @@ describe("discord tool result dispatch", () => {
});
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
messages: { responsePrefix: "PFX" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: false } },
},
routing: {
allowFrom: [],
bindings: [
{ agentId: "support", match: { provider: "discord", guildId: "g1" } },
],
},
bindings: [
{ agentId: "support", match: { provider: "discord", guildId: "g1" } },
],
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
+2 -1
View File
@@ -17,6 +17,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import type { APIAttachment } from "discord-api-types/v10";
import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10";
import { resolveAckReaction } from "../agents/identity.js";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
@@ -501,7 +502,6 @@ export function createDiscordMessageHandler(params: {
guildEntries,
} = params;
const logger = getChildLogger({ module: "discord-auto-reply" });
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = discordConfig?.groupPolicy ?? "open";
@@ -842,6 +842,7 @@ export function createDiscordMessageHandler(params: {
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
}
const ackReaction = resolveAckReaction(cfg, route.agentId);
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
+3 -3
View File
@@ -14,10 +14,10 @@ describe("diffConfigPaths", () => {
});
it("captures array changes", () => {
const prev = { routing: { groupChat: { mentionPatterns: ["a"] } } };
const next = { routing: { groupChat: { mentionPatterns: ["b"] } } };
const prev = { messages: { groupChat: { mentionPatterns: ["a"] } } };
const next = { messages: { groupChat: { mentionPatterns: ["b"] } } };
const paths = diffConfigPaths(prev, next);
expect(paths).toContain("routing.groupChat.mentionPatterns");
expect(paths).toContain("messages.groupChat.mentionPatterns");
});
});
+9 -4
View File
@@ -64,7 +64,11 @@ const RELOAD_RULES: ReloadRule[] = [
{ prefix: "gateway.reload", kind: "none" },
{ prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] },
{ prefix: "hooks", kind: "hot", actions: ["reload-hooks"] },
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
{
prefix: "agents.defaults.heartbeat",
kind: "hot",
actions: ["restart-heartbeat"],
},
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
{
prefix: "browser",
@@ -78,12 +82,13 @@ const RELOAD_RULES: ReloadRule[] = [
{ prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] },
{ prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] },
{ prefix: "msteams", kind: "hot", actions: ["restart-provider:msteams"] },
{ prefix: "identity", kind: "none" },
{ prefix: "agents", kind: "none" },
{ prefix: "tools", kind: "none" },
{ prefix: "bindings", kind: "none" },
{ prefix: "audio", kind: "none" },
{ prefix: "wizard", kind: "none" },
{ prefix: "logging", kind: "none" },
{ prefix: "models", kind: "none" },
{ prefix: "agent", kind: "none" },
{ prefix: "routing", kind: "none" },
{ prefix: "messages", kind: "none" },
{ prefix: "session", kind: "none" },
{ prefix: "whatsapp", kind: "none" },

Some files were not shown because too many files have changed in this diff Show More