diff --git a/CHANGELOG.md b/CHANGELOG.md index 821f72215..347f6215f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ - Agent: return a friendly context overflow response (413/request_too_large). Thanks @alejandroOPI for PR #395. - Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298. - Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent. +- Sub-agents: allow `sessions_spawn` to target other agents via per-agent allowlists (`routing.agents..subagents.allowAgents`). - Sessions: forward explicit sessionKey through gateway/chat/node bridge to avoid sub-agent sessionId mixups. - Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior. - Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index d1e0cb343..d1ee35bd0 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -126,10 +126,14 @@ Spawn a sub-agent run in an isolated session and announce the result back to the Parameters: - `task` (required) - `label?` (optional; used for logs/UI) +- `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values error) - `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds) - `cleanup?` (`delete|keep`, default `keep`) +Allowlist: +- `routing.agents..subagents.allowAgents`: list of agent ids allowed via `agentId` (`["*"]` to allow any). Default: only the requester agent. + Behavior: - Starts a new `agent::subagent:` session with `deliver: false`. - Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 03805f105..3527399f6 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -392,6 +392,8 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `browser`: per-agent sandboxed browser overrides (ignored when `scope: "shared"`) - `prune`: per-agent sandbox pruning overrides (ignored when `scope: "shared"`) - `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`) + - `subagents`: per-agent sub-agent defaults. + - `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent) - `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy). - `allow`: array of allowed tool names - `deny`: array of denied tool names (deny wins) diff --git a/docs/tools/index.md b/docs/tools/index.md index 3b43f112a..4507cde8f 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -177,7 +177,7 @@ Core parameters: - `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) - `sessions_history`: `sessionKey`, `limit?`, `includeTools?` - `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget) -- `sessions_spawn`: `task`, `label?`, `model?`, `runTimeoutSeconds?`, `cleanup?` +- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?` Notes: - `main` is the canonical direct-chat key; global/unknown are hidden. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index c2d25e389..427d04a20 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -24,10 +24,14 @@ Use `sessions_spawn`: Tool params: - `task` (required) - `label?` (optional) +- `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result) - `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) - `cleanup?` (`delete|keep`, default `keep`) +Allowlist: +- `routing.agents..subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent. + Auto-archive: - Sub-agent sessions are automatically archived after `agent.subagents.archiveAfterMinutes` (default: 60). - Archive uses `sessions.delete` and renames the transcript to `*.deleted.` (same folder). diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 384976e9c..01edbf808 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -27,6 +27,9 @@ export function resolveAgentConfig( workspace?: string; agentDir?: string; model?: string; + subagents?: { + allowAgents?: string[]; + }; sandbox?: { mode?: "off" | "non-main" | "all"; workspaceAccess?: "none" | "ro" | "rw"; @@ -55,6 +58,10 @@ export function resolveAgentConfig( typeof entry.workspace === "string" ? entry.workspace : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, model: typeof entry.model === "string" ? entry.model : undefined, + subagents: + typeof entry.subagents === "object" && entry.subagents + ? entry.subagents + : undefined, sandbox: entry.sandbox, tools: entry.tools, }; diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts index 0df0a0abd..992ddf65e 100644 --- a/src/agents/clawdbot-tools.subagents.test.ts +++ b/src/agents/clawdbot-tools.subagents.test.ts @@ -1,20 +1,24 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); +let configOverride: ReturnType< + typeof import("../config/config.js")["loadConfig"] +> = { + session: { + mainKey: "main", + scope: "per-sender", + }, +}; + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadConfig: () => ({ - session: { - mainKey: "main", - scope: "per-sender", - }, - }), + loadConfig: () => configOverride, resolveGatewayPort: () => 18789, }; }); @@ -24,6 +28,15 @@ import { createClawdbotTools } from "./clawdbot-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; describe("subagents", () => { + beforeEach(() => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + }, + }; + }); + it("sessions_spawn announces back to the requester group provider", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); @@ -273,6 +286,92 @@ describe("subagents", () => { expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); + it("sessions_spawn allows cross-agent spawning when configured", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + }, + routing: { + agents: { + main: { + subagents: { + allowAgents: ["beta"], + }, + }, + }, + }, + }; + + let childSessionKey: string | undefined; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + if (request.method === "agent") { + const params = request.params as { sessionKey?: string } | undefined; + childSessionKey = params?.sessionKey; + return { runId: "run-1", status: "accepted", acceptedAt: 5000 }; + } + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "main", + agentProvider: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call6", { + task: "do thing", + agentId: "beta", + }); + + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true); + }); + + it("sessions_spawn forbids cross-agent spawning when not allowed", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + }, + routing: { + agents: { + main: { + subagents: { + allowAgents: ["alpha"], + }, + }, + }, + }, + }; + + const tool = createClawdbotTools({ + agentSessionKey: "main", + agentProvider: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call7", { + task: "do thing", + agentId: "beta", + }); + expect(result.details).toMatchObject({ + status: "forbidden", + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("sessions_spawn applies a model to the child session", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 67ecb83ae..87576fa8a 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -168,7 +168,7 @@ "sessions_spawn": { "emoji": "🧑‍🔧", "title": "Sub-agent", - "detailKeys": ["label", "runTimeoutSeconds", "cleanup"] + "detailKeys": ["label", "agentId", "runTimeoutSeconds", "cleanup"] }, "whatsapp_login": { "emoji": "🟢", diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts index 84d5fdff8..1da62189a 100644 --- a/src/agents/tools/agent-step.ts +++ b/src/agents/tools/agent-step.ts @@ -28,14 +28,14 @@ export async function runAgentStep(params: { const stepIdem = crypto.randomUUID(); const response = (await callGateway({ method: "agent", - params: { - message: params.message, - sessionKey: params.sessionKey, - idempotencyKey: stepIdem, - deliver: false, - lane: params.lane ?? "nested", - extraSystemPrompt: params.extraSystemPrompt, - }, + params: { + message: params.message, + sessionKey: params.sessionKey, + idempotencyKey: stepIdem, + deliver: false, + lane: params.lane ?? "nested", + extraSystemPrompt: params.extraSystemPrompt, + }, timeoutMs: 10_000, })) as { runId?: string; acceptedAt?: number }; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index fe983feb5..1ad1235af 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -9,6 +9,7 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; +import { resolveAgentConfig } from "../agent-scope.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { registerSubagentRun } from "../subagent-registry.js"; import type { AnyAgentTool } from "./common.js"; @@ -22,6 +23,7 @@ import { const SessionsSpawnToolSchema = Type.Object({ task: Type.String(), label: Type.Optional(Type.String()), + agentId: Type.Optional(Type.String()), model: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), // Back-compat alias. Prefer runTimeoutSeconds. @@ -46,6 +48,7 @@ export function createSessionsSpawnTool(opts?: { const params = args as Record; const task = readStringParam(params, "task", { required: true }); const label = typeof params.label === "string" ? params.label.trim() : ""; + const requestedAgentId = readStringParam(params, "agentId"); const model = readStringParam(params, "model"); const cleanup = params.cleanup === "keep" || params.cleanup === "delete" @@ -96,7 +99,34 @@ export function createSessionsSpawnTool(opts?: { const requesterAgentId = normalizeAgentId( parseAgentSessionKey(requesterInternalKey)?.agentId, ); - const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`; + const targetAgentId = requestedAgentId + ? normalizeAgentId(requestedAgentId) + : requesterAgentId; + if (targetAgentId !== requesterAgentId) { + const allowAgents = + resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? + []; + const allowAny = allowAgents.some( + (value) => value.trim() === "*", + ); + const allowSet = new Set( + allowAgents + .filter((value) => value.trim() && value.trim() !== "*") + .map((value) => normalizeAgentId(value)), + ); + if (!allowAny && !allowSet.has(targetAgentId)) { + const allowedText = allowAny + ? "*" + : allowSet.size > 0 + ? Array.from(allowSet).join(", ") + : "none"; + return jsonResult({ + status: "forbidden", + error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`, + }); + } + } + const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; if (opts?.sandboxed === true) { try { await callGateway({ diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 0bbd5fcac..4ce3e9d1e 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -19,7 +19,10 @@ import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { hasNonzeroUsage } from "../agents/usage.js"; import { - DEFAULT_AGENT_WORKSPACE_DIR, + resolveAgentDir, + resolveAgentWorkspaceDir, +} from "../agents/agent-scope.js"; +import { ensureAgentWorkspace, } from "../agents/workspace.js"; import type { MsgContext } from "../auto-reply/templating.js"; @@ -180,7 +183,11 @@ export async function agentCommand( const cfg = loadConfig(); const agentCfg = cfg.agent; - const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const sessionAgentId = resolveAgentIdFromSessionKey( + opts.sessionKey?.trim(), + ); + const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId); + const agentDir = resolveAgentDir(cfg, sessionAgentId); const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !cfg.agent?.skipBootstrap, @@ -427,6 +434,7 @@ export async function agentCommand( lane: opts.lane, abortSignal: opts.abortSignal, extraSystemPrompt: opts.extraSystemPrompt, + agentDir, onAgentEvent: (evt) => { if ( evt.stream === "lifecycle" && diff --git a/src/config/types.ts b/src/config/types.ts index 0d665dba0..70b2cc45a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -717,6 +717,10 @@ export type RoutingConfig = { workspace?: string; agentDir?: string; model?: 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. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 5ea893db8..d7e9713e0 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -638,6 +638,11 @@ const RoutingSchema = z workspace: z.string().optional(), agentDir: z.string().optional(), model: z.string().optional(), + subagents: z + .object({ + allowAgents: z.array(z.string()).optional(), + }) + .optional(), sandbox: z .object({ mode: z