refactor(subagent): split responsibilities and isolate child prompt

- Extract validateAgent, toToolResult, runAgentWithRetries so index.ts
  is wiring only; orchestration, validation, and result shaping each own
  their concern.
- Separate runner internals: createRunState, handleEvent (pure event
  reducer), spawnPi (process lifecycle), runOnce (single attempt).
- Track attempt/maxAttempts on SubagentStatus; surface "try x/y" in the
  UI without overwriting the user-facing task with the retry preamble.
- Replace pi's default system prompt (--system-prompt) instead of
  appending, so the subagent .md body is authoritative.
- Document prompt-replacement and retry/cache semantics in AGENTS.md.
This commit is contained in:
2026-05-12 15:02:49 -04:00
parent 4f80b590da
commit 5c5cdb3aec
7 changed files with 423 additions and 311 deletions

View File

@@ -13,6 +13,8 @@ This repo implements the `subagent` pi extension in `index.ts`.
`~/.pi/subagent-sessions/<cwd-hash>/<agent>_<sessionId>.jsonl`. `~/.pi/subagent-sessions/<cwd-hash>/<agent>_<sessionId>.jsonl`.
- Omitting `sessionId` creates a new UUID-backed session. - Omitting `sessionId` creates a new UUID-backed session.
- Passing `sessionId` resumes the same agent/cwd child session. - Passing `sessionId` resumes the same agent/cwd child session.
- A prompt's `.md` body **replaces** pi's default system prompt (passed via `--system-prompt`). It is the entire system prompt; do not write it as an appendage. Pi still tacks on AGENTS.md/skills/date/cwd.
- Retries reuse the same session and append a new user message nudging the child to finalize. The system prompt is stable across attempts (cache-friendly).
## Validation ## Validation

186
index.ts
View File

