Add subagent orchestration example (#215)

This commit is contained in:
Nico Bailon 2025-12-18 16:45:18 -08:00 committed by GitHub
parent 774aaadbc0
commit eb1d08a5fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1202 additions and 0 deletions

View file

@ -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 |
<details>
<summary>Flow Diagrams</summary>
### 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) │
└─────────────────────────────────────────┘
```
</details>
## 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 <query>` | scout -> planner -> worker |
| `/scout-and-plan <query>` | scout -> planner |
| `/implement-and-review <query>` | worker -> reviewer -> worker |
## Limitations
- No timeout/cancellation (subprocess limitation)
- Output truncated to 500 lines / 50KB per agent
- Agents discovered fresh on each invocation

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string>; body: string } {
const frontmatter: Record<string, string> = {};
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<string, AgentConfig>();
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<TIn, TOut>(
items: TIn[],
concurrency: number,
fn: (item: TIn, index: number) => Promise<TOut>
): Promise<TOut[]> {
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<SingleResult> {
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<typeof SubagentParams, SubagentDetails> = {
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<string>();
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;