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

@@ -80,7 +80,7 @@ describe("config identity defaults", () => {
process.env.HOME = previousHome;
});
it("derives mentionPatterns when identity is set", async () => {
it("does not derive mentionPatterns when identity is set", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -88,9 +88,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
routing: {},
},
null,
2,
@@ -103,13 +113,11 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
});
});
it("defaults ackReaction to identity emoji", async () => {
it("defaults ackReactionScope without setting ackReaction", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -117,7 +125,18 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
},
null,
@@ -130,12 +149,12 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("🦥");
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("defaults ackReaction to 👀 when identity is missing", async () => {
it("keeps ackReaction unset when identity is missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -155,7 +174,7 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("👀");
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
@@ -168,17 +187,22 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
groupChat: { mentionPatterns: ["@clawd"] },
},
],
},
messages: {
responsePrefix: "✅",
},
routing: {
groupChat: { mentionPatterns: ["@clawd"] },
},
},
null,
2,
@@ -191,7 +215,9 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("✅");
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual(["@clawd"]);
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
});
});
@@ -209,7 +235,6 @@ describe("config identity defaults", () => {
// legacy field should be ignored (moved to providers)
textChunkLimit: 9999,
},
routing: {},
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
@@ -251,9 +276,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: { responsePrefix: "" },
routing: {},
},
null,
2,
@@ -277,9 +312,7 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
messages: {},
routing: {},
},
null,
2,
@@ -292,10 +325,8 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.agent).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents).toBeUndefined();
expect(cfg.session).toBeUndefined();
});
});
@@ -308,9 +339,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Clawd", theme: "space lobster", emoji: "🦞" },
agents: {
list: [
{
id: "main",
identity: {
name: "Clawd",
theme: "space lobster",
emoji: "🦞",
},
},
],
},
messages: {},
routing: {},
},
null,
2,
@@ -411,7 +452,7 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({ agent: {} }, null, 2),
JSON.stringify({ agents: { defaults: {} } }, null, 2),
"utf-8",
);
@@ -419,7 +460,7 @@ describe("config pruning defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agent?.contextPruning?.mode).toBe("adaptive");
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive");
});
});
@@ -429,7 +470,11 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({ agent: { contextPruning: { mode: "off" } } }, null, 2),
JSON.stringify(
{ agents: { defaults: { contextPruning: { mode: "off" } } } },
null,
2,
),
"utf-8",
);
@@ -437,7 +482,7 @@ describe("config pruning defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agent?.contextPruning?.mode).toBe("off");
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off");
});
});
});
@@ -850,6 +895,97 @@ describe("legacy config detection", () => {
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { mentionPatterns: ["@clawd"] } },
});
expect(res.changes).toContain(
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
);
expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
expect(res.config?.routing?.groupChat?.mentionPatterns).toBeUndefined();
});
it("migrates routing agentToAgent/queue/transcribeAudio to tools/messages/audio", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: {
agentToAgent: { enabled: true, allow: ["main"] },
queue: { mode: "queue", cap: 3 },
transcribeAudio: { command: ["echo", "hi"], timeoutSeconds: 2 },
},
});
expect(res.changes).toContain(
"Moved routing.agentToAgent → tools.agentToAgent.",
);
expect(res.changes).toContain("Moved routing.queue → messages.queue.");
expect(res.changes).toContain(
"Moved routing.transcribeAudio → audio.transcription.",
);
expect(res.config?.tools?.agentToAgent).toEqual({
enabled: true,
allow: ["main"],
});
expect(res.config?.messages?.queue).toEqual({
mode: "queue",
cap: 3,
});
expect(res.config?.audio?.transcription).toEqual({
command: ["echo", "hi"],
timeoutSeconds: 2,
});
expect(res.config?.routing).toBeUndefined();
});
it("migrates agent config into agents.defaults and tools", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
agent: {
model: "openai/gpt-5.2",
tools: { allow: ["sessions.list"], deny: ["danger"] },
elevated: { enabled: true, allowFrom: { discord: ["user:1"] } },
bash: { timeoutSec: 12 },
sandbox: { tools: { allow: ["browser.open"] } },
subagents: { tools: { deny: ["sandbox"] } },
},
});
expect(res.changes).toContain("Moved agent.tools.allow → tools.allow.");
expect(res.changes).toContain("Moved agent.tools.deny → tools.deny.");
expect(res.changes).toContain("Moved agent.elevated → tools.elevated.");
expect(res.changes).toContain("Moved agent.bash → tools.bash.");
expect(res.changes).toContain(
"Moved agent.sandbox.tools → tools.sandbox.tools.",
);
expect(res.changes).toContain(
"Moved agent.subagents.tools → tools.subagents.tools.",
);
expect(res.changes).toContain("Moved agent → agents.defaults.");
expect(res.config?.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.2",
fallbacks: [],
});
expect(res.config?.tools?.allow).toEqual(["sessions.list"]);
expect(res.config?.tools?.deny).toEqual(["danger"]);
expect(res.config?.tools?.elevated).toEqual({
enabled: true,
allowFrom: { discord: ["user:1"] },
});
expect(res.config?.tools?.bash).toEqual({ timeoutSec: 12 });
expect(res.config?.tools?.sandbox?.tools).toEqual({
allow: ["browser.open"],
});
expect(res.config?.tools?.subagents?.tools).toEqual({
deny: ["sandbox"],
});
expect((res.config as { agent?: unknown }).agent).toBeUndefined();
});
it("rejects telegram.requireMention", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
@@ -1064,7 +1200,7 @@ describe("legacy config detection", () => {
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("agent.model");
expect(res.issues.some((i) => i.path === "agent.model")).toBe(true);
}
});
@@ -1095,22 +1231,25 @@ describe("legacy config detection", () => {
},
});
expect(res.config?.agent?.model?.primary).toBe("anthropic/claude-opus-4-5");
expect(res.config?.agent?.model?.fallbacks).toEqual([
expect(res.config?.agents?.defaults?.model?.primary).toBe(
"anthropic/claude-opus-4-5",
);
expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([
"openai/gpt-4.1-mini",
]);
expect(res.config?.agent?.imageModel?.primary).toBe("openai/gpt-4.1-mini");
expect(res.config?.agent?.imageModel?.fallbacks).toEqual([
expect(res.config?.agents?.defaults?.imageModel?.primary).toBe(
"openai/gpt-4.1-mini",
);
expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([
"anthropic/claude-opus-4-5",
]);
expect(
res.config?.agent?.models?.["anthropic/claude-opus-4-5"],
res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"],
).toMatchObject({ alias: "Opus" });
expect(res.config?.agent?.models?.["openai/gpt-4.1-mini"]).toBeTruthy();
expect(res.config?.agent?.allowedModels).toBeUndefined();
expect(res.config?.agent?.modelAliases).toBeUndefined();
expect(res.config?.agent?.modelFallbacks).toBeUndefined();
expect(res.config?.agent?.imageModelFallbacks).toBeUndefined();
expect(
res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"],
).toBeTruthy();
expect(res.config?.agent).toBeUndefined();
});
it("surfaces legacy issues in snapshot", async () => {
@@ -1135,21 +1274,21 @@ describe("legacy config detection", () => {
});
describe("multi-agent agentDir validation", () => {
it("rejects shared routing.agents.*.agentDir", async () => {
it("rejects shared agents.list agentDir", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const shared = path.join(os.tmpdir(), "clawdbot-shared-agentdir");
const res = validateConfigObject({
routing: {
agents: {
a: { agentDir: shared },
b: { agentDir: shared },
},
agents: {
list: [
{ id: "a", agentDir: shared },
{ id: "b", agentDir: shared },
],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "routing.agents")).toBe(true);
expect(res.issues.some((i) => i.path === "agents.list")).toBe(true);
expect(res.issues[0]?.message).toContain("Duplicate agentDir");
}
});
@@ -1162,13 +1301,13 @@ describe("multi-agent agentDir validation", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
routing: {
agents: {
a: { agentDir: "~/.clawdbot/agents/shared/agent" },
b: { agentDir: "~/.clawdbot/agents/shared/agent" },
},
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
agents: {
list: [
{ id: "a", agentDir: "~/.clawdbot/agents/shared/agent" },
{ id: "b", agentDir: "~/.clawdbot/agents/shared/agent" },
],
},
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
},
null,
2,