mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 14:01:06 +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
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