mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 21:03:56 +00:00
Add subagent orchestration example (#215)
This commit is contained in:
parent
774aaadbc0
commit
eb1d08a5fb
10 changed files with 1202 additions and 0 deletions
|
|
@ -17,6 +17,14 @@ Full-featured example demonstrating:
|
||||||
- Proper branching support via details storage
|
- Proper branching support via details storage
|
||||||
- State management without external files
|
- 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
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
245
packages/coding-agent/examples/custom-tools/subagent/README.md
Normal file
245
packages/coding-agent/examples/custom-tools/subagent/README.md
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}.
|
||||||
|
|
@ -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}.
|
||||||
|
|
@ -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.
|
||||||
771
packages/coding-agent/examples/custom-tools/subagent/subagent.ts
Normal file
771
packages/coding-agent/examples/custom-tools/subagent/subagent.ts
Normal 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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue