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

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,

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

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

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;

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

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;

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

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

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

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").`,
);

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,

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

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

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)) {

View File

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

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,

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 = {

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": {} },
},
});
});
});

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

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: [],
},
},
},
};

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: [],
},
},
},
};

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

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

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,
},
};
});

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}`,
);
}

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}`,
);
}

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;

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",
},
},
},
};

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}`);

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;

View File

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

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,

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,
},
},
}),
};

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;

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}`);

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;