feat(agent): add sessions_spawn tool for subagent orchestration

Register sessions_spawn tool in the tool system with TypeBox schema.
Subagents are blocked from spawning nested subagents via both tool
policy (DEFAULT_SUBAGENT_TOOL_DENY) and runtime guard. Add group:subagent
tool group and parentSessionId to AgentOptions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yushen 2026-02-03 16:49:27 +08:00
parent 5b6a1c6953
commit 83b557a6fc
5 changed files with 204 additions and 11 deletions

View file

@ -6,6 +6,7 @@ import { createProcessTool } from "./tools/process.js";
import { createGlobTool } from "./tools/glob.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
import { createMemoryTools } from "./tools/memory/index.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
import { filterTools } from "./tools/policy.js";
import { isMulticaError, isRetryableError } from "../shared/errors.js";
@ -19,6 +20,10 @@ export interface CreateToolsOptions {
profileId?: string | undefined;
/** Base directory for profiles (optional) */
profileBaseDir?: string | undefined;
/** Whether this agent is a subagent (passed to sessions_spawn tool) */
isSubagent?: boolean | undefined;
/** Session ID of the agent (passed to sessions_spawn tool) */
sessionId?: string | undefined;
}
type ToolErrorPayload = {
@ -88,7 +93,7 @@ function wrapTool<TParams, TResult>(
export function createAllTools(options: CreateToolsOptions | string): AgentTool<any>[] {
// Support legacy string argument for backwards compatibility
const opts: CreateToolsOptions = typeof options === "string" ? { cwd: options } : options;
const { cwd, profileId, profileBaseDir } = opts;
const { cwd, profileId, profileBaseDir, isSubagent, sessionId } = opts;
const baseTools = createCodingTools(cwd).filter(
(tool) => tool.name !== "bash",
@ -118,6 +123,13 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
tools.push(...memoryTools);
}
// Add sessions_spawn tool (will be filtered by policy for subagents)
const sessionsSpawnTool = createSessionsSpawnTool({
isSubagent: isSubagent ?? false,
sessionId,
});
tools.push(sessionsSpawnTool as AgentTool<any>);
return tools;
}
@ -138,6 +150,8 @@ export function resolveTools(options: AgentOptions): AgentTool<any>[] {
cwd,
profileId: options.profileId,
profileBaseDir: options.profileBaseDir,
isSubagent: options.isSubagent,
sessionId: options.sessionId,
});
// Apply policy filtering

View file

@ -35,6 +35,9 @@ export const TOOL_GROUPS: Record<string, string[]> = {
// Memory tools (requires profileId)
"group:memory": ["memory_get", "memory_set", "memory_delete", "memory_list"],
// Subagent tools
"group:subagent": ["sessions_spawn"],
// All core tools
"group:core": [
"read",
@ -76,16 +79,8 @@ export const TOOL_PROFILES: Record<ToolProfileId, { allow?: string[]; deny?: str
* Subagents should not have access to session management or system tools.
*/
export const DEFAULT_SUBAGENT_TOOL_DENY: string[] = [
// Future: session management tools
// "sessions_list",
// "sessions_history",
// "sessions_send",
// "sessions_spawn",
// "session_status",
// Future: system tools
// "gateway",
// "agents_list",
// Subagents cannot spawn subagents (no nested spawning)
"sessions_spawn",
];
/**

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { createSessionsSpawnTool } from "./sessions-spawn.js";
describe("sessions_spawn tool", () => {
it("has correct name and description", () => {
const tool = createSessionsSpawnTool({ isSubagent: false, sessionId: "test-session" });
expect(tool.name).toBe("sessions_spawn");
expect(tool.label).toBe("Spawn Subagent");
expect(tool.description).toContain("Spawn a background subagent");
});
it("rejects spawn from subagent sessions", async () => {
const tool = createSessionsSpawnTool({ isSubagent: true, sessionId: "child-session" });
const result = await tool.execute(
"call-1",
{ task: "do something" } as any,
new AbortController().signal,
);
expect(result.details.status).toBe("error");
expect(result.details.error).toContain("not allowed from sub-agent sessions");
const firstContent = result.content[0] as { type: string; text: string };
expect(firstContent.text).toContain("not allowed");
});
it("fails gracefully when Hub is not initialized", async () => {
const tool = createSessionsSpawnTool({ isSubagent: false, sessionId: "parent-session" });
const result = await tool.execute(
"call-2",
{ task: "analyze code", label: "Code Analysis" } as any,
new AbortController().signal,
);
// Should get an error because Hub singleton is not set up in test
expect(result.details.status).toBe("error");
expect(result.details.error).toContain("Hub");
});
});

View file

@ -0,0 +1,142 @@
/**
* sessions_spawn tool allows a parent agent to spawn subagent runs.
*
* Subagents run in isolated sessions with restricted tools.
* Results are announced back to the parent when the child completes.
*/
import { v7 as uuidv7 } from "uuid";
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { getHub } from "../../hub/hub-singleton.js";
import { buildSubagentSystemPrompt } from "../subagent/announce.js";
import { registerSubagentRun } from "../subagent/registry.js";
const SessionsSpawnSchema = Type.Object({
task: Type.String({ description: "The task for the subagent to perform." }),
label: Type.Optional(
Type.String({ description: "Human-readable label for this background task." }),
),
model: Type.Optional(
Type.String({ description: "Override the LLM model for the subagent (e.g. 'gpt-4o', 'claude-sonnet')." }),
),
cleanup: Type.Optional(
Type.Union([Type.Literal("delete"), Type.Literal("keep")], {
description: "Session cleanup after completion. 'delete' removes session files, 'keep' preserves for audit. Default: 'delete'.",
}),
),
timeoutSeconds: Type.Optional(
Type.Number({
description: "Execution timeout in seconds. The subagent will be terminated if it exceeds this.",
minimum: 1,
}),
),
});
type SessionsSpawnArgs = {
task: string;
label?: string;
model?: string;
cleanup?: "delete" | "keep";
timeoutSeconds?: number;
};
export type SessionsSpawnResult = {
status: "accepted" | "error";
childSessionId?: string;
runId?: string;
error?: string;
};
export interface CreateSessionsSpawnToolOptions {
/** Whether the current agent is itself a subagent */
isSubagent?: boolean;
/** Session ID of the current (requester) agent */
sessionId?: string;
}
export function createSessionsSpawnTool(
options: CreateSessionsSpawnToolOptions,
): AgentTool<typeof SessionsSpawnSchema, SessionsSpawnResult> {
return {
name: "sessions_spawn",
label: "Spawn Subagent",
description:
"Spawn a background subagent to handle a specific task. The subagent runs in an isolated session with its own tool set. " +
"When it completes, its findings are announced back to you automatically. " +
"Use this for parallelizable work, long-running analysis, or tasks that benefit from isolation.",
parameters: SessionsSpawnSchema,
execute: async (_toolCallId, args) => {
const { task, label, model, cleanup = "delete", timeoutSeconds } = args as SessionsSpawnArgs;
// Guard: subagents cannot spawn subagents
if (options.isSubagent) {
return {
content: [{ type: "text", text: "Error: sessions_spawn is not allowed from sub-agent sessions." }],
details: {
status: "error",
error: "sessions_spawn is not allowed from sub-agent sessions",
},
};
}
const requesterSessionId = options.sessionId ?? "unknown";
const runId = uuidv7();
const childSessionId = uuidv7();
// Build system prompt for the child
const systemPrompt = buildSubagentSystemPrompt({
requesterSessionId,
childSessionId,
label,
task,
});
// Spawn child agent via Hub
try {
const hub = getHub();
const childAgent = hub.createSubagent(childSessionId, {
systemPrompt,
model,
});
// Register the run for lifecycle tracking
registerSubagentRun({
runId,
childSessionId,
requesterSessionId,
task,
label,
cleanup,
timeoutSeconds,
});
// Write the task to the child (non-blocking)
childAgent.write(task);
return {
content: [
{
type: "text",
text: `Subagent spawned successfully.\n\nRun ID: ${runId}\nSession: ${childSessionId}\nTask: ${label || task.slice(0, 80)}\n\nThe subagent is now working in the background. You will receive its findings when it completes.`,
},
],
details: {
status: "accepted",
childSessionId,
runId,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Error spawning subagent: ${message}` }],
details: {
status: "error",
error: message,
},
};
}
},
};
}

View file

@ -66,6 +66,8 @@ export type AgentOptions = {
tools?: ToolsConfig | undefined;
/** Whether this is a subagent (applies restricted tool set) */
isSubagent?: boolean | undefined;
/** Parent session ID (for subagent lineage tracking) */
parentSessionId?: string | undefined;
};
export interface Message {