feat: wire multi-agent config and routing
Co-authored-by: Mark Pors <1078320+pors@users.noreply.github.com>
This commit is contained in:
@@ -31,18 +31,18 @@ function canonicalizeAgentDir(agentDir: string): string {
|
||||
function collectReferencedAgentIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
|
||||
const defaultAgentId =
|
||||
cfg.routing?.defaultAgentId?.trim() || DEFAULT_AGENT_ID;
|
||||
agents.find((agent) => agent?.default)?.id ??
|
||||
agents[0]?.id ??
|
||||
DEFAULT_AGENT_ID;
|
||||
ids.add(normalizeAgentId(defaultAgentId));
|
||||
|
||||
const agents = cfg.routing?.agents;
|
||||
if (agents && typeof agents === "object") {
|
||||
for (const id of Object.keys(agents)) {
|
||||
ids.add(normalizeAgentId(id));
|
||||
}
|
||||
for (const entry of agents) {
|
||||
if (entry?.id) ids.add(normalizeAgentId(entry.id));
|
||||
}
|
||||
|
||||
const bindings = cfg.routing?.bindings;
|
||||
const bindings = cfg.bindings;
|
||||
if (Array.isArray(bindings)) {
|
||||
for (const binding of bindings) {
|
||||
const id = binding?.agentId;
|
||||
@@ -61,8 +61,12 @@ function resolveEffectiveAgentDir(
|
||||
deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string },
|
||||
): string {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = cfg.routing?.agents?.[id]?.agentDir?.trim();
|
||||
if (configured) return resolveUserPath(configured);
|
||||
const configured = Array.isArray(cfg.agents?.list)
|
||||
? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id)
|
||||
?.agentDir
|
||||
: undefined;
|
||||
const trimmed = configured?.trim();
|
||||
if (trimmed) return resolveUserPath(trimmed);
|
||||
const root = resolveStateDir(
|
||||
deps?.env ?? process.env,
|
||||
deps?.homedir ?? os.homedir,
|
||||
@@ -102,7 +106,7 @@ export function formatDuplicateAgentDirError(
|
||||
(d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`,
|
||||
),
|
||||
"",
|
||||
"Fix: remove the shared routing.agents.*.agentDir override (or give each agent its own directory).",
|
||||
"Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory).",
|
||||
"If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.",
|
||||
];
|
||||
return lines.join("\n");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,56 +24,13 @@ export type SessionDefaultsOptions = {
|
||||
warnState?: WarnState;
|
||||
};
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const identity = cfg.identity;
|
||||
if (!identity) return cfg;
|
||||
|
||||
const name = identity.name?.trim();
|
||||
|
||||
const routing = cfg.routing ?? {};
|
||||
const groupChat = routing.groupChat ?? {};
|
||||
|
||||
let mutated = false;
|
||||
const next: ClawdbotConfig = { ...cfg };
|
||||
|
||||
if (name && !groupChat.mentionPatterns) {
|
||||
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
|
||||
const re = parts.length ? parts.join("\\s+") : escapeRegExp(name);
|
||||
const pattern = `\\b@?${re}\\b`;
|
||||
next.routing = {
|
||||
...(next.routing ?? routing),
|
||||
groupChat: { ...groupChat, mentionPatterns: [pattern] },
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
return mutated ? next : cfg;
|
||||
}
|
||||
|
||||
export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const messages = cfg.messages;
|
||||
const hasAckReaction = messages?.ackReaction !== undefined;
|
||||
const hasAckScope = messages?.ackReactionScope !== undefined;
|
||||
if (hasAckReaction && hasAckScope) return cfg;
|
||||
if (hasAckScope) return cfg;
|
||||
|
||||
const fallbackEmoji = cfg.identity?.emoji?.trim() || "👀";
|
||||
const nextMessages = messages ? { ...messages } : {};
|
||||
let mutated = false;
|
||||
|
||||
if (!hasAckReaction) {
|
||||
nextMessages.ackReaction = fallbackEmoji;
|
||||
mutated = true;
|
||||
}
|
||||
if (!hasAckScope) {
|
||||
nextMessages.ackReactionScope = "group-mentions";
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (!mutated) return cfg;
|
||||
nextMessages.ackReactionScope = "group-mentions";
|
||||
return {
|
||||
...cfg,
|
||||
messages: nextMessages,
|
||||
@@ -119,7 +76,7 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig {
|
||||
}
|
||||
|
||||
export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const existingAgent = cfg.agent;
|
||||
const existingAgent = cfg.agents?.defaults;
|
||||
if (!existingAgent) return cfg;
|
||||
const existingModels = existingAgent.models ?? {};
|
||||
if (Object.keys(existingModels).length === 0) return cfg;
|
||||
@@ -141,9 +98,9 @@ export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...existingAgent,
|
||||
models: nextModels,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: { ...existingAgent, models: nextModels },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -164,18 +121,21 @@ export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
export function applyContextPruningDefaults(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const agent = cfg.agent;
|
||||
if (!agent) return cfg;
|
||||
const contextPruning = agent?.contextPruning;
|
||||
const defaults = cfg.agents?.defaults;
|
||||
if (!defaults) return cfg;
|
||||
const contextPruning = defaults?.contextPruning;
|
||||
if (contextPruning?.mode) return cfg;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...agent,
|
||||
contextPruning: {
|
||||
...contextPruning,
|
||||
mode: "adaptive",
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...defaults,
|
||||
contextPruning: {
|
||||
...contextPruning,
|
||||
mode: "adaptive",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "./agent-dirs.js";
|
||||
import {
|
||||
applyContextPruningDefaults,
|
||||
applyIdentityDefaults,
|
||||
applyLoggingDefaults,
|
||||
applyMessageDefaults,
|
||||
applyModelDefaults,
|
||||
@@ -165,9 +164,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
applyContextPruningDefaults(
|
||||
applySessionDefaults(
|
||||
applyLoggingDefaults(
|
||||
applyMessageDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
||||
),
|
||||
applyMessageDefaults(validated.data as ClawdbotConfig),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,53 +12,179 @@ type LegacyConfigMigration = {
|
||||
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const getRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
isRecord(value) ? value : null;
|
||||
|
||||
const ensureRecord = (
|
||||
root: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> => {
|
||||
const existing = root[key];
|
||||
if (isRecord(existing)) return existing;
|
||||
const next: Record<string, unknown> = {};
|
||||
root[key] = next;
|
||||
return next;
|
||||
};
|
||||
|
||||
const mergeMissing = (
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
) => {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined) continue;
|
||||
const existing = target[key];
|
||||
if (existing === undefined) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (isRecord(existing) && isRecord(value)) {
|
||||
mergeMissing(existing, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getAgentsList = (agents: Record<string, unknown> | null) => {
|
||||
const list = agents?.list;
|
||||
return Array.isArray(list) ? list : [];
|
||||
};
|
||||
|
||||
const resolveDefaultAgentIdFromRaw = (raw: Record<string, unknown>) => {
|
||||
const agents = getRecord(raw.agents);
|
||||
const list = getAgentsList(agents);
|
||||
const defaultEntry = list.find(
|
||||
(entry): entry is { id: string } =>
|
||||
isRecord(entry) &&
|
||||
entry.default === true &&
|
||||
typeof entry.id === "string" &&
|
||||
entry.id.trim() !== "",
|
||||
);
|
||||
if (defaultEntry) return defaultEntry.id.trim();
|
||||
const routing = getRecord(raw.routing);
|
||||
const routingDefault =
|
||||
typeof routing?.defaultAgentId === "string"
|
||||
? routing.defaultAgentId.trim()
|
||||
: "";
|
||||
if (routingDefault) return routingDefault;
|
||||
const firstEntry = list.find(
|
||||
(entry): entry is { id: string } =>
|
||||
isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "",
|
||||
);
|
||||
if (firstEntry) return firstEntry.id.trim();
|
||||
return "main";
|
||||
};
|
||||
|
||||
const ensureAgentEntry = (
|
||||
list: unknown[],
|
||||
id: string,
|
||||
): Record<string, unknown> => {
|
||||
const normalized = id.trim();
|
||||
const existing = list.find(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
isRecord(entry) &&
|
||||
typeof entry.id === "string" &&
|
||||
entry.id.trim() === normalized,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const created: Record<string, unknown> = { id: normalized };
|
||||
list.push(created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["routing", "allowFrom"],
|
||||
message:
|
||||
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "bindings"],
|
||||
message:
|
||||
"routing.bindings was moved; use top-level bindings instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "agents"],
|
||||
message:
|
||||
"routing.agents was moved; use agents.list instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "defaultAgentId"],
|
||||
message:
|
||||
"routing.defaultAgentId was moved; use agents.list[].default instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "agentToAgent"],
|
||||
message:
|
||||
"routing.agentToAgent was moved; use tools.agentToAgent instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "groupChat", "requireMention"],
|
||||
message:
|
||||
'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdbot doctor` to migrate).',
|
||||
},
|
||||
{
|
||||
path: ["routing", "groupChat", "mentionPatterns"],
|
||||
message:
|
||||
"routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "queue"],
|
||||
message:
|
||||
"routing.queue was moved; use messages.queue instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "transcribeAudio"],
|
||||
message:
|
||||
"routing.transcribeAudio was moved; use audio.transcription instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["telegram", "requireMention"],
|
||||
message:
|
||||
'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).',
|
||||
},
|
||||
{
|
||||
path: ["identity"],
|
||||
message:
|
||||
"identity was moved; use agents.list[].identity instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent"],
|
||||
message:
|
||||
"agent.* was moved; use agents.defaults (and tools.* for tool/elevated/bash settings) instead (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent", "model"],
|
||||
message:
|
||||
"agent.model string was replaced by agent.model.primary/fallbacks and agent.models (run `clawdbot doctor` to migrate).",
|
||||
"agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (run `clawdbot doctor` to migrate).",
|
||||
match: (value) => typeof value === "string",
|
||||
},
|
||||
{
|
||||
path: ["agent", "imageModel"],
|
||||
message:
|
||||
"agent.imageModel string was replaced by agent.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
|
||||
"agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
|
||||
match: (value) => typeof value === "string",
|
||||
},
|
||||
{
|
||||
path: ["agent", "allowedModels"],
|
||||
message:
|
||||
"agent.allowedModels was replaced by agent.models (run `clawdbot doctor` to migrate).",
|
||||
"agent.allowedModels was replaced by agents.defaults.models (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent", "modelAliases"],
|
||||
message:
|
||||
"agent.modelAliases was replaced by agent.models.*.alias (run `clawdbot doctor` to migrate).",
|
||||
"agent.modelAliases was replaced by agents.defaults.models.*.alias (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent", "modelFallbacks"],
|
||||
message:
|
||||
"agent.modelFallbacks was replaced by agent.model.fallbacks (run `clawdbot doctor` to migrate).",
|
||||
"agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent", "imageModelFallbacks"],
|
||||
message:
|
||||
"agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
|
||||
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["gateway", "token"],
|
||||
@@ -236,11 +362,11 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
describe:
|
||||
"Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
|
||||
apply: (raw, changes) => {
|
||||
const agent =
|
||||
raw.agent && typeof raw.agent === "object"
|
||||
? (raw.agent as Record<string, unknown>)
|
||||
: null;
|
||||
const agentRoot = getRecord(raw.agent);
|
||||
const defaults = getRecord(getRecord(raw.agents)?.defaults);
|
||||
const agent = agentRoot ?? defaults;
|
||||
if (!agent) return;
|
||||
const label = agentRoot ? "agent" : "agents.defaults";
|
||||
|
||||
const legacyModel =
|
||||
typeof agent.model === "string" ? String(agent.model) : undefined;
|
||||
@@ -358,26 +484,32 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
agent.models = models;
|
||||
|
||||
if (legacyModel !== undefined) {
|
||||
changes.push("Migrated agent.model string → agent.model.primary.");
|
||||
changes.push(
|
||||
`Migrated ${label}.model string → ${label}.model.primary.`,
|
||||
);
|
||||
}
|
||||
if (legacyModelFallbacks.length > 0) {
|
||||
changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks.");
|
||||
changes.push(
|
||||
`Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`,
|
||||
);
|
||||
}
|
||||
if (legacyImageModel !== undefined) {
|
||||
changes.push(
|
||||
"Migrated agent.imageModel string → agent.imageModel.primary.",
|
||||
`Migrated ${label}.imageModel string → ${label}.imageModel.primary.`,
|
||||
);
|
||||
}
|
||||
if (legacyImageModelFallbacks.length > 0) {
|
||||
changes.push(
|
||||
"Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.",
|
||||
`Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`,
|
||||
);
|
||||
}
|
||||
if (legacyAllowed.length > 0) {
|
||||
changes.push("Migrated agent.allowedModels → agent.models.");
|
||||
changes.push(`Migrated ${label}.allowedModels → ${label}.models.`);
|
||||
}
|
||||
if (Object.keys(legacyAliases).length > 0) {
|
||||
changes.push("Migrated agent.modelAliases → agent.models.*.alias.");
|
||||
changes.push(
|
||||
`Migrated ${label}.modelAliases → ${label}.models.*.alias.`,
|
||||
);
|
||||
}
|
||||
|
||||
delete agent.allowedModels;
|
||||
@@ -386,6 +518,311 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
delete agent.imageModelFallbacks;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "routing.agents-v2",
|
||||
describe: "Move routing.agents/defaultAgentId to agents.list",
|
||||
apply: (raw, changes) => {
|
||||
const routing = getRecord(raw.routing);
|
||||
if (!routing) return;
|
||||
|
||||
const routingAgents = getRecord(routing.agents);
|
||||
const agents = ensureRecord(raw, "agents");
|
||||
const list = getAgentsList(agents);
|
||||
|
||||
if (routingAgents) {
|
||||
for (const [rawId, entryRaw] of Object.entries(routingAgents)) {
|
||||
const agentId = String(rawId ?? "").trim();
|
||||
const entry = getRecord(entryRaw);
|
||||
if (!agentId || !entry) continue;
|
||||
|
||||
const target = ensureAgentEntry(list, agentId);
|
||||
const entryCopy: Record<string, unknown> = { ...entry };
|
||||
|
||||
if ("mentionPatterns" in entryCopy) {
|
||||
const mentionPatterns = entryCopy.mentionPatterns;
|
||||
const groupChat = ensureRecord(target, "groupChat");
|
||||
if (groupChat.mentionPatterns === undefined) {
|
||||
groupChat.mentionPatterns = mentionPatterns;
|
||||
changes.push(
|
||||
`Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`,
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
`Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`,
|
||||
);
|
||||
}
|
||||
delete entryCopy.mentionPatterns;
|
||||
}
|
||||
|
||||
const legacyGroupChat = getRecord(entryCopy.groupChat);
|
||||
if (legacyGroupChat) {
|
||||
const groupChat = ensureRecord(target, "groupChat");
|
||||
mergeMissing(groupChat, legacyGroupChat);
|
||||
delete entryCopy.groupChat;
|
||||
}
|
||||
|
||||
const legacySandbox = getRecord(entryCopy.sandbox);
|
||||
if (legacySandbox) {
|
||||
const sandboxTools = getRecord(legacySandbox.tools);
|
||||
if (sandboxTools) {
|
||||
const tools = ensureRecord(target, "tools");
|
||||
const sandbox = ensureRecord(tools, "sandbox");
|
||||
const toolPolicy = ensureRecord(sandbox, "tools");
|
||||
mergeMissing(toolPolicy, sandboxTools);
|
||||
delete legacySandbox.tools;
|
||||
changes.push(
|
||||
`Moved routing.agents.${agentId}.sandbox.tools → agents.list (id "${agentId}").tools.sandbox.tools.`,
|
||||
);
|
||||
}
|
||||
entryCopy.sandbox = legacySandbox;
|
||||
}
|
||||
|
||||
mergeMissing(target, entryCopy);
|
||||
}
|
||||
delete routing.agents;
|
||||
changes.push("Moved routing.agents → agents.list.");
|
||||
}
|
||||
|
||||
const defaultAgentId =
|
||||
typeof routing.defaultAgentId === "string"
|
||||
? routing.defaultAgentId.trim()
|
||||
: "";
|
||||
if (defaultAgentId) {
|
||||
const hasDefault = list.some(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
isRecord(entry) && entry.default === true,
|
||||
);
|
||||
if (!hasDefault) {
|
||||
const entry = ensureAgentEntry(list, defaultAgentId);
|
||||
entry.default = true;
|
||||
changes.push(
|
||||
`Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`,
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
"Removed routing.defaultAgentId (agents.list default already set).",
|
||||
);
|
||||
}
|
||||
delete routing.defaultAgentId;
|
||||
}
|
||||
|
||||
if (list.length > 0) {
|
||||
agents.list = list;
|
||||
}
|
||||
|
||||
if (Object.keys(routing).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "routing.config-v2",
|
||||
describe:
|
||||
"Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio",
|
||||
apply: (raw, changes) => {
|
||||
const routing = getRecord(raw.routing);
|
||||
if (!routing) return;
|
||||
|
||||
if (routing.bindings !== undefined) {
|
||||
if (raw.bindings === undefined) {
|
||||
raw.bindings = routing.bindings;
|
||||
changes.push("Moved routing.bindings → bindings.");
|
||||
} else {
|
||||
changes.push("Removed routing.bindings (bindings already set).");
|
||||
}
|
||||
delete routing.bindings;
|
||||
}
|
||||
|
||||
if (routing.agentToAgent !== undefined) {
|
||||
const tools = ensureRecord(raw, "tools");
|
||||
if (tools.agentToAgent === undefined) {
|
||||
tools.agentToAgent = routing.agentToAgent;
|
||||
changes.push("Moved routing.agentToAgent → tools.agentToAgent.");
|
||||
} else {
|
||||
changes.push(
|
||||
"Removed routing.agentToAgent (tools.agentToAgent already set).",
|
||||
);
|
||||
}
|
||||
delete routing.agentToAgent;
|
||||
}
|
||||
|
||||
if (routing.queue !== undefined) {
|
||||
const messages = ensureRecord(raw, "messages");
|
||||
if (messages.queue === undefined) {
|
||||
messages.queue = routing.queue;
|
||||
changes.push("Moved routing.queue → messages.queue.");
|
||||
} else {
|
||||
changes.push("Removed routing.queue (messages.queue already set).");
|
||||
}
|
||||
delete routing.queue;
|
||||
}
|
||||
|
||||
const groupChat = getRecord(routing.groupChat);
|
||||
if (groupChat) {
|
||||
const historyLimit = groupChat.historyLimit;
|
||||
if (historyLimit !== undefined) {
|
||||
const messages = ensureRecord(raw, "messages");
|
||||
const messagesGroup = ensureRecord(messages, "groupChat");
|
||||
if (messagesGroup.historyLimit === undefined) {
|
||||
messagesGroup.historyLimit = historyLimit;
|
||||
changes.push(
|
||||
"Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.",
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
"Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).",
|
||||
);
|
||||
}
|
||||
delete groupChat.historyLimit;
|
||||
}
|
||||
|
||||
const mentionPatterns = groupChat.mentionPatterns;
|
||||
if (mentionPatterns !== undefined) {
|
||||
const messages = ensureRecord(raw, "messages");
|
||||
const messagesGroup = ensureRecord(messages, "groupChat");
|
||||
if (messagesGroup.mentionPatterns === undefined) {
|
||||
messagesGroup.mentionPatterns = mentionPatterns;
|
||||
changes.push(
|
||||
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
"Removed routing.groupChat.mentionPatterns (messages.groupChat.mentionPatterns already set).",
|
||||
);
|
||||
}
|
||||
delete groupChat.mentionPatterns;
|
||||
}
|
||||
|
||||
if (Object.keys(groupChat).length === 0) {
|
||||
delete routing.groupChat;
|
||||
} else {
|
||||
routing.groupChat = groupChat;
|
||||
}
|
||||
}
|
||||
|
||||
if (routing.transcribeAudio !== undefined) {
|
||||
const audio = ensureRecord(raw, "audio");
|
||||
if (audio.transcription === undefined) {
|
||||
audio.transcription = routing.transcribeAudio;
|
||||
changes.push("Moved routing.transcribeAudio → audio.transcription.");
|
||||
} else {
|
||||
changes.push(
|
||||
"Removed routing.transcribeAudio (audio.transcription already set).",
|
||||
);
|
||||
}
|
||||
delete routing.transcribeAudio;
|
||||
}
|
||||
|
||||
if (Object.keys(routing).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent.defaults-v2",
|
||||
describe: "Move agent config to agents.defaults and tools",
|
||||
apply: (raw, changes) => {
|
||||
const agent = getRecord(raw.agent);
|
||||
if (!agent) return;
|
||||
|
||||
const agents = ensureRecord(raw, "agents");
|
||||
const defaults = getRecord(agents.defaults) ?? {};
|
||||
const tools = ensureRecord(raw, "tools");
|
||||
|
||||
const agentTools = getRecord(agent.tools);
|
||||
if (agentTools) {
|
||||
if (tools.allow === undefined && agentTools.allow !== undefined) {
|
||||
tools.allow = agentTools.allow;
|
||||
changes.push("Moved agent.tools.allow → tools.allow.");
|
||||
}
|
||||
if (tools.deny === undefined && agentTools.deny !== undefined) {
|
||||
tools.deny = agentTools.deny;
|
||||
changes.push("Moved agent.tools.deny → tools.deny.");
|
||||
}
|
||||
}
|
||||
|
||||
const elevated = getRecord(agent.elevated);
|
||||
if (elevated) {
|
||||
if (tools.elevated === undefined) {
|
||||
tools.elevated = elevated;
|
||||
changes.push("Moved agent.elevated → tools.elevated.");
|
||||
} else {
|
||||
changes.push("Removed agent.elevated (tools.elevated already set).");
|
||||
}
|
||||
}
|
||||
|
||||
const bash = getRecord(agent.bash);
|
||||
if (bash) {
|
||||
if (tools.bash === undefined) {
|
||||
tools.bash = bash;
|
||||
changes.push("Moved agent.bash → tools.bash.");
|
||||
} else {
|
||||
changes.push("Removed agent.bash (tools.bash already set).");
|
||||
}
|
||||
}
|
||||
|
||||
const sandbox = getRecord(agent.sandbox);
|
||||
if (sandbox) {
|
||||
const sandboxTools = getRecord(sandbox.tools);
|
||||
if (sandboxTools) {
|
||||
const toolsSandbox = ensureRecord(tools, "sandbox");
|
||||
const toolPolicy = ensureRecord(toolsSandbox, "tools");
|
||||
mergeMissing(toolPolicy, sandboxTools);
|
||||
delete sandbox.tools;
|
||||
changes.push("Moved agent.sandbox.tools → tools.sandbox.tools.");
|
||||
}
|
||||
}
|
||||
|
||||
const subagents = getRecord(agent.subagents);
|
||||
if (subagents) {
|
||||
const subagentTools = getRecord(subagents.tools);
|
||||
if (subagentTools) {
|
||||
const toolsSubagents = ensureRecord(tools, "subagents");
|
||||
const toolPolicy = ensureRecord(toolsSubagents, "tools");
|
||||
mergeMissing(toolPolicy, subagentTools);
|
||||
delete subagents.tools;
|
||||
changes.push("Moved agent.subagents.tools → tools.subagents.tools.");
|
||||
}
|
||||
}
|
||||
|
||||
const agentCopy: Record<string, unknown> = structuredClone(agent);
|
||||
delete agentCopy.tools;
|
||||
delete agentCopy.elevated;
|
||||
delete agentCopy.bash;
|
||||
if (isRecord(agentCopy.sandbox)) delete agentCopy.sandbox.tools;
|
||||
if (isRecord(agentCopy.subagents)) delete agentCopy.subagents.tools;
|
||||
|
||||
mergeMissing(defaults, agentCopy);
|
||||
agents.defaults = defaults;
|
||||
raw.agents = agents;
|
||||
delete raw.agent;
|
||||
changes.push("Moved agent → agents.defaults.");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "identity->agents.list",
|
||||
describe: "Move identity to agents.list[].identity",
|
||||
apply: (raw, changes) => {
|
||||
const identity = getRecord(raw.identity);
|
||||
if (!identity) return;
|
||||
|
||||
const agents = ensureRecord(raw, "agents");
|
||||
const list = getAgentsList(agents);
|
||||
const defaultId = resolveDefaultAgentIdFromRaw(raw);
|
||||
const entry = ensureAgentEntry(list, defaultId);
|
||||
if (entry.identity === undefined) {
|
||||
entry.identity = identity;
|
||||
changes.push(
|
||||
`Moved identity → agents.list (id "${defaultId}").identity.`,
|
||||
);
|
||||
} else {
|
||||
changes.push("Removed identity (agents.list identity already set).");
|
||||
}
|
||||
agents.list = list;
|
||||
raw.agents = agents;
|
||||
delete raw.identity;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
|
||||
@@ -5,52 +5,62 @@ import type { ClawdbotConfig } from "./types.js";
|
||||
describe("applyModelDefaults", () => {
|
||||
it("adds default aliases when models are present", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-5.2": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
const next = applyModelDefaults(cfg);
|
||||
|
||||
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
|
||||
"opus",
|
||||
expect(
|
||||
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
|
||||
).toBe("opus");
|
||||
expect(next.agents?.defaults?.models?.["openai/gpt-5.2"]?.alias).toBe(
|
||||
"gpt",
|
||||
);
|
||||
expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt");
|
||||
});
|
||||
|
||||
it("does not override existing aliases", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const next = applyModelDefaults(cfg);
|
||||
|
||||
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
|
||||
"Opus",
|
||||
);
|
||||
expect(
|
||||
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
|
||||
).toBe("Opus");
|
||||
});
|
||||
|
||||
it("respects explicit empty alias disables", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
models: {
|
||||
"google/gemini-3-pro-preview": { alias: "" },
|
||||
"google/gemini-3-flash-preview": {},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"google/gemini-3-pro-preview": { alias: "" },
|
||||
"google/gemini-3-flash-preview": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const next = applyModelDefaults(cfg);
|
||||
|
||||
expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe("");
|
||||
expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe(
|
||||
"gemini-flash",
|
||||
);
|
||||
expect(
|
||||
next.agents?.defaults?.models?.["google/gemini-3-pro-preview"]?.alias,
|
||||
).toBe("");
|
||||
expect(
|
||||
next.agents?.defaults?.models?.["google/gemini-3-flash-preview"]?.alias,
|
||||
).toBe("gemini-flash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("config schema", () => {
|
||||
const res = buildConfigSchema();
|
||||
const schema = res.schema as { properties?: Record<string, unknown> };
|
||||
expect(schema.properties?.gateway).toBeTruthy();
|
||||
expect(schema.properties?.agent).toBeTruthy();
|
||||
expect(schema.properties?.agents).toBeTruthy();
|
||||
expect(res.uiHints.gateway?.label).toBe("Gateway");
|
||||
expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true);
|
||||
expect(res.version).toBeTruthy();
|
||||
|
||||
@@ -24,13 +24,14 @@ export type ConfigSchemaResponse = {
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
identity: "Identity",
|
||||
wizard: "Wizard",
|
||||
logging: "Logging",
|
||||
gateway: "Gateway",
|
||||
agent: "Agent",
|
||||
agents: "Agents",
|
||||
tools: "Tools",
|
||||
bindings: "Bindings",
|
||||
audio: "Audio",
|
||||
models: "Models",
|
||||
routing: "Routing",
|
||||
messages: "Messages",
|
||||
commands: "Commands",
|
||||
session: "Session",
|
||||
@@ -52,30 +53,31 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const GROUP_ORDER: Record<string, number> = {
|
||||
identity: 10,
|
||||
wizard: 20,
|
||||
gateway: 30,
|
||||
agent: 40,
|
||||
models: 50,
|
||||
routing: 60,
|
||||
messages: 70,
|
||||
commands: 75,
|
||||
session: 80,
|
||||
cron: 90,
|
||||
hooks: 100,
|
||||
ui: 110,
|
||||
browser: 120,
|
||||
talk: 130,
|
||||
telegram: 140,
|
||||
discord: 150,
|
||||
slack: 155,
|
||||
signal: 160,
|
||||
imessage: 170,
|
||||
whatsapp: 180,
|
||||
skills: 190,
|
||||
discovery: 200,
|
||||
presence: 210,
|
||||
voicewake: 220,
|
||||
agents: 40,
|
||||
tools: 50,
|
||||
bindings: 55,
|
||||
audio: 60,
|
||||
models: 70,
|
||||
messages: 80,
|
||||
commands: 85,
|
||||
session: 90,
|
||||
cron: 100,
|
||||
hooks: 110,
|
||||
ui: 120,
|
||||
browser: 130,
|
||||
talk: 140,
|
||||
telegram: 150,
|
||||
discord: 160,
|
||||
slack: 165,
|
||||
signal: 170,
|
||||
imessage: 180,
|
||||
whatsapp: 190,
|
||||
skills: 200,
|
||||
discovery: 210,
|
||||
presence: 220,
|
||||
voicewake: 230,
|
||||
logging: 900,
|
||||
};
|
||||
|
||||
@@ -90,14 +92,14 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||
"agent.workspace": "Workspace",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
"auth.profiles": "Auth Profiles",
|
||||
"auth.order": "Auth Profile Order",
|
||||
"agent.models": "Models",
|
||||
"agent.model.primary": "Primary Model",
|
||||
"agent.model.fallbacks": "Model Fallbacks",
|
||||
"agent.imageModel.primary": "Image Model",
|
||||
"agent.imageModel.fallbacks": "Image Model Fallbacks",
|
||||
"agents.defaults.models": "Models",
|
||||
"agents.defaults.model.primary": "Primary Model",
|
||||
"agents.defaults.model.fallbacks": "Model Fallbacks",
|
||||
"agents.defaults.imageModel.primary": "Image Model",
|
||||
"agents.defaults.imageModel.fallbacks": "Image Model Fallbacks",
|
||||
"commands.native": "Native Commands",
|
||||
"commands.text": "Text Commands",
|
||||
"commands.restart": "Allow Restart",
|
||||
@@ -154,14 +156,14 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
|
||||
"auth.order":
|
||||
"Ordered auth profile IDs per provider (used for automatic failover).",
|
||||
"agent.models":
|
||||
"agents.defaults.models":
|
||||
"Configured model catalog (keys are full provider/model IDs).",
|
||||
"agent.model.primary": "Primary model (provider/model).",
|
||||
"agent.model.fallbacks":
|
||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||
"agents.defaults.model.fallbacks":
|
||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||
"agent.imageModel.primary":
|
||||
"agents.defaults.imageModel.primary":
|
||||
"Optional image model (provider/model) used when the primary model lacks image input.",
|
||||
"agent.imageModel.fallbacks":
|
||||
"agents.defaults.imageModel.fallbacks":
|
||||
"Ordered fallback image models (provider/model).",
|
||||
"commands.native":
|
||||
"Register native commands with connectors that support it (Discord/Slack/Telegram).",
|
||||
|
||||
@@ -217,12 +217,15 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
|
||||
export function resolveMainSessionKey(cfg?: {
|
||||
session?: { scope?: SessionScope; mainKey?: string };
|
||||
routing?: { defaultAgentId?: string };
|
||||
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
||||
}): string {
|
||||
if (cfg?.session?.scope === "global") return "global";
|
||||
const agentId = normalizeAgentId(
|
||||
cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
|
||||
);
|
||||
const agents = cfg?.agents?.list ?? [];
|
||||
const defaultAgentId =
|
||||
agents.find((agent) => agent?.default)?.id ??
|
||||
agents[0]?.id ??
|
||||
DEFAULT_AGENT_ID;
|
||||
const agentId = normalizeAgentId(defaultAgentId);
|
||||
const mainKey =
|
||||
(cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
|
||||
return buildAgentMainSessionKey({ agentId, mainKey });
|
||||
|
||||
@@ -91,6 +91,12 @@ export type AgentElevatedAllowFromConfig = {
|
||||
webchat?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type IdentityConfig = {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
export type WhatsAppActionConfig = {
|
||||
reactions?: boolean;
|
||||
sendMessage?: boolean;
|
||||
@@ -762,83 +768,133 @@ export type GroupChatConfig = {
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
export type RoutingConfig = {
|
||||
transcribeAudio?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
timeoutSeconds?: number;
|
||||
export type QueueConfig = {
|
||||
mode?: QueueMode;
|
||||
byProvider?: QueueModeByProvider;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
};
|
||||
|
||||
export type AgentToolsConfig = {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
sandbox?: {
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
groupChat?: GroupChatConfig;
|
||||
/** Default agent id when no binding matches. Default: "main". */
|
||||
defaultAgentId?: string;
|
||||
};
|
||||
|
||||
export type ToolsConfig = {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
agentToAgent?: {
|
||||
/** Enable agent-to-agent messaging tools. Default: false. */
|
||||
enabled?: boolean;
|
||||
/** Allowlist of agent ids or patterns (implementation-defined). */
|
||||
allow?: string[];
|
||||
};
|
||||
agents?: Record<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
/** Per-agent override for group mention patterns. */
|
||||
mentionPatterns?: string[];
|
||||
subagents?: {
|
||||
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
|
||||
allowAgents?: string[];
|
||||
};
|
||||
sandbox?: {
|
||||
mode?: "off" | "non-main" | "all";
|
||||
/** Agent workspace access inside the sandbox. */
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
/** Container/workspace scope for sandbox isolation. */
|
||||
scope?: "session" | "agent" | "shared";
|
||||
/** Legacy alias for scope ("session" when true, "shared" when false). */
|
||||
perSession?: boolean;
|
||||
workspaceRoot?: string;
|
||||
/** Docker-specific sandbox overrides for this agent. */
|
||||
docker?: SandboxDockerSettings;
|
||||
/** Optional sandboxed browser overrides for this agent. */
|
||||
browser?: SandboxBrowserSettings;
|
||||
/** Tool allow/deny policy for sandboxed sessions (deny wins). */
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
/** Auto-prune overrides for this agent. */
|
||||
prune?: SandboxPruneSettings;
|
||||
};
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
}
|
||||
>;
|
||||
bindings?: Array<{
|
||||
agentId: string;
|
||||
match: {
|
||||
provider: string;
|
||||
accountId?: string;
|
||||
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
||||
guildId?: string;
|
||||
teamId?: string;
|
||||
/** Elevated bash permissions for the host machine. */
|
||||
elevated?: {
|
||||
/** Enable or disable elevated mode (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Approved senders for /elevated (per-provider allowlists). */
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
/** Bash tool defaults. */
|
||||
bash?: {
|
||||
/** Default time (ms) before a bash command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing bash commands. */
|
||||
timeoutSec?: number;
|
||||
/** How long to keep finished sessions in memory (ms). */
|
||||
cleanupMs?: number;
|
||||
};
|
||||
/** Sub-agent tool policy defaults (deny wins). */
|
||||
subagents?: {
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
}>;
|
||||
queue?: {
|
||||
mode?: QueueMode;
|
||||
byProvider?: QueueModeByProvider;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
};
|
||||
/** Sandbox tool policy defaults (deny wins). */
|
||||
sandbox?: {
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentConfig = {
|
||||
id: string;
|
||||
default?: boolean;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
identity?: IdentityConfig;
|
||||
groupChat?: GroupChatConfig;
|
||||
subagents?: {
|
||||
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
|
||||
allowAgents?: string[];
|
||||
};
|
||||
sandbox?: {
|
||||
mode?: "off" | "non-main" | "all";
|
||||
/** Agent workspace access inside the sandbox. */
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
/**
|
||||
* Session tools visibility for sandboxed sessions.
|
||||
* - "spawned": only allow session tools to target sessions spawned from this session (default)
|
||||
* - "all": allow session tools to target any session
|
||||
*/
|
||||
sessionToolsVisibility?: "spawned" | "all";
|
||||
/** Container/workspace scope for sandbox isolation. */
|
||||
scope?: "session" | "agent" | "shared";
|
||||
/** Legacy alias for scope ("session" when true, "shared" when false). */
|
||||
perSession?: boolean;
|
||||
workspaceRoot?: string;
|
||||
/** Docker-specific sandbox overrides for this agent. */
|
||||
docker?: SandboxDockerSettings;
|
||||
/** Optional sandboxed browser overrides for this agent. */
|
||||
browser?: SandboxBrowserSettings;
|
||||
/** Auto-prune overrides for this agent. */
|
||||
prune?: SandboxPruneSettings;
|
||||
};
|
||||
tools?: AgentToolsConfig;
|
||||
};
|
||||
|
||||
export type AgentsConfig = {
|
||||
defaults?: AgentDefaultsConfig;
|
||||
list?: AgentConfig[];
|
||||
};
|
||||
|
||||
export type AgentBinding = {
|
||||
agentId: string;
|
||||
match: {
|
||||
provider: string;
|
||||
accountId?: string;
|
||||
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
||||
guildId?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AudioConfig = {
|
||||
transcription?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MessagesConfig = {
|
||||
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "")
|
||||
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
|
||||
groupChat?: GroupChatConfig;
|
||||
queue?: QueueConfig;
|
||||
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
|
||||
ackReaction?: string;
|
||||
/** When to send ack reactions. Default: "group-mentions". */
|
||||
@@ -1097,6 +1153,113 @@ export type AgentContextPruningConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentDefaultsConfig = {
|
||||
/** Primary model and fallbacks (provider/model). */
|
||||
model?: AgentModelListConfig;
|
||||
/** Optional image-capable model and fallbacks (provider/model). */
|
||||
imageModel?: AgentModelListConfig;
|
||||
/** Model catalog with optional aliases (full provider/model keys). */
|
||||
models?: Record<string, AgentModelEntryConfig>;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||
skipBootstrap?: boolean;
|
||||
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
|
||||
userTimezone?: string;
|
||||
/** Optional display-only context window override (used for % in status UIs). */
|
||||
contextTokens?: number;
|
||||
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
|
||||
contextPruning?: AgentContextPruningConfig;
|
||||
/** Default thinking level when no /think directive is present. */
|
||||
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
/** Default verbose level when no /verbose directive is present. */
|
||||
verboseDefault?: "off" | "on";
|
||||
/** Default elevated level when no /elevated directive is present. */
|
||||
elevatedDefault?: "off" | "on";
|
||||
/** Default block streaming level when no override is present. */
|
||||
blockStreamingDefault?: "off" | "on";
|
||||
/**
|
||||
* Block streaming boundary:
|
||||
* - "text_end": end of each assistant text content block (before tool calls)
|
||||
* - "message_end": end of the whole assistant message (may include tool blocks)
|
||||
*/
|
||||
blockStreamingBreak?: "text_end" | "message_end";
|
||||
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
|
||||
blockStreamingChunk?: {
|
||||
minChars?: number;
|
||||
maxChars?: number;
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
timeoutSeconds?: number;
|
||||
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
||||
mediaMaxMb?: number;
|
||||
typingIntervalSeconds?: number;
|
||||
/** Typing indicator start mode (never|instant|thinking|message). */
|
||||
typingMode?: TypingMode;
|
||||
/** Periodic background heartbeat runs. */
|
||||
heartbeat?: {
|
||||
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
||||
every?: string;
|
||||
/** Heartbeat model override (provider/model). */
|
||||
model?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "none";
|
||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
|
||||
prompt?: string;
|
||||
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
||||
ackMaxChars?: number;
|
||||
};
|
||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||
maxConcurrent?: number;
|
||||
/** Sub-agent defaults (spawned via sessions_spawn). */
|
||||
subagents?: {
|
||||
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
|
||||
maxConcurrent?: number;
|
||||
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
|
||||
archiveAfterMinutes?: number;
|
||||
};
|
||||
/** Optional sandbox settings for non-main sessions. */
|
||||
sandbox?: {
|
||||
/** Enable sandboxing for sessions. */
|
||||
mode?: "off" | "non-main" | "all";
|
||||
/**
|
||||
* Agent workspace access inside the sandbox.
|
||||
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
|
||||
* - "ro": mount the agent workspace read-only; disables write/edit tools
|
||||
* - "rw": mount the agent workspace read/write; enables write/edit tools
|
||||
*/
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
/**
|
||||
* Session tools visibility for sandboxed sessions.
|
||||
* - "spawned": only allow session tools to target sessions spawned from this session (default)
|
||||
* - "all": allow session tools to target any session
|
||||
*/
|
||||
sessionToolsVisibility?: "spawned" | "all";
|
||||
/** Container/workspace scope for sandbox isolation. */
|
||||
scope?: "session" | "agent" | "shared";
|
||||
/** Legacy alias for scope ("session" when true, "shared" when false). */
|
||||
perSession?: boolean;
|
||||
/** Root directory for sandbox workspaces. */
|
||||
workspaceRoot?: string;
|
||||
/** Docker-specific sandbox settings. */
|
||||
docker?: SandboxDockerSettings;
|
||||
/** Optional sandboxed browser settings. */
|
||||
browser?: SandboxBrowserSettings;
|
||||
/** Auto-prune sandbox containers. */
|
||||
prune?: SandboxPruneSettings;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClawdbotConfig = {
|
||||
auth?: AuthConfig;
|
||||
env?: {
|
||||
@@ -1115,11 +1278,6 @@ export type ClawdbotConfig = {
|
||||
| { enabled?: boolean; timeoutMs?: number }
|
||||
| undefined;
|
||||
};
|
||||
identity?: {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
wizard?: {
|
||||
lastRunAt?: string;
|
||||
lastRunVersion?: string;
|
||||
@@ -1135,145 +1293,10 @@ export type ClawdbotConfig = {
|
||||
};
|
||||
skills?: SkillsConfig;
|
||||
models?: ModelsConfig;
|
||||
agent?: {
|
||||
/** Primary model and fallbacks (provider/model). */
|
||||
model?: AgentModelListConfig;
|
||||
/** Optional image-capable model and fallbacks (provider/model). */
|
||||
imageModel?: AgentModelListConfig;
|
||||
/** Model catalog with optional aliases (full provider/model keys). */
|
||||
models?: Record<string, AgentModelEntryConfig>;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||
skipBootstrap?: boolean;
|
||||
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
|
||||
userTimezone?: string;
|
||||
/** Optional display-only context window override (used for % in status UIs). */
|
||||
contextTokens?: number;
|
||||
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
|
||||
contextPruning?: AgentContextPruningConfig;
|
||||
/** Default thinking level when no /think directive is present. */
|
||||
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
/** Default verbose level when no /verbose directive is present. */
|
||||
verboseDefault?: "off" | "on";
|
||||
/** Default elevated level when no /elevated directive is present. */
|
||||
elevatedDefault?: "off" | "on";
|
||||
/** Default block streaming level when no override is present. */
|
||||
blockStreamingDefault?: "off" | "on";
|
||||
/**
|
||||
* Block streaming boundary:
|
||||
* - "text_end": end of each assistant text content block (before tool calls)
|
||||
* - "message_end": end of the whole assistant message (may include tool blocks)
|
||||
*/
|
||||
blockStreamingBreak?: "text_end" | "message_end";
|
||||
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
|
||||
blockStreamingChunk?: {
|
||||
minChars?: number;
|
||||
maxChars?: number;
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
timeoutSeconds?: number;
|
||||
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
||||
mediaMaxMb?: number;
|
||||
typingIntervalSeconds?: number;
|
||||
/** Typing indicator start mode (never|instant|thinking|message). */
|
||||
typingMode?: TypingMode;
|
||||
/** Periodic background heartbeat runs. */
|
||||
heartbeat?: {
|
||||
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
||||
every?: string;
|
||||
/** Heartbeat model override (provider/model). */
|
||||
model?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|signal|imessage|msteams|none). */
|
||||
target?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams"
|
||||
| "none";
|
||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
|
||||
prompt?: string;
|
||||
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
||||
ackMaxChars?: number;
|
||||
};
|
||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||
maxConcurrent?: number;
|
||||
/** Sub-agent defaults (spawned via sessions_spawn). */
|
||||
subagents?: {
|
||||
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
|
||||
maxConcurrent?: number;
|
||||
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
|
||||
archiveAfterMinutes?: number;
|
||||
/** Tool allow/deny policy for sub-agent sessions (deny wins). */
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
/** Bash tool defaults. */
|
||||
bash?: {
|
||||
/** Default time (ms) before a bash command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing bash commands. */
|
||||
timeoutSec?: number;
|
||||
/** How long to keep finished sessions in memory (ms). */
|
||||
cleanupMs?: number;
|
||||
};
|
||||
/** Elevated bash permissions for the host machine. */
|
||||
elevated?: {
|
||||
/** Enable or disable elevated mode (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Approved senders for /elevated (per-provider allowlists). */
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
/** Optional sandbox settings for non-main sessions. */
|
||||
sandbox?: {
|
||||
/** Enable sandboxing for sessions. */
|
||||
mode?: "off" | "non-main" | "all";
|
||||
/**
|
||||
* Agent workspace access inside the sandbox.
|
||||
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
|
||||
* - "ro": mount the agent workspace read-only; disables write/edit tools
|
||||
* - "rw": mount the agent workspace read/write; enables write/edit tools
|
||||
*/
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
/**
|
||||
* Session tools visibility for sandboxed sessions.
|
||||
* - "spawned": only allow session tools to target sessions spawned from this session (default)
|
||||
* - "all": allow session tools to target any session
|
||||
*/
|
||||
sessionToolsVisibility?: "spawned" | "all";
|
||||
/** Container/workspace scope for sandbox isolation. */
|
||||
scope?: "session" | "agent" | "shared";
|
||||
/** Legacy alias for scope ("session" when true, "shared" when false). */
|
||||
perSession?: boolean;
|
||||
/** Root directory for sandbox workspaces. */
|
||||
workspaceRoot?: string;
|
||||
/** Docker-specific sandbox settings. */
|
||||
docker?: SandboxDockerSettings;
|
||||
/** Optional sandboxed browser settings. */
|
||||
browser?: SandboxBrowserSettings;
|
||||
/** Tool allow/deny policy (deny wins). */
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
/** Auto-prune sandbox containers. */
|
||||
prune?: SandboxPruneSettings;
|
||||
};
|
||||
/** Global tool allow/deny policy for all providers (deny wins). */
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
routing?: RoutingConfig;
|
||||
agents?: AgentsConfig;
|
||||
tools?: ToolsConfig;
|
||||
bindings?: AgentBinding[];
|
||||
audio?: AudioConfig;
|
||||
messages?: MessagesConfig;
|
||||
commands?: CommandsConfig;
|
||||
session?: SessionConfig;
|
||||
|
||||
@@ -2,11 +2,7 @@ import {
|
||||
findDuplicateAgentDirs,
|
||||
formatDuplicateAgentDirError,
|
||||
} from "./agent-dirs.js";
|
||||
import {
|
||||
applyIdentityDefaults,
|
||||
applyModelDefaults,
|
||||
applySessionDefaults,
|
||||
} from "./defaults.js";
|
||||
import { applyModelDefaults, applySessionDefaults } from "./defaults.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
@@ -42,7 +38,7 @@ export function validateConfigObject(
|
||||
ok: false,
|
||||
issues: [
|
||||
{
|
||||
path: "routing.agents",
|
||||
path: "agents.list",
|
||||
message: formatDuplicateAgentDirError(duplicates),
|
||||
},
|
||||
],
|
||||
@@ -51,9 +47,7 @@ export function validateConfigObject(
|
||||
return {
|
||||
ok: true,
|
||||
config: applyModelDefaults(
|
||||
applySessionDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
||||
),
|
||||
applySessionDefaults(validated.data as ClawdbotConfig),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ const GroupChatSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const IdentitySchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const QueueModeSchema = z.union([
|
||||
z.literal("steer"),
|
||||
z.literal("followup"),
|
||||
@@ -133,6 +141,16 @@ const QueueModeBySurfaceSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const QueueSchema = z
|
||||
.object({
|
||||
mode: QueueModeSchema.optional(),
|
||||
byProvider: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const TranscribeAudioSchema = z
|
||||
.object({
|
||||
command: z.array(z.string()),
|
||||
@@ -554,6 +572,8 @@ const MessagesSchema = z
|
||||
.object({
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
groupChat: GroupChatSchema,
|
||||
queue: QueueSchema,
|
||||
ackReaction: z.string().optional(),
|
||||
ackReactionScope: z
|
||||
.enum(["group-mentions", "group-all", "direct", "all"])
|
||||
@@ -667,96 +687,140 @@ const ToolPolicySchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const RoutingSchema = z
|
||||
const ElevatedAllowFromSchema = z
|
||||
.object({
|
||||
groupChat: GroupChatSchema,
|
||||
transcribeAudio: TranscribeAudioSchema,
|
||||
defaultAgentId: z.string().optional(),
|
||||
whatsapp: z.array(z.string()).optional(),
|
||||
telegram: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
discord: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
slack: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
signal: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
imessage: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
webchat: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const AgentSandboxSchema = z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
||||
.optional(),
|
||||
workspaceAccess: z
|
||||
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
|
||||
.optional(),
|
||||
sessionToolsVisibility: z
|
||||
.union([z.literal("spawned"), z.literal("all")])
|
||||
.optional(),
|
||||
scope: z
|
||||
.union([z.literal("session"), z.literal("agent"), z.literal("shared")])
|
||||
.optional(),
|
||||
perSession: z.boolean().optional(),
|
||||
workspaceRoot: z.string().optional(),
|
||||
docker: SandboxDockerSchema,
|
||||
browser: SandboxBrowserSchema,
|
||||
prune: SandboxPruneSchema,
|
||||
})
|
||||
.optional();
|
||||
|
||||
const AgentToolsSchema = z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
tools: ToolPolicySchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const AgentEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
default: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
agentDir: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
identity: IdentitySchema,
|
||||
groupChat: GroupChatSchema,
|
||||
subagents: z
|
||||
.object({
|
||||
allowAgents: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
sandbox: AgentSandboxSchema,
|
||||
tools: AgentToolsSchema,
|
||||
});
|
||||
|
||||
const ToolsSchema = z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
agentToAgent: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
agents: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
agentDir: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
subagents: z
|
||||
.object({
|
||||
allowAgents: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("non-main"),
|
||||
z.literal("all"),
|
||||
])
|
||||
.optional(),
|
||||
workspaceAccess: z
|
||||
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
|
||||
.optional(),
|
||||
scope: z
|
||||
.union([
|
||||
z.literal("session"),
|
||||
z.literal("agent"),
|
||||
z.literal("shared"),
|
||||
])
|
||||
.optional(),
|
||||
perSession: z.boolean().optional(),
|
||||
workspaceRoot: z.string().optional(),
|
||||
docker: SandboxDockerSchema,
|
||||
browser: SandboxBrowserSchema,
|
||||
tools: ToolPolicySchema,
|
||||
prune: SandboxPruneSchema,
|
||||
})
|
||||
.optional(),
|
||||
tools: ToolPolicySchema,
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
bindings: z
|
||||
.array(
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
match: z.object({
|
||||
provider: z.string(),
|
||||
accountId: z.string().optional(),
|
||||
peer: z
|
||||
.object({
|
||||
kind: z.union([
|
||||
z.literal("dm"),
|
||||
z.literal("group"),
|
||||
z.literal("channel"),
|
||||
]),
|
||||
id: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
guildId: z.string().optional(),
|
||||
teamId: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
queue: z
|
||||
elevated: z
|
||||
.object({
|
||||
mode: QueueModeSchema.optional(),
|
||||
byProvider: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: ElevatedAllowFromSchema,
|
||||
})
|
||||
.optional(),
|
||||
bash: z
|
||||
.object({
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
subagents: z
|
||||
.object({
|
||||
tools: ToolPolicySchema,
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
tools: ToolPolicySchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const AgentsSchema = z
|
||||
.object({
|
||||
defaults: z.lazy(() => AgentDefaultsSchema).optional(),
|
||||
list: z.array(AgentEntrySchema).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const BindingsSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
match: z.object({
|
||||
provider: z.string(),
|
||||
accountId: z.string().optional(),
|
||||
peer: z
|
||||
.object({
|
||||
kind: z.union([
|
||||
z.literal("dm"),
|
||||
z.literal("group"),
|
||||
z.literal("channel"),
|
||||
]),
|
||||
id: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
guildId: z.string().optional(),
|
||||
teamId: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional();
|
||||
|
||||
const AudioSchema = z
|
||||
.object({
|
||||
transcription: TranscribeAudioSchema,
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -832,6 +896,145 @@ const HooksGmailSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const AgentDefaultsSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
imageModel: z
|
||||
.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
models: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
alias: z.string().optional(),
|
||||
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
workspace: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
contextTokens: z.number().int().positive().optional(),
|
||||
contextPruning: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("adaptive"),
|
||||
z.literal("aggressive"),
|
||||
])
|
||||
.optional(),
|
||||
keepLastAssistants: z.number().int().nonnegative().optional(),
|
||||
softTrimRatio: z.number().min(0).max(1).optional(),
|
||||
hardClearRatio: z.number().min(0).max(1).optional(),
|
||||
minPrunableToolChars: z.number().int().nonnegative().optional(),
|
||||
tools: z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
softTrim: z
|
||||
.object({
|
||||
maxChars: z.number().int().nonnegative().optional(),
|
||||
headChars: z.number().int().nonnegative().optional(),
|
||||
tailChars: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hardClear: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
thinkingDefault: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("minimal"),
|
||||
z.literal("low"),
|
||||
z.literal("medium"),
|
||||
z.literal("high"),
|
||||
])
|
||||
.optional(),
|
||||
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
blockStreamingDefault: z
|
||||
.union([z.literal("off"), z.literal("on")])
|
||||
.optional(),
|
||||
blockStreamingBreak: z
|
||||
.union([z.literal("text_end"), z.literal("message_end")])
|
||||
.optional(),
|
||||
blockStreamingChunk: z
|
||||
.object({
|
||||
minChars: z.number().int().positive().optional(),
|
||||
maxChars: z.number().int().positive().optional(),
|
||||
breakPreference: z
|
||||
.union([
|
||||
z.literal("paragraph"),
|
||||
z.literal("newline"),
|
||||
z.literal("sentence"),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
typingMode: z
|
||||
.union([
|
||||
z.literal("never"),
|
||||
z.literal("instant"),
|
||||
z.literal("thinking"),
|
||||
z.literal("message"),
|
||||
])
|
||||
.optional(),
|
||||
heartbeat: HeartbeatSchema,
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
subagents: z
|
||||
.object({
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
archiveAfterMinutes: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
||||
.optional(),
|
||||
workspaceAccess: z
|
||||
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
|
||||
.optional(),
|
||||
sessionToolsVisibility: z
|
||||
.union([z.literal("spawned"), z.literal("all")])
|
||||
.optional(),
|
||||
scope: z
|
||||
.union([
|
||||
z.literal("session"),
|
||||
z.literal("agent"),
|
||||
z.literal("shared"),
|
||||
])
|
||||
.optional(),
|
||||
perSession: z.boolean().optional(),
|
||||
workspaceRoot: z.string().optional(),
|
||||
docker: SandboxDockerSchema,
|
||||
browser: SandboxBrowserSchema,
|
||||
prune: SandboxPruneSchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const ClawdbotSchema = z.object({
|
||||
env: z
|
||||
.object({
|
||||
@@ -845,13 +1048,6 @@ export const ClawdbotSchema = z.object({
|
||||
})
|
||||
.catchall(z.string())
|
||||
.optional(),
|
||||
identity: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
wizard: z
|
||||
.object({
|
||||
lastRunAt: z.string().optional(),
|
||||
@@ -954,182 +1150,10 @@ export const ClawdbotSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
models: ModelsConfigSchema,
|
||||
agent: z
|
||||
.object({
|
||||
model: z
|
||||
.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
imageModel: z
|
||||
.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
models: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
alias: z.string().optional(),
|
||||
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
workspace: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
contextTokens: z.number().int().positive().optional(),
|
||||
contextPruning: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("adaptive"),
|
||||
z.literal("aggressive"),
|
||||
])
|
||||
.optional(),
|
||||
keepLastAssistants: z.number().int().nonnegative().optional(),
|
||||
softTrimRatio: z.number().min(0).max(1).optional(),
|
||||
hardClearRatio: z.number().min(0).max(1).optional(),
|
||||
minPrunableToolChars: z.number().int().nonnegative().optional(),
|
||||
tools: z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
softTrim: z
|
||||
.object({
|
||||
maxChars: z.number().int().nonnegative().optional(),
|
||||
headChars: z.number().int().nonnegative().optional(),
|
||||
tailChars: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hardClear: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
tools: z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
thinkingDefault: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("minimal"),
|
||||
z.literal("low"),
|
||||
z.literal("medium"),
|
||||
z.literal("high"),
|
||||
])
|
||||
.optional(),
|
||||
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
blockStreamingDefault: z
|
||||
.union([z.literal("off"), z.literal("on")])
|
||||
.optional(),
|
||||
blockStreamingBreak: z
|
||||
.union([z.literal("text_end"), z.literal("message_end")])
|
||||
.optional(),
|
||||
blockStreamingChunk: z
|
||||
.object({
|
||||
minChars: z.number().int().positive().optional(),
|
||||
maxChars: z.number().int().positive().optional(),
|
||||
breakPreference: z
|
||||
.union([
|
||||
z.literal("paragraph"),
|
||||
z.literal("newline"),
|
||||
z.literal("sentence"),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
typingMode: z
|
||||
.union([
|
||||
z.literal("never"),
|
||||
z.literal("instant"),
|
||||
z.literal("thinking"),
|
||||
z.literal("message"),
|
||||
])
|
||||
.optional(),
|
||||
heartbeat: HeartbeatSchema,
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
subagents: z
|
||||
.object({
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
archiveAfterMinutes: z.number().int().positive().optional(),
|
||||
tools: z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
bash: z
|
||||
.object({
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
elevated: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z
|
||||
.object({
|
||||
whatsapp: z.array(z.string()).optional(),
|
||||
telegram: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
discord: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
slack: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
signal: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
imessage: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
msteams: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
webchat: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
||||
.optional(),
|
||||
workspaceAccess: z
|
||||
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
|
||||
.optional(),
|
||||
sessionToolsVisibility: z
|
||||
.union([z.literal("spawned"), z.literal("all")])
|
||||
.optional(),
|
||||
scope: z
|
||||
.union([
|
||||
z.literal("session"),
|
||||
z.literal("agent"),
|
||||
z.literal("shared"),
|
||||
])
|
||||
.optional(),
|
||||
perSession: z.boolean().optional(),
|
||||
workspaceRoot: z.string().optional(),
|
||||
docker: SandboxDockerSchema,
|
||||
browser: SandboxBrowserSchema,
|
||||
tools: ToolPolicySchema,
|
||||
prune: SandboxPruneSchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
routing: RoutingSchema,
|
||||
agents: AgentsSchema,
|
||||
tools: ToolsSchema,
|
||||
bindings: BindingsSchema,
|
||||
audio: AudioSchema,
|
||||
messages: MessagesSchema,
|
||||
commands: CommandsSchema,
|
||||
session: SessionSchema,
|
||||
|
||||
Reference in New Issue
Block a user