feat: allow sessions_spawn cross-agent

This commit is contained in:
Peter Steinberger
2026-01-08 06:55:28 +00:00
parent d1ceb3aa60
commit 0ba72477de
13 changed files with 184 additions and 20 deletions

View File

@@ -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,
};

View File

@@ -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<typeof import("../config/config.js")>();
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();

View File

@@ -168,7 +168,7 @@
"sessions_spawn": {
"emoji": "🧑‍🔧",
"title": "Sub-agent",
"detailKeys": ["label", "runTimeoutSeconds", "cleanup"]
"detailKeys": ["label", "agentId", "runTimeoutSeconds", "cleanup"]
},
"whatsapp_login": {
"emoji": "🟢",

View File

@@ -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 };

View File

@@ -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<string, unknown>;
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({

View File

@@ -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" &&

View File

@@ -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. */

View File

@@ -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