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

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

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

View File

@@ -857,7 +857,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
).items;
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agent?.thinkingDefault;
const configured = cfg.agents?.defaults?.thinkingDefault;
if (configured) {
thinkingLevel = configured;
} else {

View File

@@ -61,7 +61,7 @@ export const chatHandlers: GatewayRequestHandlers = {
).items;
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agent?.thinkingDefault;
const configured = cfg.agents?.defaults?.thinkingDefault;
if (configured) {
thinkingLevel = configured;
} else {

View File

@@ -1,9 +1,11 @@
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../../agents/workspace.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { resolveUserPath } from "../../utils.js";
import {
ErrorCodes,
errorShape,
@@ -28,8 +30,10 @@ export const skillsHandlers: GatewayRequestHandlers = {
return;
}
const cfg = loadConfig();
const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspaceDir = resolveUserPath(workspaceDirRaw);
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
});
@@ -53,7 +57,10 @@ export const skillsHandlers: GatewayRequestHandlers = {
timeoutMs?: number;
};
const cfg = loadConfig();
const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspaceDirRaw = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const result = await installSkill({
workspaceDir: workspaceDirRaw,
skillName: p.name,

View File

@@ -11,12 +11,11 @@ installGatewayTestHooks();
describe("gateway server agents", () => {
test("lists configured agents via agents.list RPC", async () => {
testState.routingConfig = {
defaultAgentId: "work",
agents: {
work: { name: "Work" },
home: { name: "Home" },
},
testState.agentsConfig = {
list: [
{ id: "work", name: "Work", default: true },
{ id: "home", name: "Home" },
],
};
const { ws } = await startServerWithClient();

View File

@@ -210,7 +210,7 @@ describe("gateway hot reload", () => {
gmail: { account: "me@example.com" },
},
cron: { enabled: true, store: "/tmp/cron.json" },
agent: { heartbeat: { every: "1m" }, maxConcurrent: 2 },
agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } },
browser: { enabled: true, controlUrl: "http://127.0.0.1:18791" },
web: { enabled: true },
telegram: { botToken: "token" },
@@ -224,7 +224,7 @@ describe("gateway hot reload", () => {
changedPaths: [
"hooks.gmail.account",
"cron.enabled",
"agent.heartbeat.every",
"agents.defaults.heartbeat.every",
"browser.enabled",
"web.enabled",
"telegram.botToken",

View File

@@ -328,12 +328,8 @@ describe("gateway server sessions", () => {
testState.sessionConfig = {
store: path.join(dir, "{agentId}", "sessions.json"),
};
testState.routingConfig = {
defaultAgentId: "home",
agents: {
home: {},
work: {},
},
testState.agentsConfig = {
list: [{ id: "home", default: true }, { id: "work" }],
};
const homeDir = path.join(dir, "home");
const workDir = path.join(dir, "work");

View File

@@ -687,10 +687,13 @@ export async function startGatewayServer(
{ controller: AbortController; sessionId: string; sessionKey: string }
>();
setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency("main", cfgAtStart.agent?.maxConcurrent ?? 1);
setCommandLaneConcurrency(
"main",
cfgAtStart.agents?.defaults?.maxConcurrent ?? 1,
);
setCommandLaneConcurrency(
"subagent",
cfgAtStart.agent?.subagents?.maxConcurrent ?? 1,
cfgAtStart.agents?.defaults?.subagents?.maxConcurrent ?? 1,
);
const cronLogger = getChildLogger({
@@ -1975,10 +1978,13 @@ export async function startGatewayServer(
}
setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1);
setCommandLaneConcurrency(
"main",
nextConfig.agents?.defaults?.maxConcurrent ?? 1,
);
setCommandLaneConcurrency(
"subagent",
nextConfig.agent?.subagents?.maxConcurrent ?? 1,
nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1,
);
if (plan.hotReasons.length > 0) {

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
@@ -231,11 +232,11 @@ function listExistingAgentIdsFromDisk(): string[] {
function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
const ids = new Set<string>();
const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
ids.add(defaultId);
const agents = cfg.routing?.agents;
if (agents && typeof agents === "object") {
for (const id of Object.keys(agents)) ids.add(normalizeAgentId(id));
const agents = cfg.agents?.list ?? [];
for (const entry of agents) {
if (entry?.id) ids.add(normalizeAgentId(entry.id));
}
for (const id of listExistingAgentIdsFromDisk()) ids.add(id);
const sorted = Array.from(ids).filter(Boolean);
@@ -252,22 +253,19 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): {
scope: SessionScope;
agents: GatewayAgentRow[];
} {
const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
const mainKey =
(cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
const scope = cfg.session?.scope ?? "per-sender";
const configured = cfg.routing?.agents;
const configuredById = new Map<string, { name?: string }>();
if (configured && typeof configured === "object") {
for (const [key, value] of Object.entries(configured)) {
if (!value || typeof value !== "object") continue;
configuredById.set(normalizeAgentId(key), {
name:
typeof value.name === "string" && value.name.trim()
? value.name.trim()
: undefined,
});
}
for (const entry of cfg.agents?.list ?? []) {
if (!entry?.id) continue;
configuredById.set(normalizeAgentId(entry.id), {
name:
typeof entry.name === "string" && entry.name.trim()
? entry.name.trim()
: undefined,
});
}
const agents = listConfiguredAgentIds(cfg).map((id) => {
const meta = configuredById.get(id);
@@ -350,7 +348,7 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
const storeConfig = cfg.session?.store;
if (storeConfig && !isStorePathTemplate(storeConfig)) {
const storePath = resolveStorePath(storeConfig);
const defaultAgentId = normalizeAgentId(cfg.routing?.defaultAgentId);
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
const store = loadSessionStore(storePath);
const combined: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(store)) {
@@ -396,7 +394,7 @@ export function getSessionDefaults(
defaultModel: DEFAULT_MODEL,
});
const contextTokens =
cfg.agent?.contextTokens ??
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(resolved.model) ??
DEFAULT_CONTEXT_TOKENS;
return {

View File

@@ -85,7 +85,8 @@ export const agentCommand = hoisted.agentCommand;
export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined,
routingConfig: undefined as Record<string, unknown> | undefined,
agentsConfig: undefined as Record<string, unknown> | undefined,
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | undefined,
allowFrom: undefined as string[] | undefined,
@@ -242,12 +243,18 @@ vi.mock("../config/config.js", async () => {
changes: testState.migrationChanges,
}),
loadConfig: () => ({
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
},
routing: testState.routingConfig,
agents: (() => {
const defaults = {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
};
if (testState.agentsConfig) {
return { ...testState.agentsConfig, defaults };
}
return { defaults };
})(),
bindings: testState.bindingsConfig,
whatsapp: {
allowFrom: testState.allowFrom,
},
@@ -356,7 +363,8 @@ export function installGatewayTestHooks() {
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
testState.routingConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();