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