feat: wire multi-agent config and routing

Co-authored-by: Mark Pors <1078320+pors@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-09 12:44:23 +00:00
parent 81beda0772
commit 7b81d97ec2
189 changed files with 4340 additions and 2903 deletions

View File

@@ -11,10 +11,8 @@ describe("resolveAgentConfig", () => {
it("should return undefined when agent id does not exist", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
const result = resolveAgentConfig(cfg, "nonexistent");
@@ -23,15 +21,16 @@ describe("resolveAgentConfig", () => {
it("should return basic agent config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main Agent",
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
},
},
],
},
};
const result = resolveAgentConfig(cfg, "main");
@@ -40,6 +39,9 @@ describe("resolveAgentConfig", () => {
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
identity: undefined,
groupChat: undefined,
subagents: undefined,
sandbox: undefined,
tools: undefined,
});
@@ -47,9 +49,10 @@ describe("resolveAgentConfig", () => {
it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -57,13 +60,9 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "work");
@@ -73,25 +72,22 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
});
});
it("should return agent-specific tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "restricted");
@@ -103,9 +99,10 @@ describe("resolveAgentConfig", () => {
it("should return both sandbox and tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
family: {
agents: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all",
@@ -116,7 +113,7 @@ describe("resolveAgentConfig", () => {
deny: ["bash"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "family");
@@ -126,10 +123,8 @@ describe("resolveAgentConfig", () => {
it("should normalize agent id", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
// Should normalize to "main" (default)

View File

@@ -11,6 +11,24 @@ import {
import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"];
sandbox?: AgentEntry["sandbox"];
tools?: AgentEntry["tools"];
};
let defaultAgentWarned = false;
export function resolveAgentIdFromSessionKey(
sessionKey?: string | null,
): string {
@@ -18,46 +36,51 @@ export function resolveAgentIdFromSessionKey(
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
}
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const agents = listAgents(cfg);
if (agents.length === 0) return DEFAULT_AGENT_ID;
const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true;
console.warn(
"Multiple agents marked default=true; using the first entry as default.",
);
}
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
}
function resolveAgentEntry(
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
export function resolveAgentConfig(
cfg: ClawdbotConfig,
agentId: string,
):
| {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
subagents?: {
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
workspaceAccess?: "none" | "ro" | "rw";
scope?: "session" | "agent" | "shared";
perSession?: boolean;
workspaceRoot?: string;
tools?: {
allow?: string[];
deny?: string[];
};
};
tools?: {
allow?: string[];
deny?: string[];
};
}
| undefined {
): ResolvedAgentConfig | undefined {
const id = normalizeAgentId(agentId);
const agents = cfg.routing?.agents;
if (!agents || typeof agents !== "object") return undefined;
const entry = agents[id];
if (!entry || typeof entry !== "object") return undefined;
const entry = resolveAgentEntry(cfg, id);
if (!entry) return undefined;
return {
name: typeof entry.name === "string" ? entry.name : undefined,
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
identity: entry.identity,
groupChat: entry.groupChat,
subagents:
typeof entry.subagents === "object" && entry.subagents
? entry.subagents
@@ -71,9 +94,10 @@ export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
if (configured) return resolveUserPath(configured);
if (id === DEFAULT_AGENT_ID) {
const legacy = cfg.agent?.workspace?.trim();
if (legacy) return resolveUserPath(legacy);
const defaultAgentId = resolveDefaultAgentId(cfg);
if (id === defaultAgentId) {
const fallback = cfg.agents?.defaults?.workspace?.trim();
if (fallback) return resolveUserPath(fallback);
return DEFAULT_AGENT_WORKSPACE_DIR;
}
return path.join(os.homedir(), `clawd-${id}`);

View File

@@ -925,7 +925,10 @@ export function resolveAuthProfileOrder(params: {
// Still put preferredProfile first if specified
if (preferredProfile && ordered.includes(preferredProfile)) {
return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)];
return [
preferredProfile,
...ordered.filter((e) => e !== preferredProfile),
];
}
return ordered;
}

View File

@@ -108,7 +108,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -134,7 +134,9 @@ function buildSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone);
const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
@@ -143,7 +145,7 @@ function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo: {
host: "clawdbot",

View File

@@ -46,7 +46,7 @@ describe("gateway tool", () => {
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
const raw = '{\n agent: { workspace: "~/clawd" }\n}\n';
const raw = '{\n agents: { defaults: { workspace: "~/clawd" } }\n}\n';
await tool.execute("call2", {
action: "config.apply",
raw,

View File

@@ -52,18 +52,20 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main",
subagents: {
allowAgents: ["research"],
},
},
research: {
{
id: "research",
name: "Research",
},
},
],
},
};
@@ -87,20 +89,23 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
research: {
{
id: "research",
name: "Research",
},
coder: {
{
id: "coder",
name: "Coder",
},
},
],
},
};
@@ -131,14 +136,15 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
},
],
},
};

View File

@@ -314,14 +314,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["beta"],
},
},
},
],
},
};
@@ -365,14 +366,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
},
],
},
};
@@ -416,14 +418,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["Research"],
},
},
},
],
},
};
@@ -467,14 +470,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["alpha"],
},
},
},
],
},
};

