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`.
|
`~/.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
186
index.ts
@@ -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) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
409
src/runner.ts
409
src/runner.ts
@@ -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 });
|
||||||
|
|||||||
@@ -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
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