From 83b557a6fc1b999f82e4c5f2aa20a3e644ef3830 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 16:49:27 +0800 Subject: [PATCH] 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 --- src/agent/tools.ts | 16 ++- src/agent/tools/groups.ts | 15 +-- src/agent/tools/sessions-spawn.test.ts | 40 +++++++ src/agent/tools/sessions-spawn.ts | 142 +++++++++++++++++++++++++ src/agent/types.ts | 2 + 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 src/agent/tools/sessions-spawn.test.ts create mode 100644 src/agent/tools/sessions-spawn.ts diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 8190cb86..56c9b766 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -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( export function createAllTools(options: CreateToolsOptions | string): AgentTool[] { // 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); + return tools; } @@ -138,6 +150,8 @@ export function resolveTools(options: AgentOptions): AgentTool[] { cwd, profileId: options.profileId, profileBaseDir: options.profileBaseDir, + isSubagent: options.isSubagent, + sessionId: options.sessionId, }); // Apply policy filtering diff --git a/src/agent/tools/groups.ts b/src/agent/tools/groups.ts index a886b22b..1e9edf6c 100644 --- a/src/agent/tools/groups.ts +++ b/src/agent/tools/groups.ts @@ -35,6 +35,9 @@ export const TOOL_GROUPS: Record = { // 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 { + 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"); + }); +}); diff --git a/src/agent/tools/sessions-spawn.ts b/src/agent/tools/sessions-spawn.ts new file mode 100644 index 00000000..a8ca4f96 --- /dev/null +++ b/src/agent/tools/sessions-spawn.ts @@ -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 { + 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, + }, + }; + } + }, + }; +} diff --git a/src/agent/types.ts b/src/agent/types.ts index 75e53ad1..06ffb217 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -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 {