View File

@@ -34,7 +34,7 @@ function buildAllowedModelKeys(
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = (() => {
const modelMap = cfg?.agent?.models ?? {};
const modelMap = cfg?.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null;
@@ -85,7 +85,7 @@ function resolveImageFallbackCandidates(params: {
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false);
} else {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { primary?: string }
| string
| undefined;
@@ -95,7 +95,7 @@ function resolveImageFallbackCandidates(params: {
}
const imageFallbacks = (() => {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { fallbacks?: string[] }
| string
| undefined;
@@ -142,7 +142,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
const model = params.cfg?.agent?.model as
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
| undefined;
@@ -253,7 +253,7 @@ export async function runWithImageModelFallback<T>(params: {
});
if (candidates.length === 0) {
throw new Error(
"No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.",
"No image model configured. Set agents.defaults.imageModel.primary or agents.defaults.imageModel.fallbacks.",
);
}

View File

@@ -18,9 +18,11 @@ const catalog = [
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agent: {
models: {
"openai/gpt-4": { alias: "gpt4" },
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
},
},
},
} as ClawdbotConfig;
@@ -41,7 +43,7 @@ describe("buildAllowedModelSet", () => {
it("includes the default model when no allowlist is set", () => {
const cfg = {
agent: {},
agents: { defaults: {} },
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({

View File

@@ -65,7 +65,7 @@ export function buildModelAliasIndex(params: {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
const rawModels = params.cfg.agent?.models ?? {};
const rawModels = params.cfg.agents?.defaults?.models ?? {};
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
if (!parsed) continue;
@@ -109,7 +109,7 @@ export function resolveConfiguredModelRef(params: {
defaultModel: string;
}): ModelRef {
const rawModel = (() => {
const raw = params.cfg.agent?.model as
const raw = params.cfg.agents?.defaults?.model as
| { primary?: string }
| string
| undefined;
@@ -128,7 +128,7 @@ export function resolveConfiguredModelRef(params: {
aliasIndex,
});
if (resolved) return resolved.ref;
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated.
// TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
return { provider: "anthropic", model: trimmed };
}
return { provider: params.defaultProvider, model: params.defaultModel };
@@ -145,7 +145,7 @@ export function buildAllowedModelSet(params: {
allowedKeys: Set<string>;
} {
const rawAllowlist = (() => {
const modelMap = params.cfg.agent?.models ?? {};
const modelMap = params.cfg.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
const allowAny = rawAllowlist.length === 0;
@@ -203,7 +203,7 @@ export function resolveThinkingDefault(params: {
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel {
const configured = params.cfg.agent?.thinkingDefault;
const configured = params.cfg.agents?.defaults?.thinkingDefault;
if (configured) return configured;
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,

View File

@@ -110,12 +110,14 @@ describe("resolveExtraParams", () => {
it("respects explicit thinking config from user (disable thinking)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
},
},
},
},
@@ -136,12 +138,14 @@ describe("resolveExtraParams", () => {
it("preserves other params while adding thinking config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
},
},
},
},
@@ -164,13 +168,15 @@ describe("resolveExtraParams", () => {
it("does not override explicit thinking config even if partial", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
},
},
},
},
@@ -214,12 +220,14 @@ describe("resolveExtraParams", () => {
it("passes through params for non-GLM models without modification", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
},
},
},
},
@@ -264,7 +272,7 @@ describe("resolveExtraParams", () => {
it("handles config with empty models gracefully", () => {
const result = resolveExtraParams({
cfg: { agent: { models: {} } },
cfg: { agents: { defaults: { models: {} } } },
provider: "zai",
modelId: "glm-4.7",
});
@@ -280,12 +288,14 @@ describe("resolveExtraParams", () => {
it("model alias lookup uses exact provider/model key", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
},
},
},
},
@@ -307,11 +317,13 @@ describe("resolveExtraParams", () => {
it("treats thinking: null as explicit config (no auto-enable)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
},
},
},
},
@@ -374,11 +386,13 @@ describe("resolveExtraParams", () => {
it("thinkLevel: 'off' still passes through explicit config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
},
},
},
},

View File

@@ -105,7 +105,7 @@ import { loadWorkspaceBootstrapFiles } from "./workspace.js";
* - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn
*
* Users can override via config:
* agent.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
* agents.defaults.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
*
* Or disable via runtime flag: --thinking off
*
@@ -119,7 +119,7 @@ export function resolveExtraParams(params: {
thinkLevel?: string;
}): Record<string, unknown> | undefined {
const modelKey = `${params.provider}/${params.modelId}`;
const modelConfig = params.cfg?.agent?.models?.[modelKey];
const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey];
let extraParams = modelConfig?.params ? { ...modelConfig.params } : undefined;
// Auto-enable thinking for ZAI GLM-4.x models when not explicitly configured
@@ -200,10 +200,10 @@ function resolveContextWindowTokens(params: {
if (fromModelsConfig) return fromModelsConfig;
const fromAgentConfig =
typeof params.cfg?.agent?.contextTokens === "number" &&
Number.isFinite(params.cfg.agent.contextTokens) &&
params.cfg.agent.contextTokens > 0
? Math.floor(params.cfg.agent.contextTokens)
typeof params.cfg?.agents?.defaults?.contextTokens === "number" &&
Number.isFinite(params.cfg.agents.defaults.contextTokens) &&
params.cfg.agents.defaults.contextTokens > 0
? Math.floor(params.cfg.agents.defaults.contextTokens)
: undefined;
if (fromAgentConfig) return fromAgentConfig;
@@ -217,7 +217,7 @@ function buildContextPruningExtension(params: {
modelId: string;
model: Model<Api> | undefined;
}): { additionalExtensionPaths?: string[] } {
const raw = params.cfg?.agent?.contextPruning;
const raw = params.cfg?.agents?.defaults?.contextPruning;
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
const settings = computeEffectiveSettings(raw);
@@ -254,7 +254,7 @@ export type EmbeddedPiRunMeta = {
};
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -844,7 +844,7 @@ export async function compactEmbeddedPiSession(params: {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -865,7 +865,7 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -875,7 +875,7 @@ export async function compactEmbeddedPiSession(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1157,7 +1157,7 @@ export async function runEmbeddedPiAgent(params: {
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -1178,7 +1178,7 @@ export async function runEmbeddedPiAgent(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -1188,7 +1188,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1444,7 +1444,8 @@ export async function runEmbeddedPiAgent(params: {
}
const fallbackConfigured =
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) >
0;
const authFailure = isAuthAssistantError(lastAssistant);
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);

View File

@@ -6,18 +6,17 @@ import type { SandboxDockerConfig } from "./sandbox.js";
describe("Agent-specific tool filtering", () => {
it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
},
},
],
},
};
@@ -36,22 +35,21 @@ describe("Agent-specific tool filtering", () => {
it("should apply agent-specific tool policy", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"], // Agent override: only read
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
@@ -71,20 +69,22 @@ describe("Agent-specific tool filtering", () => {
it("should allow different tool policies for different agents", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
// No tools restriction - all tools available
},
family: {
{
id: "family",
workspace: "~/clawd-family",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit", "process"],
},
},
},
],
},
};
@@ -116,20 +116,19 @@ describe("Agent-specific tool filtering", () => {
it("should prefer agent-specific tool policy over global", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["browser"], // Global deny
},
tools: {
deny: ["browser"], // Global deny
},
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
tools: {
deny: ["bash", "process"], // Agent deny (override)
},
},
},
],
},
};
@@ -149,19 +148,16 @@ describe("Agent-specific tool filtering", () => {
it("should work with sandbox tools filtering", () => {
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
@@ -172,6 +168,14 @@ describe("Agent-specific tool filtering", () => {
deny: ["bash", "write"],
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
},
},
},
};
@@ -216,10 +220,8 @@ describe("Agent-specific tool filtering", () => {
it("should run bash synchronously when process is denied", async () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["process"],
},
tools: {
deny: ["process"],
},
};

View File

@@ -171,7 +171,7 @@ describe("createClawdbotCodingTools", () => {
sessionKey: "agent:main:subagent:test",
// Intentionally partial config; only fields used by pi-tools are provided.
config: {
agent: {
tools: {
subagents: {
tools: {
// Policy matching is case-insensitive
@@ -325,7 +325,7 @@ describe("createClawdbotCodingTools", () => {
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
config: { tools: { deny: ["browser"] } },
});
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);

View File

@@ -429,7 +429,7 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
];
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
const configured = cfg?.agent?.subagents?.tools;
const configured = cfg?.tools?.subagents?.tools;
const deny = [
...DEFAULT_SUBAGENT_TOOL_DENY,
...(Array.isArray(configured?.deny) ? configured.deny : []),
@@ -466,7 +466,7 @@ function resolveEffectiveToolPolicy(params: {
? resolveAgentConfig(params.config, agentId)
: undefined;
const hasAgentTools = agentConfig?.tools !== undefined;
const globalTools = params.config?.agent?.tools;
const globalTools = params.config?.tools;
return {
agentId,
policy: hasAgentTools ? agentConfig?.tools : globalTools,

View File

@@ -56,18 +56,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
},
},
routing: {
agents: {
main: {
workspace: "~/clawd",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
list: [
{
id: "main",
workspace: "~/clawd",
},
],
},
};
@@ -85,18 +86,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -106,7 +108,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -133,18 +135,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -154,7 +157,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -182,19 +185,20 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -205,7 +209,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -224,21 +228,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all", // Global default
scope: "agent",
agents: {
defaults: {
sandbox: {
mode: "all", // Global default
scope: "agent",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // Agent override
},
},
},
],
},
};
@@ -256,21 +261,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "off", // Global default
agents: {
defaults: {
sandbox: {
mode: "off", // Global default
},
},
},
routing: {
agents: {
family: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // Agent override
scope: "agent",
},
},
},
],
},
};
@@ -288,22 +294,23 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "session", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "session", // Global default
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
scope: "agent", // Agent override
},
},
},
],
},
};
@@ -322,16 +329,17 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
},
},
},
routing: {
agents: {
isolated: {
list: [
{
id: "isolated",
workspace: "~/clawd-isolated",
sandbox: {
mode: "all",
@@ -339,7 +347,7 @@ describe("Agent-specific sandbox config", () => {
workspaceRoot: "/tmp/isolated-sandboxes", // Agent override
},
},
},
],
},
};
@@ -359,28 +367,30 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "non-main",
scope: "session",
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // main: no sandbox
},
},
family: {
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // family: always sandbox
scope: "agent",
},
},
},
],
},
};
@@ -406,29 +416,38 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read"],
deny: ["bash"],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
},
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
};

View File

@@ -22,7 +22,10 @@ import {
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
import {
resolveAgentConfig,
resolveAgentIdFromSessionKey,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
@@ -345,15 +348,14 @@ export function resolveSandboxConfigForAgent(
cfg?: ClawdbotConfig,
agentId?: string,
): SandboxConfig {
const agent = cfg?.agent?.sandbox;
const agent = cfg?.agents?.defaults?.sandbox;
// Agent-specific sandbox config overrides global
let agentSandbox: typeof agent | undefined;
if (agentId && cfg?.routing?.agents) {
const agentConfig = cfg.routing.agents[agentId];
if (agentConfig && typeof agentConfig === "object") {
agentSandbox = agentConfig.sandbox;
}
const agentConfig =
cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
if (agentConfig?.sandbox) {
agentSandbox = agentConfig.sandbox;
}
const scope = resolveSandboxScope({
@@ -382,9 +384,13 @@ export function resolveSandboxConfigForAgent(
}),
tools: {
allow:
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
agentConfig?.tools?.sandbox?.tools?.allow ??
cfg?.tools?.sandbox?.tools?.allow ??
DEFAULT_TOOL_ALLOW,
deny:
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
agentConfig?.tools?.sandbox?.tools?.deny ??
cfg?.tools?.sandbox?.tools?.deny ??
DEFAULT_TOOL_DENY,
},
prune: resolveSandboxPruneConfig({
scope,
@@ -1059,7 +1065,7 @@ export async function resolveSandboxContext(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
@@ -1133,7 +1139,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {

View File

@@ -24,7 +24,7 @@ let listenerStarted = false;
function resolveArchiveAfterMs() {
const cfg = loadConfig();
const minutes = cfg.agent?.subagents?.archiveAfterMinutes ?? 60;
const minutes = cfg.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60;
if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
return Math.max(1, Math.floor(minutes)) * 60_000;
}

View File

@@ -8,7 +8,7 @@ const normalizeNumber = (value: unknown): number | undefined =>
: undefined;
export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number {
const raw = normalizeNumber(cfg?.agent?.timeoutSeconds);
const raw = normalizeNumber(cfg?.agents?.defaults?.timeoutSeconds);
const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS;
return Math.max(seconds, 1);
}

View File

@@ -55,19 +55,17 @@ export function createAgentsListTool(opts?: {
.map((value) => normalizeAgentId(value)),
);
const configuredAgents = cfg.routing?.agents ?? {};
const configuredIds = Object.keys(configuredAgents).map((key) =>
normalizeAgentId(key),
const configuredAgents = Array.isArray(cfg.agents?.list)
? cfg.agents?.list
: [];
const configuredIds = configuredAgents.map((entry) =>
normalizeAgentId(entry.id),
);
const configuredNameMap = new Map<string, string>();
for (const [key, value] of Object.entries(configuredAgents)) {
if (!value || typeof value !== "object") continue;
const name =
typeof (value as { name?: unknown }).name === "string"
? ((value as { name?: string }).name?.trim() ?? "")
: "";
for (const entry of configuredAgents) {
const name = entry?.name?.trim() ?? "";
if (!name) continue;
configuredNameMap.set(normalizeAgentId(key), name);
configuredNameMap.set(normalizeAgentId(entry.id), name);
}
const allowed = new Set<string>();

View File

@@ -23,7 +23,7 @@ import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image.";
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
const imageModel = cfg?.agent?.imageModel as
const imageModel = cfg?.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
@@ -45,7 +45,7 @@ function pickMaxBytes(
) {
return Math.floor(maxBytesMb * 1024 * 1024);
}
const configured = cfg?.agent?.mediaMaxMb;
const configured = cfg?.agents?.defaults?.mediaMaxMb;
if (
typeof configured === "number" &&
Number.isFinite(configured) &&
@@ -141,7 +141,7 @@ export function createImageTool(options?: {
label: "Image",
name: "image",
description:
"Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.",
"Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL.",
parameters: Type.Object({
prompt: Type.Optional(Type.String()),
image: Type.String(),

View File

@@ -25,7 +25,7 @@ const SessionsHistoryToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
async function isSpawnedSessionAllowed(params: {
@@ -97,7 +97,7 @@ export function createSessionsHistoryTool(opts?: {
}
}
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -126,14 +126,13 @@ export function createSessionsHistoryTool(opts?: {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.",
"Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history denied by routing.agentToAgent.allow.",
error: "Agent-to-agent history denied by tools.agentToAgent.allow.",
});
}
}

View File

@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -32,7 +32,7 @@ describe("sessions_list gating", () => {
});
});
it("filters out other agents when routing.agentToAgent.enabled is false", async () => {
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({

View File

@@ -53,7 +53,7 @@ const SessionsListToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
export function createSessionsListTool(opts?: {
@@ -126,7 +126,7 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow

View File

@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -25,7 +25,7 @@ describe("sessions_send gating", () => {
callGatewayMock.mockReset();
});
it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => {
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main",
agentProvider: "whatsapp",

View File

@@ -54,7 +54,7 @@ export function createSessionsSendTool(opts?: {
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
@@ -126,7 +126,7 @@ export function createSessionsSendTool(opts?: {
mainKey,
});
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -156,7 +156,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.",
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
sessionKey: displayKey,
});
}
@@ -165,7 +165,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by routing.agentToAgent.allow.",
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
sessionKey: displayKey,
});
}