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

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