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:
@@ -13,6 +13,8 @@ This repo implements the `subagent` pi extension in `index.ts`.
|
||||
`~/.pi/subagent-sessions/<cwd-hash>/<agent>_<sessionId>.jsonl`.
|
||||
- Omitting `sessionId` creates a new UUID-backed 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
|
||||
|
||||
|
||||
184
index.ts
184
index.ts
@@ -1,18 +1,17 @@
|
||||
// Subagent Extension - Registers a tool for delegating work to prompt-defined
|
||||
// subagents with constrained tool permissions.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { FINALIZE_TOOL_NAME, MAX_FINALIZE_RETRIES } from "./src/constants.ts";
|
||||
import { formatPromptList, formatSubagentContent } from "./src/format.ts";
|
||||
import { FINALIZE_TOOL_NAME } from "./src/constants.ts";
|
||||
import { formatPromptList, toToolResult } from "./src/format.ts";
|
||||
import { discoverPrompts } from "./src/prompts.ts";
|
||||
import { renderSubagentCall, renderSubagentResult } from "./src/render.ts";
|
||||
import { runAgent } from "./src/runner.ts";
|
||||
import { getSubagentSessionPath } from "./src/session.ts";
|
||||
import { FinalizeStatus, type SubagentResult } from "./src/types.ts";
|
||||
import { resolveTools, SubagentParams } from "./src/tools.ts";
|
||||
import { runAgentWithRetries } from "./src/runner.ts";
|
||||
import { SubagentParams } from "./src/tools.ts";
|
||||
import { FinalizeStatus } from "./src/types.ts";
|
||||
import { validateAgent } from "./src/validate.ts";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
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.",
|
||||
parameters: SubagentParams,
|
||||
|
||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||
const prompts = discoverPrompts();
|
||||
const agent = prompts.find((prompt) => prompt.name === params.agent);
|
||||
if (!agent) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Unknown subagent: ${params.agent}. Available: ${formatPromptList(prompts)}`,
|
||||
},
|
||||
],
|
||||
details: { available: prompts.map((prompt) => prompt.name) },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (agent.approvedTools.length > 0 && agent.deniedTools.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
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 },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const activeTools = pi.getActiveTools();
|
||||
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,
|
||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
||||
// Validate Agent
|
||||
const validation = validateAgent(
|
||||
params.agent,
|
||||
discoverPrompts(),
|
||||
pi.getActiveTools(),
|
||||
);
|
||||
|
||||
if (result.finalized) break;
|
||||
if (result.exitCode !== 0 || result.error) break;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
if (!validation.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatSubagentContent(
|
||||
FinalizeStatus.ERROR,
|
||||
sessionId,
|
||||
undefined,
|
||||
"Subagent did not run.",
|
||||
),
|
||||
},
|
||||
],
|
||||
details: { sessionId },
|
||||
content: [{ type: "text", text: validation.text }],
|
||||
details: validation.details,
|
||||
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,
|
||||
};
|
||||
}
|
||||
// Run Subagent
|
||||
const result = await runAgentWithRetries({
|
||||
cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
|
||||
agent: validation.agent,
|
||||
task: params.task,
|
||||
tools: validation.tools,
|
||||
sessionId: params.sessionId ?? randomUUID(),
|
||||
signal,
|
||||
onUpdate,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
return toToolResult(result);
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
import { FINALIZE_TOOL_NAME } from "./constants.ts";
|
||||
import type { PromptConfig, SubagentResult } 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.
|
||||
export function formatSubagentContent(
|
||||
status: FinalizeStatus,
|
||||
@@ -51,6 +117,10 @@ export function formatStatusText(result: SubagentResult): string {
|
||||
`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) {
|
||||
lines.push("", "Tool Calls - Last 3:");
|
||||
for (const call of result.status.recentToolCalls.slice(0, 3)) {
|
||||
|
||||
@@ -37,11 +37,15 @@ export function renderSubagentSummary(
|
||||
const latestText = latest
|
||||
? ` · ${latest.toolName}${latest.summary ? ` ${latest.summary}` : ""}`
|
||||
: "";
|
||||
const attemptText =
|
||||
result.status.attempt > 1
|
||||
? ` · try ${result.status.attempt}/${result.status.maxAttempts}`
|
||||
: "";
|
||||
const text =
|
||||
theme.fg(statusColor, statusText) +
|
||||
theme.fg(
|
||||
"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);
|
||||
}
|
||||
@@ -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) {
|
||||
lines.push("", theme.fg("dim", "Tool Calls - Last 3:"));
|
||||
for (const call of result.status.recentToolCalls.slice(0, 3)) {
|
||||
|
||||
289
src/runner.ts
289
src/runner.ts
@@ -3,9 +3,14 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
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 { buildSubagentPrompt } from "./prompts.ts";
|
||||
import { getSubagentSessionPath } from "./session.ts";
|
||||
import type {
|
||||
PromptConfig,
|
||||
SubagentResult,
|
||||
@@ -13,6 +18,21 @@ import type {
|
||||
} from "./types.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.
|
||||
async function writePromptToTempFile(
|
||||
agentName: string,
|
||||
@@ -44,23 +64,22 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
||||
return { command: "pi", args };
|
||||
}
|
||||
|
||||
// Run Agent - Spawn an isolated pi process in JSON mode and collect final text.
|
||||
export async function runAgent(
|
||||
cwd: string,
|
||||
// Run State - Mutable bookkeeping for a single subagent run.
|
||||
interface RunState {
|
||||
result: SubagentResult;
|
||||
activeToolIds: Set<string>;
|
||||
emitUpdate: () => void;
|
||||
}
|
||||
|
||||
function createRunState(
|
||||
agent: PromptConfig,
|
||||
task: string,
|
||||
tools: string[],
|
||||
sessionId: string,
|
||||
sessionPath: string,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: (partial: {
|
||||
content: { type: "text"; text: string }[];
|
||||
details: SubagentResult;
|
||||
}) => void,
|
||||
): Promise<SubagentResult> {
|
||||
let tmpDir: string | null = null;
|
||||
let tmpPromptPath: string | null = null;
|
||||
|
||||
attempt: number,
|
||||
maxAttempts: number,
|
||||
onUpdate?: OnUpdate,
|
||||
): RunState {
|
||||
const result: SubagentResult = {
|
||||
agent: agent.name,
|
||||
task,
|
||||
@@ -74,11 +93,11 @@ export async function runAgent(
|
||||
toolCallCount: 0,
|
||||
activeToolCalls: 0,
|
||||
recentToolCalls: [],
|
||||
attempt,
|
||||
maxAttempts,
|
||||
},
|
||||
};
|
||||
|
||||
const activeToolIds = new Set<string>();
|
||||
|
||||
const emitUpdate = () => {
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: formatStatusText(result) }],
|
||||
@@ -92,82 +111,47 @@ export async function runAgent(
|
||||
});
|
||||
};
|
||||
|
||||
const rememberToolCall = (activity: SubagentToolActivity) => {
|
||||
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 {
|
||||
const args = [
|
||||
"--mode",
|
||||
"json",
|
||||
"-p",
|
||||
"--session",
|
||||
sessionPath,
|
||||
"--extension",
|
||||
EXTENSION_ENTRY,
|
||||
];
|
||||
args.push("--tools", tools.join(","));
|
||||
|
||||
const prompt = buildSubagentPrompt(agent);
|
||||
if (prompt) {
|
||||
const tmp = await writePromptToTempFile(agent.name, prompt);
|
||||
tmpDir = tmp.dir;
|
||||
tmpPromptPath = tmp.filePath;
|
||||
args.push("--append-system-prompt", tmpPromptPath);
|
||||
return { result, activeToolIds: new Set<string>(), emitUpdate };
|
||||
}
|
||||
|
||||
args.push(task);
|
||||
emitUpdate();
|
||||
function rememberToolCall(state: RunState, activity: SubagentToolActivity) {
|
||||
const calls = state.result.status.recentToolCalls;
|
||||
const existing = calls.findIndex((call) => call.id === activity.id);
|
||||
if (existing >= 0) calls.splice(existing, 1);
|
||||
calls.unshift(activity);
|
||||
state.result.status.recentToolCalls = calls.slice(0, 3);
|
||||
}
|
||||
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
const invocation = getPiInvocation(args);
|
||||
const proc = spawn(invocation.command, invocation.args, {
|
||||
cwd,
|
||||
env: { ...process.env, PI_SUBAGENT_CHILD: "1" },
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
// Handle Event - Apply one parsed JSON event from the child to run state.
|
||||
function handleEvent(state: RunState, event: any) {
|
||||
const { result, activeToolIds, emitUpdate } = state;
|
||||
|
||||
let buffer = "";
|
||||
let aborted = false;
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (
|
||||
event.type === "message_start" &&
|
||||
event.message?.role === "assistant"
|
||||
) {
|
||||
if (event.type === "message_start" && event.message?.role === "assistant") {
|
||||
result.status.state = "thinking";
|
||||
emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_start") {
|
||||
const id = String(
|
||||
event.toolCallId ?? result.status.toolCallCount + 1,
|
||||
);
|
||||
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;
|
||||
result.finalized = validateFinalizePayload(event.args) ?? undefined;
|
||||
}
|
||||
activeToolIds.add(id);
|
||||
result.status.state = "running";
|
||||
result.status.toolCallCount += 1;
|
||||
result.status.activeToolCalls = activeToolIds.size;
|
||||
rememberToolCall({
|
||||
rememberToolCall(state, {
|
||||
id,
|
||||
toolName,
|
||||
summary: formatToolArgs(event.args),
|
||||
status: "running",
|
||||
});
|
||||
emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_end") {
|
||||
const id = String(event.toolCallId ?? "");
|
||||
if (id) activeToolIds.delete(id);
|
||||
@@ -175,18 +159,17 @@ export async function runAgent(
|
||||
const previous = result.status.recentToolCalls.find(
|
||||
(call) => call.id === id,
|
||||
);
|
||||
rememberToolCall({
|
||||
rememberToolCall(state, {
|
||||
id,
|
||||
toolName: String(event.toolName ?? previous?.toolName ?? "tool"),
|
||||
summary: previous?.summary ?? "",
|
||||
status: event.isError ? "error" : "done",
|
||||
});
|
||||
emitUpdate();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === "message_end" &&
|
||||
event.message?.role === "assistant"
|
||||
) {
|
||||
|
||||
if (event.type === "message_end" && event.message?.role === "assistant") {
|
||||
for (const part of event.message.content ?? []) {
|
||||
if (part.type === "text") result.output = part.text;
|
||||
}
|
||||
@@ -196,33 +179,51 @@ export async function runAgent(
|
||||
}
|
||||
emitUpdate();
|
||||
}
|
||||
} catch {
|
||||
// Ignore Non-JSON Output.
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn Pi - Run the child process, stream stdout lines, capture stderr.
|
||||
function spawnPi(opts: {
|
||||
command: string;
|
||||
args: string[];
|
||||
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"],
|
||||
});
|
||||
|
||||
let buffer = "";
|
||||
let aborted = false;
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) processLine(line);
|
||||
for (const line of lines) {
|
||||
if (line.trim()) opts.onLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
result.stderr += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data) => opts.onStderr(data.toString()));
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (buffer.trim()) processLine(buffer);
|
||||
if (buffer.trim()) opts.onLine(buffer);
|
||||
resolve(aborted ? 130 : (code ?? 0));
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
result.error = error.message;
|
||||
opts.onError(error.message);
|
||||
resolve(1);
|
||||
});
|
||||
|
||||
if (signal) {
|
||||
if (opts.signal) {
|
||||
const killProc = () => {
|
||||
aborted = true;
|
||||
proc.kill("SIGTERM");
|
||||
@@ -230,16 +231,122 @@ export async function runAgent(
|
||||
if (!proc.killed) proc.kill("SIGKILL");
|
||||
}, 5000);
|
||||
};
|
||||
if (signal.aborted) killProc();
|
||||
else signal.addEventListener("abort", killProc, { once: true });
|
||||
if (opts.signal.aborted) killProc();
|
||||
else opts.signal.addEventListener("abort", killProc, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
result.exitCode = exitCode;
|
||||
result.status.state = "done";
|
||||
result.status.activeToolCalls = 0;
|
||||
if (exitCode === 130) result.error = "Subagent was aborted.";
|
||||
// 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 {
|
||||
if (tmpPromptPath) await fs.promises.rm(tmpPromptPath, { force: true });
|
||||
if (tmpDir) await fs.promises.rm(tmpDir, { force: true, recursive: true });
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface SubagentStatus {
|
||||
toolCallCount: number;
|
||||
activeToolCalls: number;
|
||||
recentToolCalls: SubagentToolActivity[];
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
export enum FinalizeStatus {
|
||||
|
||||
50
src/validate.ts
Normal file
50
src/validate.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user