@@ -1,18 +1,17 @@
// Subagent Extension - Registers a tool for delegating work to prompt-defined // Subagent Extension - Registers a tool for delegating work to prompt-defined
// subagents with constrained tool permissions. // subagents with constrained tool permissions.
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox"; import { Type } from "typebox";
import { FINALIZE_TOOL_NAME, MAX_FINALIZE_RETRIES } from "./src/constants.ts"; import { FINALIZE_TOOL_NAME } from "./src/constants.ts";
import { formatPromptList, formatSubagentContent } from "./src/format.ts"; import { formatPromptList, toToolResult } from "./src/format.ts";
import { discoverPrompts } from "./src/prompts.ts"; import { discoverPrompts } from "./src/prompts.ts";
import { renderSubagentCall, renderSubagentResult } from "./src/render.ts"; import { renderSubagentCall, renderSubagentResult } from "./src/render.ts";
import { runAgent } from "./src/runner.ts"; import { runAgentWithRetries } from "./src/runner.ts";
import { getSubagentSessionPath } from "./src/session.ts"; import { SubagentParams } from "./src/tools.ts";
import { FinalizeStatus, type SubagentResult } from "./src/types.ts"; import { FinalizeStatus } from "./src/types.ts";
import { resolveTools, SubagentParams } from "./src/tools.ts"; import { validateAgent } from "./src/validate.ts";
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
if (process.env.PI_SUBAGENT_CHILD === "1") { if (process.env.PI_SUBAGENT_CHILD === "1") {
@@ -49,164 +48,33 @@ export default function (pi: ExtensionAPI) {
"Delegate tasks to subagents by name. Subagent prompts live in prompts/*.md and define approved_tools/denied_tools.", "Delegate tasks to subagents by name. Subagent prompts live in prompts/*.md and define approved_tools/denied_tools.",
parameters: SubagentParams, parameters: SubagentParams,
async execute(_toolCallId, params, signal, _onUpdate, ctx) { async execute(_toolCallId, params, signal, onUpdate, ctx) {
const prompts = discoverPrompts(); // Validate Agent
const agent = prompts.find((prompt) => prompt.name === params.agent); const validation = validateAgent(
if (!agent) { params.agent,
discoverPrompts(),
pi.getActiveTools(),
);
if (!validation.ok) {
return { return {
content: [ content: [{ type: "text", text: validation.text }],
{ details: validation.details,
type: "text",
text: `Unknown subagent: ${params.agent}. Available: ${formatPromptList(prompts)}`,
},
],
details: { available: prompts.map((prompt) => prompt.name) },
isError: true, isError: true,
}; };
} }
if (agent.approvedTools.length > 0 && agent.deniedTools.length > 0) { // Run Subagent
return { const result = await runAgentWithRetries({
content: [ cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
{ agent: validation.agent,
type: "text", task: params.task,
text: tools: validation.tools,
`Invalid subagent config for ${agent.name}: define either approved_tools/allowed_tools ` + sessionId: params.sessionId ?? randomUUID(),
"or denied_tools, not both.", signal,
}, onUpdate,
], });
details: { agent: agent.name, filePath: agent.filePath },
isError: true,
};
}
const activeTools = pi.getActiveTools(); return toToolResult(result);
const tools = resolveTools(agent, activeTools);
if (tools.length === 0) {
return {
content: [
{
type: "text",
text: `Subagent ${agent.name} has no approved tools after applying denied_tools.`,
},
],
details: {
agent: agent.name,
approvedTools: agent.approvedTools,
deniedTools: agent.deniedTools,
},
isError: true,
};
}
const cwd = path.resolve(ctx.cwd, params.cwd ?? ".");
const sessionId = params.sessionId ?? randomUUID();
const sessionPath = getSubagentSessionPath(cwd, agent.name, sessionId);
await fs.promises.mkdir(path.dirname(sessionPath), { recursive: true });
let result: SubagentResult | null = null;
for (
let retryCount = 0;
retryCount <= MAX_FINALIZE_RETRIES;
retryCount += 1
) {
const task =
retryCount === 0
? `Task: ${params.task}`
: [
"Your previous response did not finalize correctly.",
`If you are finished, call ${FINALIZE_TOOL_NAME}.`,
"If you are not finished, continue the original task using available tools as needed.",
`Original task: ${params.task}`,
].join("\n\n");
result = await runAgent(
cwd,
agent,
task,
tools,
sessionId,
sessionPath,
signal,
_onUpdate,
);
if (result.finalized) break;
if (result.exitCode !== 0 || result.error) break;
}
if (!result) {
return {
content: [
{
type: "text",
text: formatSubagentContent(
FinalizeStatus.ERROR,
sessionId,
undefined,
"Subagent did not run.",
),
},
],
details: { sessionId },
isError: true,
};
}
if (!result.finalized) {
const fallback =
result.error ||
result.stderr ||
`Subagent did not call ${FINALIZE_TOOL_NAME}.`;
return {
content: [
{
type: "text",
text: formatSubagentContent(
FinalizeStatus.ERROR,
sessionId,
undefined,
fallback,
),
},
],
details: { sessionId },
isError: true,
};
}
if (result.finalized.status === FinalizeStatus.ERROR) {
return {
content: [
{
type: "text",
text: formatSubagentContent(
FinalizeStatus.ERROR,
sessionId,
result.finalized.result,
result.finalized.error ?? "Subagent failed.",
),
},
],
details: { sessionId, finalized: result.finalized },
isError: true,
};
}
return {
content: [
{
type: "text",
text: formatSubagentContent(
FinalizeStatus.SUCCESS,
sessionId,
result.finalized.result,
),
},
],
details: { sessionId, finalized: result.finalized },
isError: false,
};
}, },
renderCall(args, theme) { renderCall(args, theme) {

View File

@@ -1,6 +1,72 @@
import { FINALIZE_TOOL_NAME } from "./constants.ts";
import type { PromptConfig, SubagentResult } from "./types.ts"; import type { PromptConfig, SubagentResult } from "./types.ts";
import { FinalizeStatus } from "./types.ts"; import { FinalizeStatus } from "./types.ts";
// To Tool Result - Translate a completed SubagentResult into the tool response
// shape. Three outcomes: missing finalize, finalize error, finalize success.
export function toToolResult(result: SubagentResult): {
content: { type: "text"; text: string }[];
details: Record<string, unknown>;
isError: boolean;
} {
const { sessionId, finalized } = result;
if (!finalized) {
const fallback =
result.error ||
result.stderr ||
`Subagent did not call ${FINALIZE_TOOL_NAME}.`;
return {
content: [
{
type: "text",
text: formatSubagentContent(
FinalizeStatus.ERROR,
sessionId,
undefined,
fallback,
),
},
],
details: { sessionId },
isError: true,
};
}
if (finalized.status === FinalizeStatus.ERROR) {
return {
content: [
{
type: "text",
text: formatSubagentContent(
FinalizeStatus.ERROR,
sessionId,
finalized.result,
finalized.error ?? "Subagent failed.",
),
},
],
details: { sessionId, finalized },
isError: true,
};
}
return {
content: [
{
type: "text",
text: formatSubagentContent(
FinalizeStatus.SUCCESS,
sessionId,
finalized.result,
),
},
],
details: { sessionId, finalized },
isError: false,
};
}
// Format Tool Content - Some clients hide structured details from the model. // Format Tool Content - Some clients hide structured details from the model.
export function formatSubagentContent( export function formatSubagentContent(
status: FinalizeStatus, status: FinalizeStatus,
@@ -51,6 +117,10 @@ export function formatStatusText(result: SubagentResult): string {
`Tool Calls: ${result.status.toolCallCount} total, ${result.status.activeToolCalls} active`, `Tool Calls: ${result.status.toolCallCount} total, ${result.status.activeToolCalls} active`,
]; ];
if (result.status.attempt > 1) {
lines.push(`Attempt: ${result.status.attempt}/${result.status.maxAttempts}`);
}
if (result.status.recentToolCalls.length > 0) { if (result.status.recentToolCalls.length > 0) {
lines.push("", "Tool Calls - Last 3:"); lines.push("", "Tool Calls - Last 3:");
for (const call of result.status.recentToolCalls.slice(0, 3)) { for (const call of result.status.recentToolCalls.slice(0, 3)) {

View File

@@ -37,11 +37,15 @@ export function renderSubagentSummary(
const latestText = latest const latestText = latest
? ` · ${latest.toolName}${latest.summary ? ` ${latest.summary}` : ""}` ? ` · ${latest.toolName}${latest.summary ? ` ${latest.summary}` : ""}`
: ""; : "";
const attemptText =
result.status.attempt > 1
? ` · try ${result.status.attempt}/${result.status.maxAttempts}`
: "";
const text = const text =
theme.fg(statusColor, statusText) + theme.fg(statusColor, statusText) +
theme.fg( theme.fg(
"muted", "muted",
` · ${result.status.toolCallCount} total, ${result.status.activeToolCalls} active${latestText}`, ` · ${result.status.toolCallCount} total, ${result.status.activeToolCalls} active${latestText}${attemptText}`,
); );
return new Text(text, 0, 0); return new Text(text, 0, 0);
} }
@@ -65,6 +69,15 @@ export function renderSubagentStatus(
)}`, )}`,
]; ];
if (result.status.attempt > 1) {
lines.push(
`${theme.fg("dim", "Attempt:")} ${theme.fg(
"warning",
`${result.status.attempt}/${result.status.maxAttempts}`,
)}`,
);
}
if (result.status.recentToolCalls.length > 0) { if (result.status.recentToolCalls.length > 0) {
lines.push("", theme.fg("dim", "Tool Calls - Last 3:")); lines.push("", theme.fg("dim", "Tool Calls - Last 3:"));
for (const call of result.status.recentToolCalls.slice(0, 3)) { for (const call of result.status.recentToolCalls.slice(0, 3)) {

View File

@@ -3,9 +3,14 @@ import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { withFileMutationQueue } from "@mariozechner/pi-coding-agent"; import { withFileMutationQueue } from "@mariozechner/pi-coding-agent";
import { EXTENSION_ENTRY, FINALIZE_TOOL_NAME } from "./constants.ts"; import {
EXTENSION_ENTRY,
FINALIZE_TOOL_NAME,
MAX_FINALIZE_RETRIES,
} from "./constants.ts";
import { formatStatusText, formatToolArgs } from "./format.ts"; import { formatStatusText, formatToolArgs } from "./format.ts";
import { buildSubagentPrompt } from "./prompts.ts"; import { buildSubagentPrompt } from "./prompts.ts";
import { getSubagentSessionPath } from "./session.ts";
import type { import type {
PromptConfig, PromptConfig,
SubagentResult, SubagentResult,
@@ -13,6 +18,21 @@ import type {
} from "./types.ts"; } from "./types.ts";
import { validateFinalizePayload } from "./tools.ts"; import { validateFinalizePayload } from "./tools.ts";
export interface RunAgentOptions {
cwd: string;
agent: PromptConfig;
task: string;
tools: string[];
sessionId: string;
signal?: AbortSignal;
onUpdate?: OnUpdate;
}
type OnUpdate = (partial: {
content: { type: "text"; text: string }[];
details: SubagentResult;
}) => void;
// Write Prompt - pi accepts appended system prompts via file path. // Write Prompt - pi accepts appended system prompts via file path.
async function writePromptToTempFile( async function writePromptToTempFile(
agentName: string, agentName: string,
@@ -44,23 +64,22 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
return { command: "pi", args }; return { command: "pi", args };
} }
// Run Agent - Spawn an isolated pi process in JSON mode and collect final text. // Run State - Mutable bookkeeping for a single subagent run.
export async function runAgent( interface RunState {
cwd: string, result: SubagentResult;
activeToolIds: Set<string>;
emitUpdate: () => void;
}
function createRunState(
agent: PromptConfig, agent: PromptConfig,
task: string, task: string,
tools: string[], tools: string[],
sessionId: string, sessionId: string,
sessionPath: string, attempt: number,
signal?: AbortSignal, maxAttempts: number,
onUpdate?: (partial: { onUpdate?: OnUpdate,
content: { type: "text"; text: string }[]; ): RunState {
details: SubagentResult;
}) => void,
): Promise<SubagentResult> {
let tmpDir: string | null = null;
let tmpPromptPath: string | null = null;
const result: SubagentResult = { const result: SubagentResult = {
agent: agent.name, agent: agent.name,
task, task,
@@ -74,11 +93,11 @@ export async function runAgent(
toolCallCount: 0, toolCallCount: 0,
activeToolCalls: 0, activeToolCalls: 0,
recentToolCalls: [], recentToolCalls: [],
attempt,
maxAttempts,
}, },
}; };
const activeToolIds = new Set<string>();
const emitUpdate = () => { const emitUpdate = () => {
onUpdate?.({ onUpdate?.({
content: [{ type: "text", text: formatStatusText(result) }], content: [{ type: "text", text: formatStatusText(result) }],
@@ -92,154 +111,242 @@ export async function runAgent(
}); });
}; };
const rememberToolCall = (activity: SubagentToolActivity) => { return { result, activeToolIds: new Set<string>(), emitUpdate };
const existing = result.status.recentToolCalls.findIndex( }
(call) => call.id === activity.id,
);
if (existing >= 0) result.status.recentToolCalls.splice(existing, 1);
result.status.recentToolCalls.unshift(activity);
result.status.recentToolCalls = result.status.recentToolCalls.slice(0, 3);
};
try { function rememberToolCall(state: RunState, activity: SubagentToolActivity) {
const args = [ const calls = state.result.status.recentToolCalls;
"--mode", const existing = calls.findIndex((call) => call.id === activity.id);
"json", if (existing >= 0) calls.splice(existing, 1);
"-p", calls.unshift(activity);
"--session", state.result.status.recentToolCalls = calls.slice(0, 3);
sessionPath, }
"--extension",
EXTENSION_ENTRY,
];
args.push("--tools", tools.join(","));
const prompt = buildSubagentPrompt(agent); // Handle Event - Apply one parsed JSON event from the child to run state.
if (prompt) { function handleEvent(state: RunState, event: any) {
const tmp = await writePromptToTempFile(agent.name, prompt); const { result, activeToolIds, emitUpdate } = state;
tmpDir = tmp.dir;
tmpPromptPath = tmp.filePath;
args.push("--append-system-prompt", tmpPromptPath);
}
args.push(task); if (event.type === "message_start" && event.message?.role === "assistant") {
result.status.state = "thinking";
emitUpdate(); emitUpdate();
return;
}
const exitCode = await new Promise<number>((resolve) => { if (event.type === "tool_execution_start") {
const invocation = getPiInvocation(args); const id = String(event.toolCallId ?? result.status.toolCallCount + 1);
const proc = spawn(invocation.command, invocation.args, { const toolName = String(event.toolName ?? "tool");
cwd, if (toolName === FINALIZE_TOOL_NAME) {
env: { ...process.env, PI_SUBAGENT_CHILD: "1" }, result.finalized = validateFinalizePayload(event.args) ?? undefined;
shell: false, }
stdio: ["ignore", "pipe", "pipe"], activeToolIds.add(id);
}); result.status.state = "running";
result.status.toolCallCount += 1;
result.status.activeToolCalls = activeToolIds.size;
rememberToolCall(state, {
id,
toolName,
summary: formatToolArgs(event.args),
status: "running",
});
emitUpdate();
return;
}
let buffer = ""; if (event.type === "tool_execution_end") {
let aborted = false; const id = String(event.toolCallId ?? "");
if (id) activeToolIds.delete(id);
result.status.activeToolCalls = activeToolIds.size;
const previous = result.status.recentToolCalls.find(
(call) => call.id === id,
);
rememberToolCall(state, {
id,
toolName: String(event.toolName ?? previous?.toolName ?? "tool"),
summary: previous?.summary ?? "",
status: event.isError ? "error" : "done",
});
emitUpdate();
return;
}
const processLine = (line: string) => { if (event.type === "message_end" && event.message?.role === "assistant") {
if (!line.trim()) return; for (const part of event.message.content ?? []) {
try { if (part.type === "text") result.output = part.text;
const event = JSON.parse(line); }
if ( if (event.message.stopReason === "error") {
event.type === "message_start" && result.error =
event.message?.role === "assistant" event.message.errorMessage ?? "Subagent stopped with an error.";
) { }
result.status.state = "thinking"; emitUpdate();
emitUpdate(); }
} }
if (event.type === "tool_execution_start") {
const id = String(
event.toolCallId ?? result.status.toolCallCount + 1,
);
const toolName = String(event.toolName ?? "tool");
if (toolName === FINALIZE_TOOL_NAME) {
result.finalized =
validateFinalizePayload(event.args) ?? undefined;
}
activeToolIds.add(id);
result.status.state = "running";
result.status.toolCallCount += 1;
result.status.activeToolCalls = activeToolIds.size;
rememberToolCall({
id,
toolName,
summary: formatToolArgs(event.args),
status: "running",
});
emitUpdate();
}
if (event.type === "tool_execution_end") {
const id = String(event.toolCallId ?? "");
if (id) activeToolIds.delete(id);
result.status.activeToolCalls = activeToolIds.size;
const previous = result.status.recentToolCalls.find(
(call) => call.id === id,
);
rememberToolCall({
id,
toolName: String(event.toolName ?? previous?.toolName ?? "tool"),
summary: previous?.summary ?? "",
status: event.isError ? "error" : "done",
});
emitUpdate();
}
if (
event.type === "message_end" &&
event.message?.role === "assistant"
) {
for (const part of event.message.content ?? []) {
if (part.type === "text") result.output = part.text;
}
if (event.message.stopReason === "error") {
result.error =
event.message.errorMessage ?? "Subagent stopped with an error.";
}
emitUpdate();
}
} catch {
// Ignore Non-JSON Output.
}
};
proc.stdout.on("data", (data) => { // Spawn Pi - Run the child process, stream stdout lines, capture stderr.
buffer += data.toString(); function spawnPi(opts: {
const lines = buffer.split("\n"); command: string;
buffer = lines.pop() ?? ""; args: string[];
for (const line of lines) processLine(line); cwd: string;
}); signal?: AbortSignal;
onLine: (line: string) => void;
onStderr: (chunk: string) => void;
onError: (message: string) => void;
}): Promise<number> {
return new Promise<number>((resolve) => {
const proc = spawn(opts.command, opts.args, {
cwd: opts.cwd,
env: { ...process.env, PI_SUBAGENT_CHILD: "1" },
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
proc.stderr.on("data", (data) => { let buffer = "";
result.stderr += data.toString(); let aborted = false;
});
proc.on("close", (code) => { proc.stdout.on("data", (data) => {
if (buffer.trim()) processLine(buffer); buffer += data.toString();
resolve(aborted ? 130 : (code ?? 0)); const lines = buffer.split("\n");
}); buffer = lines.pop() ?? "";
for (const line of lines) {
proc.on("error", (error) => { if (line.trim()) opts.onLine(line);
result.error = error.message;
resolve(1);
});
if (signal) {
const killProc = () => {
aborted = true;
proc.kill("SIGTERM");
setTimeout(() => {
if (!proc.killed) proc.kill("SIGKILL");
}, 5000);
};
if (signal.aborted) killProc();
else signal.addEventListener("abort", killProc, { once: true });
} }
}); });
result.exitCode = exitCode; proc.stderr.on("data", (data) => opts.onStderr(data.toString()));
result.status.state = "done";
result.status.activeToolCalls = 0; proc.on("close", (code) => {
if (exitCode === 130) result.error = "Subagent was aborted."; if (buffer.trim()) opts.onLine(buffer);
return result; resolve(aborted ? 130 : (code ?? 0));
});
proc.on("error", (error) => {
opts.onError(error.message);
resolve(1);
});
if (opts.signal) {
const killProc = () => {
aborted = true;
proc.kill("SIGTERM");
setTimeout(() => {
if (!proc.killed) proc.kill("SIGKILL");
}, 5000);
};
if (opts.signal.aborted) killProc();
else opts.signal.addEventListener("abort", killProc, { once: true });
}
});
}
// Run Agent With Retries - Public entry. Sets up the sticky session once,
// then loops runOnce until the child finalizes or fails hard.
export async function runAgentWithRetries(
opts: RunAgentOptions,
): Promise<SubagentResult> {
const sessionPath = getSubagentSessionPath(
opts.cwd,
opts.agent.name,
opts.sessionId,
);
await fs.promises.mkdir(path.dirname(sessionPath), { recursive: true });
const maxAttempts = MAX_FINALIZE_RETRIES + 1;
let result!: SubagentResult;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
result = await runOnce(opts, sessionPath, attempt, maxAttempts);
// Break Conditions - finalize wins; otherwise any process failure aborts retry.
if (result.finalized) break;
if (result.exitCode !== 0 || result.error) break;
}
return result;
}
// Build Child Prompt - First attempt sends the task verbatim; later attempts
// nudge the child to finalize without altering the user-facing task string.
function buildChildPrompt(task: string, attempt: number): string {
if (attempt === 1) return `Task: ${task}`;
return [
"Your previous response did not finalize correctly.",
`If you are finished, call ${FINALIZE_TOOL_NAME}.`,
"If you are not finished, continue the original task using available tools as needed.",
`Original task: ${task}`,
].join("\n\n");
}
// Run Once - Spawn an isolated pi process in JSON mode and collect final text.
async function runOnce(
opts: RunAgentOptions,
sessionPath: string,
attempt: number,
maxAttempts: number,
): Promise<SubagentResult> {
const { cwd, agent, task, tools, sessionId, signal, onUpdate } = opts;
const state = createRunState(
agent,
task,
tools,
sessionId,
attempt,
maxAttempts,
onUpdate,
);
// Build CLI Args
const args = [
"--mode",
"json",
"-p",
"--session",
sessionPath,
"--extension",
EXTENSION_ENTRY,
"--tools",
tools.join(","),
];
// Append System Prompt - Written to a temp file scoped to this run.
let tmpDir: string | null = null;
let tmpPromptPath: string | null = null;
const prompt = buildSubagentPrompt(agent);
if (prompt) {
const tmp = await writePromptToTempFile(agent.name, prompt);
tmpDir = tmp.dir;
tmpPromptPath = tmp.filePath;
// System Prompt - Replace pi's default so the subagent .md body is the
// authoritative prompt (no conflicting "You are..." identity from pi).
args.push("--system-prompt", tmpPromptPath);
}
args.push(buildChildPrompt(task, attempt));
try {
state.emitUpdate();
const invocation = getPiInvocation(args);
const exitCode = await spawnPi({
command: invocation.command,
args: invocation.args,
cwd,
signal,
onLine: (line) => {
try {
handleEvent(state, JSON.parse(line));
} catch {
// Ignore Non-JSON Output.
}
},
onStderr: (chunk) => {
state.result.stderr += chunk;
},
onError: (message) => {
state.result.error = message;
},
});
state.result.exitCode = exitCode;
state.result.status.state = "done";
state.result.status.activeToolCalls = 0;
if (exitCode === 130) state.result.error = "Subagent was aborted.";
return state.result;
} finally { } finally {
if (tmpPromptPath) await fs.promises.rm(tmpPromptPath, { force: true }); if (tmpPromptPath) await fs.promises.rm(tmpPromptPath, { force: true });
if (tmpDir) await fs.promises.rm(tmpDir, { force: true, recursive: true }); if (tmpDir) await fs.promises.rm(tmpDir, { force: true, recursive: true });

View File

@@ -19,6 +19,8 @@ export interface SubagentStatus {
toolCallCount: number; toolCallCount: number;
activeToolCalls: number; activeToolCalls: number;
recentToolCalls: SubagentToolActivity[]; recentToolCalls: SubagentToolActivity[];
attempt: number;
maxAttempts: number;
} }
export enum FinalizeStatus { export enum FinalizeStatus {

50
src/validate.ts Normal file
View File

@@ -0,0 +1,50 @@
import { formatPromptList } from "./format.ts";
import { resolveTools } from "./tools.ts";
import type { PromptConfig } from "./types.ts";
export type AgentValidation =
| { ok: true; agent: PromptConfig; tools: string[] }
| { ok: false; text: string; details: Record<string, unknown> };
// Validate Agent - Resolve the agent by name, enforce config invariants, and
// compute the effective tool list. Returns a flat error payload on failure so
// the caller can shape it into a tool result without re-deriving any of this.
export function validateAgent(
agentName: string,
prompts: PromptConfig[],
activeTools: string[],
): AgentValidation {
const agent = prompts.find((prompt) => prompt.name === agentName);
if (!agent) {
return {
ok: false,
text: `Unknown subagent: ${agentName}. Available: ${formatPromptList(prompts)}`,
details: { available: prompts.map((prompt) => prompt.name) },
};
}
if (agent.approvedTools.length > 0 && agent.deniedTools.length > 0) {
return {
ok: false,
text:
`Invalid subagent config for ${agent.name}: define either approved_tools/allowed_tools ` +
"or denied_tools, not both.",
details: { agent: agent.name, filePath: agent.filePath },
};
}
const tools = resolveTools(agent, activeTools);
if (tools.length === 0) {
return {
ok: false,
text: `Subagent ${agent.name} has no approved tools after applying denied_tools.`,
details: {
agent: agent.name,
approvedTools: agent.approvedTools,
deniedTools: agent.deniedTools,
},
};
}
return { ok: true, agent, tools };
}