From eb1d08a5fb25384e6512b82154a00351850fc945 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Thu, 18 Dec 2025 16:45:18 -0800 Subject: [PATCH] Add subagent orchestration example (#215) --- .../examples/custom-tools/README.md | 8 + .../examples/custom-tools/subagent/README.md | 245 ++++++ .../custom-tools/subagent/agents/planner.md | 37 + .../custom-tools/subagent/agents/reviewer.md | 35 + .../custom-tools/subagent/agents/scout.md | 53 ++ .../custom-tools/subagent/agents/worker.md | 24 + .../subagent/commands/implement-and-review.md | 10 + .../subagent/commands/implement.md | 10 + .../subagent/commands/scout-and-plan.md | 9 + .../custom-tools/subagent/subagent.ts | 771 ++++++++++++++++++ 10 files changed, 1202 insertions(+) create mode 100644 packages/coding-agent/examples/custom-tools/subagent/README.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/agents/planner.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/agents/scout.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/agents/worker.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/commands/implement.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md create mode 100644 packages/coding-agent/examples/custom-tools/subagent/subagent.ts diff --git a/packages/coding-agent/examples/custom-tools/README.md b/packages/coding-agent/examples/custom-tools/README.md index 522f193e..ca4fb49f 100644 --- a/packages/coding-agent/examples/custom-tools/README.md +++ b/packages/coding-agent/examples/custom-tools/README.md @@ -17,6 +17,14 @@ Full-featured example demonstrating: - Proper branching support via details storage - State management without external files +### subagent/ +Delegate tasks to specialized subagents with isolated context windows. Includes: +- `subagent.ts` - The custom tool (single, parallel, and chain modes) +- `agents/` - Sample agent definitions (scout, planner, reviewer, worker) +- `commands/` - Workflow presets (/implement, /scout-and-plan, /implement-and-review) + +See [subagent/README.md](subagent/README.md) for full documentation. + ## Usage ```bash diff --git a/packages/coding-agent/examples/custom-tools/subagent/README.md b/packages/coding-agent/examples/custom-tools/subagent/README.md new file mode 100644 index 00000000..740aa752 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/README.md @@ -0,0 +1,245 @@ +# Subagent Example + +Delegate tasks to specialized subagents with isolated context windows. + +## Structure + +``` +subagent/ +├── README.md # This file +├── subagent.ts # The custom tool +├── agents/ # Sample agent definitions +│ ├── scout.md # Fast recon, returns compressed context +│ ├── planner.md # Creates implementation plans +│ ├── reviewer.md # Code review +│ └── worker.md # General-purpose (full capabilities) +└── commands/ # Workflow presets + ├── implement.md # scout -> planner -> worker + ├── scout-and-plan.md # scout -> planner (no implementation) + └── implement-and-review.md # worker -> reviewer -> worker +``` + +## Installation + +From the `examples/custom-tools/subagent/` directory: + +```bash +# Copy the tool +mkdir -p ~/.pi/agent/tools +cp subagent.ts ~/.pi/agent/tools/ + +# Copy agents +mkdir -p ~/.pi/agent/agents +cp agents/*.md ~/.pi/agent/agents/ + +# Copy workflow commands +mkdir -p ~/.pi/agent/commands +cp commands/*.md ~/.pi/agent/commands/ +``` + +## Security Model + +This example intentionally executes a separate `pi` subprocess with a delegated system prompt and tool/model configuration. + +Treat **project-local agent definitions as repo-controlled prompts**: +- A project can define agents in `.pi/agents/*.md`. +- Those prompts can instruct the model to read files, run bash commands, etc. (depending on the allowed tools). + +**Default behavior:** the tool only loads **user-level agents** from `~/.pi/agent/agents`. + +To enable project-local agents, pass `agentScope: "both"` (or `"project"`) explicitly. Only do this for repositories you trust. + +When running interactively, the tool will prompt for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable the prompt. + +## Usage + +### Single agent +``` +> Use the subagent tool with agent "scout" and task "find all authentication code" +``` + +### Parallel execution +``` +> Use subagent with tasks: +> - scout: "analyze the auth module" +> - scout: "analyze the api module" +> - scout: "analyze the database module" +``` + +### Chained workflow +``` +> Use subagent chain: +> 1. scout: "find code related to caching" +> 2. planner: "plan Redis integration using: {previous}" +> 3. worker: "implement: {previous}" +``` + +### Workflow commands +``` +/implement add Redis caching to the session store +/scout-and-plan refactor auth to support OAuth +/implement-and-review add input validation to API endpoints +``` + +## Tool Modes + +| Mode | Parameter | Description | +|------|-----------|-------------| +| Single | `{ agent, task }` | One agent, one task | +| Parallel | `{ tasks: [...] }` | Multiple agents run concurrently | +| Chain | `{ chain: [...] }` | Sequential with `{previous}` placeholder | + +
+Flow Diagrams + +### Single Mode + +``` +┌─────────────────┐ +│ Main Agent │ +└────────┬────────┘ + │ "use scout to find auth code" + ▼ +┌─────────────────┐ +│ subagent tool │ +└────────┬────────┘ + │ pi -p --model haiku ... + ▼ +┌─────────────────┐ +│ Scout │ +│ (subprocess) │ +└────────┬────────┘ + │ stdout + ▼ +┌─────────────────┐ +│ Tool Result │ +└─────────────────┘ +``` + +### Parallel Mode + +``` +┌──────────────────────┐ +│ Main Agent │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ subagent tool │ +│ Promise.all() │ +└──────────┬───────────┘ + │ + ┌─────┼─────┐ + ▼ ▼ ▼ +┌──────┐┌──────┐┌──────┐ +│Scout ││Scout ││Scout │ +│ auth ││ api ││ db │ +└──┬───┘└──┬───┘└──┬───┘ + │ │ │ + └───────┼───────┘ + ▼ +┌──────────────────────┐ +│ Combined Result │ +└──────────────────────┘ +``` + +### Chain Mode + +``` +┌─────────────────┐ +│ Main Agent │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ subagent tool │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Step 1: Scout │ +└────────┬────────┘ + │ {previous} = scout output + ▼ +┌─────────────────┐ +│ Step 2: Planner │ +└────────┬────────┘ + │ {previous} = planner output + ▼ +┌─────────────────┐ +│ Step 3: Worker │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Chain Result │ +└─────────────────┘ +``` + +### Workflow Command Expansion + +``` +/implement add Redis + │ + ▼ +┌─────────────────────────────────────────┐ +│ Expands to chain: │ +│ 1. scout: "find code for add Redis" │ +│ 2. planner: "plan using {previous}" │ +│ 3. worker: "implement {previous}" │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Chain Execution │ +│ │ +│ scout ──► planner ──► worker │ +│ (haiku) (sonnet) (sonnet) │ +└─────────────────────────────────────────┘ +``` + +
+ +## Agent Definitions + +Agents are markdown files with YAML frontmatter: + +```markdown +--- +name: my-agent +description: What this agent does +tools: read, grep, find, ls +model: claude-haiku-4-5 +--- + +System prompt for the agent goes here. +``` + +**Locations:** +- `~/.pi/agent/agents/*.md` - User-level (global) +- `.pi/agents/*.md` - Project-level (only loaded if `agentScope` includes `"project"`) + +## Sample Agents + +| Agent | Purpose | Model | +|-------|---------|-------| +| `scout` | Fast codebase recon, returns compressed context | Haiku | +| `planner` | Creates implementation plans from context | Sonnet | +| `reviewer` | Code review for quality/security | Sonnet | +| `worker` | General-purpose with full capabilities | Sonnet | + +## Workflow Commands + +Commands are prompt templates that invoke the subagent tool: + +| Command | Flow | +|---------|------| +| `/implement ` | scout -> planner -> worker | +| `/scout-and-plan ` | scout -> planner | +| `/implement-and-review ` | worker -> reviewer -> worker | + +## Limitations + +- No timeout/cancellation (subprocess limitation) +- Output truncated to 500 lines / 50KB per agent +- Agents discovered fresh on each invocation diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/planner.md b/packages/coding-agent/examples/custom-tools/subagent/agents/planner.md new file mode 100644 index 00000000..7acc7187 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/agents/planner.md @@ -0,0 +1,37 @@ +--- +name: planner +description: Creates implementation plans from context and requirements +tools: read, grep, find, ls +model: claude-sonnet-4-5 +--- + +You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan. + +You must NOT make any changes. Only read, analyze, and plan. + +Input format you'll receive: +- Context/findings from a scout agent +- Original query or requirements + +Output format: + +## Goal +One sentence summary of what needs to be done. + +## Plan +Numbered steps, each small and actionable: +1. Step one - specific file/function to modify +2. Step two - what to add/change +3. ... + +## Files to Modify +- `path/to/file.ts` - what changes +- `path/to/other.ts` - what changes + +## New Files (if any) +- `path/to/new.ts` - purpose + +## Risks +Anything to watch out for. + +Keep the plan concrete. The worker agent will execute it verbatim. diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md b/packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md new file mode 100644 index 00000000..a6706993 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md @@ -0,0 +1,35 @@ +--- +name: reviewer +description: Code review specialist for quality and security analysis +tools: read, grep, find, ls, bash +model: claude-sonnet-4-5 +--- + +You are a senior code reviewer. Analyze code for quality, security, and maintainability. + +Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds. +Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only. + +Strategy: +1. Run `git diff` to see recent changes (if applicable) +2. Read the modified files +3. Check for bugs, security issues, code smells + +Output format: + +## Files Reviewed +- `path/to/file.ts` (lines X-Y) + +## Critical (must fix) +- `file.ts:42` - Issue description + +## Warnings (should fix) +- `file.ts:100` - Issue description + +## Suggestions (consider) +- `file.ts:150` - Improvement idea + +## Summary +Overall assessment in 2-3 sentences. + +Be specific with file paths and line numbers. diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/scout.md b/packages/coding-agent/examples/custom-tools/subagent/agents/scout.md new file mode 100644 index 00000000..a67544c1 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/agents/scout.md @@ -0,0 +1,53 @@ +--- +name: scout +description: Fast codebase recon that returns compressed context for handoff to other agents +tools: read, grep, find, ls, bash +model: claude-haiku-4-5 +--- + +You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything. + +Your output will be passed to an agent who has NOT seen the files you explored. + +Thoroughness (infer from task, default medium): +- Quick: Targeted lookups, key files only +- Medium: Follow imports, read critical sections +- Thorough: Trace all dependencies, check tests/types + +Strategy: +1. grep/find to locate relevant code +2. Read key sections (not entire files) +3. Identify types, interfaces, key functions +4. Note dependencies between files + +Output format: + +## Query +One line summary of what was searched. + +## Files Retrieved +List with exact line ranges: +1. `path/to/file.ts` (lines 10-50) - Description of what's here +2. `path/to/other.ts` (lines 100-150) - Description +3. ... + +## Key Code +Critical types, interfaces, or functions: + +```typescript +interface Example { + // actual code from the files +} +``` + +```typescript +function keyFunction() { + // actual implementation +} +``` + +## Architecture +Brief explanation of how the pieces connect. + +## Start Here +Which file to look at first and why. diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/worker.md b/packages/coding-agent/examples/custom-tools/subagent/agents/worker.md new file mode 100644 index 00000000..d9688355 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/agents/worker.md @@ -0,0 +1,24 @@ +--- +name: worker +description: General-purpose subagent with full capabilities, isolated context +model: claude-sonnet-4-5 +--- + +You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation. + +Work autonomously to complete the assigned task. Use all available tools as needed. + +Output format when finished: + +## Completed +What was done. + +## Files Changed +- `path/to/file.ts` - what changed + +## Notes (if any) +Anything the main agent should know. + +If handing off to another agent (e.g. reviewer), include: +- Exact file paths changed +- Key functions/types touched (short list) diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md b/packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md new file mode 100644 index 00000000..6493b3d6 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md @@ -0,0 +1,10 @@ +--- +description: Worker implements, reviewer reviews, worker applies feedback +--- +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "worker" agent to implement: $@ +2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder) +3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/implement.md b/packages/coding-agent/examples/custom-tools/subagent/commands/implement.md new file mode 100644 index 00000000..559da4d6 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/commands/implement.md @@ -0,0 +1,10 @@ +--- +description: Full implementation workflow - scout gathers context, planner creates plan, worker implements +--- +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "scout" agent to find all code relevant to: $@ +2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder) +3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md b/packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md new file mode 100644 index 00000000..093b6339 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md @@ -0,0 +1,9 @@ +--- +description: Scout gathers context, planner creates implementation plan (no implementation) +--- +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "scout" agent to find all code relevant to: $@ +2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan. diff --git a/packages/coding-agent/examples/custom-tools/subagent/subagent.ts b/packages/coding-agent/examples/custom-tools/subagent/subagent.ts new file mode 100644 index 00000000..cd29e798 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/subagent/subagent.ts @@ -0,0 +1,771 @@ +/** + * Subagent Tool - Delegate tasks to specialized agents + * + * Discovers agent definitions from: + * - ~/.pi/agent/agents/*.md (user-level) + * - .pi/agents/*.md (project-level, opt-in via agentScope) + * + * Agent files use markdown with YAML frontmatter: + * + * --- + * name: scout + * description: Fast codebase recon + * tools: read, grep, find, ls, bash + * model: claude-haiku-4-5 + * --- + * + * You are a scout. Quickly investigate and return findings. + * + * The tool spawns a separate `pi` process for each subagent invocation, + * giving it an isolated context window. Project agents can be enabled explicitly, + * and will override user agents with the same name when agentScope="both". + * + * Supports three modes: + * - Single: { agent: "name", task: "..." } + * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] } + * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] } + * + * Chain mode runs steps sequentially. Use {previous} in task to reference + * the previous step's output. + * + * Limitations: + * - No timeout/cancellation (pi.exec limitation) + * - Output is truncated for UI/context size (pi.exec still buffers full output today) + * - Agents reloaded on each invocation (edit agents mid-session) + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Text } from "@mariozechner/pi-tui"; +import type { CustomAgentTool, CustomToolFactory, ToolAPI } from "@mariozechner/pi-coding-agent"; + +const MAX_OUTPUT_LINES = 500; +const MAX_OUTPUT_BYTES = 50_000; +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; +const MAX_AGENTS_IN_DESCRIPTION = 10; + +type AgentScope = "user" | "project" | "both"; + +interface AgentConfig { + name: string; + description: string; + tools?: string[]; + model?: string; + systemPrompt: string; + source: "user" | "project"; + filePath: string; +} + +interface SingleResult { + agent: string; + agentSource: "user" | "project" | "unknown"; + task: string; + exitCode: number; + stdout: string; + stderr: string; + truncated: boolean; + step?: number; +} + +interface SubagentDetails { + mode: "single" | "parallel" | "chain"; + agentScope: AgentScope; + projectAgentsDir: string | null; + results: SingleResult[]; +} + +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + const normalized = content.replace(/\r\n/g, "\n"); + + if (!normalized.startsWith("---")) { + return { frontmatter, body: normalized }; + } + + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { frontmatter, body: normalized }; + } + + const frontmatterBlock = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + + for (const line of frontmatterBlock.split("\n")) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (match) { + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + frontmatter[match[1]] = value; + } + } + + return { frontmatter, body }; +} + +function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] { + const agents: AgentConfig[] = []; + + if (!fs.existsSync(dir)) { + return agents; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return agents; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + + const filePath = path.join(dir, entry.name); + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const { frontmatter, body } = parseFrontmatter(content); + + if (!frontmatter.name || !frontmatter.description) { + continue; + } + + const tools = frontmatter.tools + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean); + + agents.push({ + name: frontmatter.name, + description: frontmatter.description, + tools: tools && tools.length > 0 ? tools : undefined, + model: frontmatter.model, + systemPrompt: body, + source, + filePath, + }); + } + + return agents; +} + +function isDirectory(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +function findNearestProjectAgentsDir(cwd: string): string | null { + let currentDir = cwd; + while (true) { + const candidate = path.join(currentDir, ".pi", "agents"); + if (isDirectory(candidate)) return candidate; + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +function discoverAgents(cwd: string, scope: AgentScope): { agents: AgentConfig[]; projectAgentsDir: string | null } { + const userDir = path.join(os.homedir(), ".pi", "agent", "agents"); + const projectAgentsDir = findNearestProjectAgentsDir(cwd); + + const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user"); + const projectAgents = + scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project"); + + const agentMap = new Map(); + + if (scope === "both") { + // Explicit opt-in: project agents override user agents with the same name. + for (const agent of userAgents) agentMap.set(agent.name, agent); + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } else if (scope === "user") { + for (const agent of userAgents) agentMap.set(agent.name, agent); + } else { + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } + + return { agents: Array.from(agentMap.values()), projectAgentsDir }; +} + +function truncateOutput(output: string): { text: string; truncated: boolean } { + let truncated = false; + let byteBudget = MAX_OUTPUT_BYTES; + let lineBudget = MAX_OUTPUT_LINES; + + // Note: This truncation is for UI/context size. The underlying pi.exec() currently buffers + // full stdout/stderr in memory before we see it here. + + let i = 0; + let lastNewlineIndex = -1; + while (i < output.length && byteBudget > 0) { + const ch = output.charCodeAt(i); + + // Approximate bytes by UTF-16 code units; MAX_OUTPUT_BYTES is a practical guardrail, not exact bytes. + byteBudget--; + + if (ch === 10 /* \n */) { + lineBudget--; + lastNewlineIndex = i; + if (lineBudget <= 0) { + truncated = true; + break; + } + } + + i++; + } + + if (i < output.length) { + truncated = true; + } + + // Prefer cutting at a newline boundary if we hit the line cap, to keep previews readable. + if (truncated && lineBudget <= 0 && lastNewlineIndex >= 0) { + output = output.slice(0, lastNewlineIndex); + } else { + output = output.slice(0, i); + } + + return { text: output, truncated }; +} + +function previewFirstLines(text: string, maxLines: number): string { + if (maxLines <= 0) return ""; + let linesRemaining = maxLines; + let i = 0; + while (i < text.length) { + const nextNewline = text.indexOf("\n", i); + if (nextNewline === -1) return text; + linesRemaining--; + if (linesRemaining <= 0) return text.slice(0, nextNewline); + i = nextNewline + 1; + } + return text; +} + +function firstLine(text: string): string { + const idx = text.indexOf("\n"); + return idx === -1 ? text : text.slice(0, idx); +} + +function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } { + if (agents.length === 0) return { text: "none", remaining: 0 }; + const listed = agents.slice(0, maxItems); + const remaining = agents.length - listed.length; + return { + text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "), + remaining, + }; +} + +async function mapWithConcurrencyLimit( + items: TIn[], + concurrency: number, + fn: (item: TIn, index: number) => Promise +): Promise { + if (items.length === 0) return []; + const limit = Math.max(1, Math.min(concurrency, items.length)); + const results: TOut[] = new Array(items.length); + + let nextIndex = 0; + const workers = new Array(limit).fill(null).map(async () => { + while (true) { + const current = nextIndex++; + if (current >= items.length) return; + results[current] = await fn(items[current], current); + } + }); + + await Promise.all(workers); + return results; +} + +function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-")); + const safeName = agentName.replace(/[^\w.-]+/g, "_"); + const filePath = path.join(tmpDir, `prompt-${safeName}.md`); + fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 }); + return { dir: tmpDir, filePath }; +} + +async function runSingleAgent( + pi: ToolAPI, + agents: AgentConfig[], + agentName: string, + task: string, + step?: number +): Promise { + const agent = agents.find((a) => a.name === agentName); + + if (!agent) { + return { + agent: agentName, + agentSource: "unknown", + task, + exitCode: 1, + stdout: "", + stderr: `Unknown agent: ${agentName}`, + truncated: false, + step, + }; + } + + const args: string[] = ["-p", "--no-session"]; + + if (agent.model) { + args.push("--model", agent.model); + } + + if (agent.tools && agent.tools.length > 0) { + args.push("--tools", agent.tools.join(",")); + } + + let tmpPromptDir: string | null = null; + let tmpPromptPath: string | null = null; + try { + if (agent.systemPrompt.trim()) { + // IMPORTANT: Never pass raw prompt text to --append-system-prompt. + // pi treats this flag as "path or literal", and will read the file contents if the string + // happens to match an existing path. Writing to a temp file prevents unintended file exfiltration. + const tmp = writePromptToTempFile(agent.name, agent.systemPrompt); + tmpPromptDir = tmp.dir; + tmpPromptPath = tmp.filePath; + args.push("--append-system-prompt", tmpPromptPath); + } + + // Prefixing prevents accidental CLI flag parsing if the task starts with '-'. + args.push(`Task: ${task}`); + + const result = await pi.exec("pi", args); + + const stdoutResult = truncateOutput(result.stdout); + const stderrResult = truncateOutput(result.stderr); + + return { + agent: agentName, + agentSource: agent.source, + task, + exitCode: result.code, + stdout: stdoutResult.text, + stderr: stderrResult.text, + truncated: stdoutResult.truncated || stderrResult.truncated, + step, + }; + } finally { + if (tmpPromptPath) { + try { + fs.unlinkSync(tmpPromptPath); + } catch { + // ignore + } + } + if (tmpPromptDir) { + try { + fs.rmdirSync(tmpPromptDir); + } catch { + // ignore + } + } + } +} + +const TaskItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task to delegate to the agent" }), +}); + +const ChainItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }), +}); + +const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, { + description: + 'Which agent directories are eligible. Default: "user". Use "both" to enable project-local agents from .pi/agents.', + default: "user", +}); + +const SubagentParams = Type.Object({ + agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })), + task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })), + tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })), + chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution. Use {previous} in task to reference prior output" })), + agentScope: Type.Optional(AgentScopeSchema), + confirmProjectAgents: Type.Optional( + Type.Boolean({ + description: + "Interactive-only safety prompt when running project-local agents (.pi/agents). Ignored in headless modes. Default: true.", + default: true, + }), + ), +}); + +const factory: CustomToolFactory = (pi) => { + const tool: CustomAgentTool = { + name: "subagent", + label: "Subagent", + get description() { + const user = discoverAgents(pi.cwd, "user"); + const project = discoverAgents(pi.cwd, "project"); + + const userList = formatAgentList(user.agents, MAX_AGENTS_IN_DESCRIPTION); + const projectList = formatAgentList(project.agents, MAX_AGENTS_IN_DESCRIPTION); + + const userSuffix = userList.remaining > 0 ? `; ... and ${userList.remaining} more` : ""; + const projectSuffix = projectList.remaining > 0 ? `; ... and ${projectList.remaining} more` : ""; + + const projectDirNote = project.projectAgentsDir ? ` (from ${project.projectAgentsDir})` : ""; + + return [ + "Delegate tasks to specialized subagents with isolated context.", + "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", + 'Default agent scope is "user" (from ~/.pi/agent/agents).', + 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', + `User agents: ${userList.text}${userSuffix}.`, + `Project agents${projectDirNote}: ${projectList.text}${projectSuffix}.`, + ].join(" "); + }, + parameters: SubagentParams, + + async execute(_toolCallId, params) { + const agentScope: AgentScope = params.agentScope ?? "user"; + const discovery = discoverAgents(pi.cwd, agentScope); + const agents = discovery.agents; + const confirmProjectAgents = params.confirmProjectAgents ?? true; + + const hasChain = (params.chain?.length ?? 0) > 0; + const hasTasks = (params.tasks?.length ?? 0) > 0; + const hasSingle = Boolean(params.agent && params.task); + const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle); + + if (modeCount !== 1) { + const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; + return { + content: [ + { + type: "text", + text: + "Invalid parameters. Provide exactly one mode:\n" + + "- { agent, task } for single\n" + + "- { tasks: [...] } for parallel\n" + + "- { chain: [...] } for sequential\n\n" + + `agentScope: ${agentScope}\n` + + `Available agents: ${available}`, + }, + ], + details: { mode: "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] }, + }; + } + + if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && pi.hasUI) { + const requestedAgentNames = new Set(); + if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); + if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); + if (params.agent) requestedAgentNames.add(params.agent); + + const projectAgentsRequested = Array.from(requestedAgentNames) + .map((name) => agents.find((a) => a.name === name)) + .filter((a): a is AgentConfig => a?.source === "project"); + + if (projectAgentsRequested.length > 0) { + const names = projectAgentsRequested.map((a) => a.name).join(", "); + const dir = discovery.projectAgentsDir ?? "(unknown .pi/agents)"; + const ok = await pi.ui.confirm( + "Run project-local agents?", + `About to run project agent(s): ${names}\n\nSource directory:\n${dir}\n\nProject agents are repo-controlled prompts. Only continue for repositories you trust.\n\nContinue?`, + ); + if (!ok) { + return { + content: [{ type: "text", text: "Canceled: project-local agents not approved." }], + details: { mode: hasChain ? "chain" : hasTasks ? "parallel" : "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] }, + }; + } + } + } + + if (params.chain && params.chain.length > 0) { + const results: SingleResult[] = []; + let previousOutput = ""; + + for (let i = 0; i < params.chain.length; i++) { + const step = params.chain[i]; + const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput); + + const result = await runSingleAgent(pi, agents, step.agent, taskWithContext, i + 1); + results.push(result); + + if (result.exitCode !== 0) { + const output = result.stdout.trim() || result.stderr.trim() || "(no output)"; + const preview = previewFirstLines(output, 15); + const summaries = results.map((r) => { + const status = r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`; + return `Step ${r.step}: [${r.agent}] ${status}`; + }); + return { + content: [ + { + type: "text", + text: + `Chain stopped at step ${i + 1} (${step.agent} failed)\n\n` + + `${summaries.join("\n")}\n\n` + + `Failed step output (preview):\n${preview}`, + }, + ], + details: { mode: "chain", agentScope, projectAgentsDir: discovery.projectAgentsDir, results }, + }; + } + + previousOutput = result.stdout.trim() || result.stderr.trim(); + } + + const finalResult = results[results.length - 1]; + const output = finalResult.stdout.trim() || finalResult.stderr.trim() || "(no output)"; + const summaries = results.map((r) => `Step ${r.step}: [${r.agent}] completed`); + + return { + content: [{ type: "text", text: `Chain completed (${results.length} steps)\n\n${summaries.join("\n")}\n\nFinal output:\n${output}` }], + details: { mode: "chain", agentScope, projectAgentsDir: discovery.projectAgentsDir, results }, + }; + } + + if (params.tasks && params.tasks.length > 0) { + if (params.tasks.length > MAX_PARALLEL_TASKS) { + return { + content: [ + { + type: "text", + text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}. Split into multiple calls or use chain mode.`, + }, + ], + details: { mode: "parallel", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] }, + }; + } + + const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, (t) => + runSingleAgent(pi, agents, t.agent, t.task) + ); + + const successCount = results.filter((r) => r.exitCode === 0).length; + const summaries = results.map((r) => { + const status = r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`; + const output = r.stdout.trim() || r.stderr.trim() || "(no output)"; + const preview = previewFirstLines(output, 5); + return `[${r.agent}] ${status}\n${preview}`; + }); + + return { + content: [{ type: "text", text: `Parallel execution: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}` }], + details: { mode: "parallel", agentScope, projectAgentsDir: discovery.projectAgentsDir, results }, + }; + } + + if (params.agent && params.task) { + const result = await runSingleAgent(pi, agents, params.agent, params.task); + + const success = result.exitCode === 0; + const output = result.stdout.trim() || result.stderr.trim() || "(no output)"; + const truncatedNote = result.truncated ? " [output truncated]" : ""; + + return { + content: [{ type: "text", text: success ? output + truncatedNote : `Agent failed (exit ${result.exitCode}): ${output}${truncatedNote}` }], + details: { mode: "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [result] }, + }; + } + + const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; + return { + content: [{ type: "text", text: `Invalid parameters. Use: {agent, task} for single, {tasks: [...]} for parallel, or {chain: [...]} for sequential. Available agents: ${available}` }], + details: { mode: "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] }, + }; + }, + + renderCall(args, theme) { + const agents = discoverAgents(pi.cwd, "both").agents; + const scope: AgentScope = args.agentScope ?? "user"; + + if (args.chain && args.chain.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `chain (${args.chain.length} steps)`) + + theme.fg("muted", ` [scope: ${scope}]`); + for (let i = 0; i < Math.min(args.chain.length, 5); i++) { + const step = args.chain[i]; + const agent = agents.find((a) => a.name === step.agent); + const sourceTag = agent ? theme.fg("muted", ` (${agent.source})`) : ""; + const taskPreview = step.task.length > 35 ? step.task.slice(0, 35) + "..." : step.task; + text += "\n" + theme.fg("dim", ` ${i + 1}. ${step.agent}${sourceTag}: ${taskPreview}`); + } + if (args.chain.length > 5) { + text += "\n" + theme.fg("muted", ` ... and ${args.chain.length - 5} more steps`); + } + return new Text(text, 0, 0); + } + + if (args.tasks && args.tasks.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `parallel (${args.tasks.length} tasks)`) + + theme.fg("muted", ` [scope: ${scope}]`); + for (const t of args.tasks.slice(0, 5)) { + const agent = agents.find((a) => a.name === t.agent); + const sourceTag = agent ? theme.fg("muted", ` (${agent.source})`) : ""; + const taskPreview = t.task.length > 40 ? t.task.slice(0, 40) + "..." : t.task; + text += "\n" + theme.fg("dim", ` ${t.agent}${sourceTag}: ${taskPreview}`); + } + if (args.tasks.length > 5) { + text += "\n" + theme.fg("muted", ` ... and ${args.tasks.length - 5} more`); + } + return new Text(text, 0, 0); + } + + if (args.agent && args.task) { + const agent = agents.find((a) => a.name === args.agent); + const sourceTag = agent ? theme.fg("muted", ` (${agent.source})`) : ""; + const agentLabel = agent ? theme.fg("accent", args.agent) + sourceTag : theme.fg("error", args.agent); + + let text = theme.fg("toolTitle", theme.bold("subagent ")) + agentLabel + theme.fg("muted", ` [scope: ${scope}]`); + const taskPreview = args.task.length > 60 ? args.task.slice(0, 60) + "..." : args.task; + text += "\n" + theme.fg("dim", ` ${taskPreview}`); + return new Text(text, 0, 0); + } + + return new Text(theme.fg("error", "subagent: invalid parameters"), 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const { details } = result; + if (!details || details.results.length === 0) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (details.mode === "chain") { + const successCount = details.results.filter((r) => r.exitCode === 0).length; + const totalCount = details.results.length; + const allSuccess = successCount === totalCount; + const icon = allSuccess ? theme.fg("success", "✓") : theme.fg("error", "✗"); + + let text = + icon + + " " + + theme.fg("accent", `chain ${successCount}/${totalCount}`) + + theme.fg("muted", " steps completed") + + theme.fg("muted", ` (scope: ${details.agentScope})`); + + if (expanded) { + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const truncTag = r.truncated ? theme.fg("warning", " [truncated]") : ""; + const sourceTag = theme.fg("muted", ` (${r.agentSource})`); + text += "\n\n" + theme.fg("muted", `Step ${r.step}: `) + rIcon + " " + theme.fg("accent", r.agent) + sourceTag + truncTag; + const output = r.stdout.trim() || r.stderr.trim(); + if (output) { + text += "\n" + theme.fg("dim", output); + } + } + } else { + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const output = r.stdout.trim() || r.stderr.trim(); + const preview = (firstLine(output) || "(no output)").slice(0, 50); + text += + "\n" + + theme.fg("muted", `${r.step}. `) + + rIcon + + " " + + theme.fg("accent", r.agent) + + theme.fg("muted", ` (${r.agentSource})`) + + ": " + + theme.fg("dim", preview); + } + if (details.results.some((r) => (r.stdout + r.stderr).includes("\n"))) { + text += "\n" + theme.fg("muted", " (Ctrl+O to expand)"); + } + } + + return new Text(text, 0, 0); + } + + if (details.mode === "parallel") { + const successCount = details.results.filter((r) => r.exitCode === 0).length; + const totalCount = details.results.length; + const allSuccess = successCount === totalCount; + const icon = allSuccess ? theme.fg("success", "✓") : theme.fg("warning", "◐"); + + let text = + icon + + " " + + theme.fg("accent", `${successCount}/${totalCount}`) + + theme.fg("muted", " tasks completed") + + theme.fg("muted", ` (scope: ${details.agentScope})`); + + if (expanded) { + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const truncTag = r.truncated ? theme.fg("warning", " [truncated]") : ""; + text += "\n\n" + rIcon + " " + theme.fg("accent", r.agent) + theme.fg("muted", ` (${r.agentSource})`) + truncTag; + const output = r.stdout.trim() || r.stderr.trim(); + if (output) { + text += "\n" + theme.fg("dim", output); + } + } + } else { + for (const r of details.results.slice(0, 3)) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const output = r.stdout.trim() || r.stderr.trim(); + const preview = (firstLine(output) || "(no output)").slice(0, 60); + text += "\n" + rIcon + " " + theme.fg("accent", r.agent) + ": " + theme.fg("dim", preview); + } + if (details.results.length > 3) { + text += "\n" + theme.fg("muted", ` ... ${details.results.length - 3} more (Ctrl+O to expand)`); + } + } + + return new Text(text, 0, 0); + } + + const r = details.results[0]; + const success = r.exitCode === 0; + const icon = success ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const status = success ? "completed" : `failed (exit ${r.exitCode})`; + const sourceTag = theme.fg("muted", ` (${r.agentSource})`); + const truncatedTag = r.truncated ? theme.fg("warning", " [truncated]") : ""; + + let text = icon + " " + theme.fg("accent", r.agent) + sourceTag + " " + theme.fg("muted", status) + truncatedTag; + + const output = r.stdout.trim() || r.stderr.trim(); + if (output) { + if (expanded) { + text += "\n" + theme.fg("dim", output); + } else { + const preview = previewFirstLines(output, 3); + const hasMore = preview.length < output.length; + text += "\n" + theme.fg("dim", preview); + if (hasMore) { + text += "\n" + theme.fg("muted", " ... (Ctrl+O to expand)"); + } + } + } + + return new Text(text, 0, 0); + }, + }; + + return tool; +}; + +export default factory;