feat: wire multi-agent config and routing
Co-authored-by: Mark Pors <1078320+pors@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user