Files
pygienium/src/agent-runner.ts
Michael Freno d8be026a2b fix: todos scan task ballooned to 2.5MB and analysis produced no output
The todos pre-scan walked .output/ (Nitro) and .vercel/ (Vercel) build
dirs, flagging 119 of 124 candidates inside minified bundles (single
lines up to 162KB). buildTodosScanTask embedded full candidate lines in
the task prompt, producing a 2.5MB prompt on freno-dev; the analysis
agent settled with ok:true + empty text + no findings.md, verify failed,
and resume re-ran the same oversized prompt and failed identically.

- scope: exclude .output/.vercel/.netlify (shared by all checks)
- todos: truncate candidate code at 160 chars in the prompt + fallback
- agent-runner: a session settling with no text and no observed message/
  tool events now fails the run loudly instead of reporting ok:true
- agent prompts: add the three dirs to each skip list
- tests: excluded-dir scan, prompt truncation, emptySessionError cases
2026-08-11 12:12:15 -04:00

451 lines
16 KiB
TypeScript

/**
* agent-runner.ts — spawn isolated sub-agents for analysis and fix phases.
*
* The production runner uses pi's `createAgentSession` SDK to spin up a fresh
* in-memory agent session scoped to the target `cwd`, with the agent
* definition's system prompt and tool allowlist applied. Because that path
* needs live model credentials (unsuitable for CI), the runner is backed by an
* injectable factory: tests swap it for a deterministic fake that executes a
* tiny instruction protocol embedded in the task string.
*
* Instruction protocol (used by the fake runner, harmless to the real one):
* task lines may begin with `!write <path> <text>` — the fake writes the file
* and reports it as a finding. Real sub-agents receive the whole task verbatim.
*
* @module pygienium/agent-runner
*/
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import type {
AgentSession,
AgentSessionEvent,
} from "@earendil-works/pi-coding-agent";
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
/**
* Env var overriding the per-agent settle timeout (ms). Guards against a
* sub-agent session that never settles (stalled provider stream, hung retry /
* auto-compaction after the final tool call), which otherwise leaves the run
* stuck mid-phase with no state save, no error, and no completion message.
*/
export const AGENT_TIMEOUT_ENV = "PYGIENIUM_AGENT_TIMEOUT_MS";
/** Default settle timeout per agent phase: 60 minutes. */
const DEFAULT_AGENT_TIMEOUT_MS = 60 * 60_000;
/** Resolve the per-agent settle timeout, honouring the env override. */
export function agentTimeoutMs(): number {
const raw = process.env[AGENT_TIMEOUT_ENV];
if (raw && /^\d+$/.test(raw.trim()) && Number(raw.trim()) > 0) {
return Number(raw.trim());
}
return DEFAULT_AGENT_TIMEOUT_MS;
}
export interface AgentTaskOptions {
/** Absolute working directory for the sub-agent. */
cwd: string;
/** Agent name to look up in `agents/*.md`. */
agentName: string;
/** The task prompt handed to the sub-agent. */
task: string;
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */
allowedTools?: string[];
/** Optional explicit agent definition (skips `loadAgents`). */
agent?: AgentDef;
/**
* Live callback forwarding raw {@link AgentSessionEvent}s from the
* sub-agent session. The `pygienium-stream` forwarder in `index.ts`
* turns tool_execution_start/end + assistant turns into chat messages.
*/
onEvent?: (event: AgentSessionEvent) => void;
/**
* Maximum time the agent run may take before it is aborted and the phase
* fails loudly (default {@link agentTimeoutMs}). A session that never
* settles — stalled provider stream, hung retry/compaction after its last
* tool call — would otherwise hang the check run silently with no state
* update and no completion message.
*/
timeoutMs?: number;
}
/**
* Race `promise` against a settle deadline. Returns `{ value }` on success or
* `{ error }` when the deadline elapsed first (the caller aborts the work).
* `promise` is still awaited-then-ignored afterwards so late rejections can
* never surface as unhandled.
*/
export async function withSettleTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<{ value: T } | { error: string }> {
if (timeoutMs <= 0) {
try {
return { value: await promise };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
const settled = promise.then(
(value) => ({ value }) as { value: T },
(err) =>
({ error: err instanceof Error ? err.message : String(err) }) as {
error: string;
},
);
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<{ error: string }>((resolve) => {
timer = setTimeout(() => {
// ~`1m 30s` / `45s` for the message (timeoutMs is ms; tests use small values).
const totalSec = Math.round(timeoutMs / 1000);
const duration =
totalSec >= 60
? `${Math.floor(totalSec / 60)}m${totalSec % 60 ? ` ${totalSec % 60}s` : ""}`
: `${totalSec}s`;
resolve({
error: `${label} did not settle within ${duration}; aborted. Check model/provider connectivity, then resume with /pygienium-resume.`,
});
}, timeoutMs);
// Never hold the process open just because a deadline is pending.
timer.unref?.();
});
try {
return await Promise.race([settled, deadline]);
} finally {
if (timer) clearTimeout(timer);
}
}
export interface AgentRunResult {
/** Whether the sub-agent completed without throwing. */
ok: boolean;
/** The final assistant text emitted by the sub-agent. */
text: string;
/** Error message when `ok` is false. */
error?: string;
}
export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>;
/** Module-level runner (defaults to the SDK-backed runner; tests override it). */
let currentRunner: AgentRunner = defaultAgentRunner;
/** Entry point used by the check-runner. */
export async function runAgentTask(
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
// Backstop: even a third-party/custom runner must not be able to hang the
// run forever. The default runner additionally aborts its session on
// timeout (see defaultAgentRunner); this race covers every other runner.
const settled = await withSettleTimeout(
currentRunner(opts),
opts.timeoutMs ?? agentTimeoutMs(),
`sub-agent "${opts.agentName}"`,
);
if ("error" in settled) {
return { ok: false, text: "", error: settled.error };
}
return settled.value;
}
/** Override the active agent runner (primarily for tests). */
export function setAgentRunner(runner: AgentRunner): void {
currentRunner = runner;
}
/** Restore the default SDK-backed agent runner. */
export function resetAgentRunner(): void {
currentRunner = defaultAgentRunner;
}
/**
* Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`,
* overrides the system prompt with the agent definition's body, restricts tools
* to the agent's allowlist, and runs the task to completion.
*/
export async function defaultAgentRunner(
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
const agents = await loadAgents({ cwd: opts.cwd });
const agent = opts.agent ?? agents.get(opts.agentName);
if (!agent) {
const names = [...agents.keys()];
return {
ok: false,
text: "",
error:
`Unknown agent definition: "${opts.agentName}". ` +
`Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` +
`Searched ${extensionRoot()}/agents/ (extension) and ${opts.cwd}/agents/ (project-local). ` +
`Add agents/${opts.agentName}.md to either location.`,
};
}
// Lazily import the SDK so the rest of the module graph (and tests using the
// fake runner) never resolve the heavy pi-coding-agent package.
const {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
} = await import("@earendil-works/pi-coding-agent");
const loader = new DefaultResourceLoader({
cwd: opts.cwd,
agentDir: getAgentDir(),
systemPromptOverride: () => agent.systemPrompt,
// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.
noExtensions: true,
noSkills: true,
noThemes: true,
noPromptTemplates: true,
});
await loader.reload();
const tools = opts.allowedTools ??
agent.allowedTools ?? ["read", "bash", "grep", "find"];
const { session } = await createAgentSession({
cwd: opts.cwd,
tools,
sessionManager: SessionManager.inMemory(opts.cwd),
resourceLoader: loader,
});
// A session that never settles (stalled provider stream, hung retry /
// auto-compaction after its last tool call) must fail the phase loudly
// instead of hanging the run mid-transition with zero diagnostics. On
// timeout the session is disposed, which aborts the in-flight run.
const settled = await withSettleTimeout(
runSessionToCompletion(session, opts),
opts.timeoutMs ?? agentTimeoutMs(),
`sub-agent "${opts.agentName}"`,
);
if ("error" in settled) {
// Abort the still-running session so it can't keep burning provider
// calls; the background settle path then finishes and disposes too.
try {
session.dispose();
} catch {
/* ignore dispose errors */
}
return { ok: false, text: "", error: settled.error };
}
return settled.value;
}
/**
* Run an in-memory agent session to completion, streaming tool events to the
* chat forwarder, and return the agent's final text. Owns session cleanup.
*/
async function runSessionToCompletion(
session: AgentSession,
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
const acc: SessionEventAccumulator = { text: "" };
try {
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
applySessionEvent(acc, event, opts.onEvent);
});
await session.prompt(opts.task, { expandPromptTemplates: false });
// Ensure the agent has fully settled (tool calls may still be in-flight
// after prompt() resolves; piolium's runner calls this for the same
// reason).
await session.agent.waitForIdle();
unsubscribe();
// Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true. A
// session that settled with NO text and NO observed events means the
// model never produced anything at all (dead provider stream, failed
// start) — reporting that as a successful analysis would let an empty
// scan masquerade as a clean one, and the check's verify hook would
// fail only later with a confusing "artifact missing" error.
const sessionError = emptySessionError(acc);
if (sessionError) {
return { ok: false, text: acc.text, error: sessionError };
}
return { ok: true, text: acc.text };
} catch (err) {
return {
ok: false,
text: "",
error: err instanceof Error ? err.message : String(err),
};
} finally {
try {
session.dispose();
} catch {
/* ignore dispose errors */
}
}
}
/** Running capture state while a session streams assistant output. */
export interface SessionEventAccumulator {
/** Joined assistant text seen so far (text_delta stream). */
text: string;
/** Whether any assistant-message or tool event was observed at all. */
sawMessage: boolean;
/** stopReason of the final assistant message, when reported. */
stopReason?: string;
/** errorMessage of the final assistant message, when reported. */
errorMessage?: string;
}
/**
* Classify a settled session's capture: `undefined` when the result is a
* legitimate (possibly empty-text) outcome, else the error that should fail
* the run. Kept pure so the decision is unit-testable without a session.
*/
export function emptySessionError(
acc: SessionEventAccumulator,
): string | undefined {
if (acc.errorMessage) return acc.errorMessage;
if (!acc.text.trim()) {
if (acc.stopReason === "error") {
return "sub-agent session ended in error with no output.";
}
if (!acc.sawMessage) {
return "sub-agent session settled with no output — no assistant message or tool activity was observed. Check model/provider connectivity, then resume.";
}
}
return undefined;
}
/**
* Interpret one session event into the running accumulator and forward the
* stream-driving events to the chat.
*
* MUST never throw: it runs synchronously inside the SDK's event pipeline
* (`session.subscribe` listeners are invoked from `processEvents`/`_emit`).
* A throw there is not contained — the SDK's run-failure path re-emits
* failure events through the same callback, so a callback that always throws
* recurses until stack overflow and crashes the host, stranding run-state at
* the phase boundary with no save and no completion. Guard every shape (the
* runtime events are partial/streaming and weaker than their types) and never
* let the cosmetic chat forwarder take the run down.
*/
export function applySessionEvent(
acc: SessionEventAccumulator,
event: AgentSessionEvent,
forward?: (event: AgentSessionEvent) => void,
): void {
if (!event) return;
try {
// Any message or tool event means the session actually ran — a settled
// capture with none of these is a dead session, not an empty scan.
if (
event.type === "message_update" ||
event.type === "message_end" ||
event.type === "tool_execution_start" ||
event.type === "tool_execution_end"
) {
acc.sawMessage = true;
}
if (
event.type === "message_update" &&
event.assistantMessageEvent?.type === "text_delta"
) {
acc.text += event.assistantMessageEvent.delta ?? "";
}
if (event.type === "message_end") {
// Capture the full assistant text from the finalized message —
// models that don't stream text_delta (or truncate) still surface
// their output here. Prefer the streamed text when non-empty.
const message = event.message as
| {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
}
| undefined;
if (message) {
if (message.stopReason) acc.stopReason = message.stopReason;
if (message.errorMessage) acc.errorMessage = message.errorMessage;
if (message.role === "assistant") {
const full = extractAssistantText(message.content).trim();
if (full && !acc.text.trim()) acc.text = full;
}
}
}
} catch (err) {
// A malformed event must not crash the SDK event pipeline; log and skip.
console.error("pygienium: error processing sub-agent event", err);
}
// Forward the stream-driving events to the chat forwarder; it turns each
// into its own `pygienium-stream` message (see index.ts). Cosmetic chat
// UI: a broken forwarder must not fail or hang the agent run.
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end" ||
event.type === "message_end"
) {
try {
forward?.(event);
} catch (err) {
console.error("pygienium: error forwarding sub-agent event", err);
}
}
}
/**
* Extract joined text from an assistant message's content blocks.
* Mirrors piolium's `extractAssistantText`.
*/
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((c) =>
c && typeof c === "object" && (c as { type?: string }).type === "text"
? [(c as { text?: string }).text ?? ""]
: [],
)
.join("");
}
/**
* Fake agent runner for tests: it understands a tiny instruction protocol
* embedded in the task so a no-op check can produce deterministic findings
* and write marker files without a model. Recognised instructions (one per
* line, leading-whitespace tolerant):
*
* !write <path> <text...> — write text to path (relative to cwd); recorded
* !echo <text...> — appended to findings
*
* The agent's emitted findings text is the collected `!echo`/`!write` lines.
*/
export const fakeAgentRunner: AgentRunner = async (opts) => {
const lines = opts.task.split(/\r?\n/);
const findings: string[] = [];
try {
for (const line of lines) {
const trimmed = line.trim();
const write = /^!write\s+(\S+)\s*(.*)$/.exec(trimmed);
if (write) {
const rel = write[1] as string;
const content = (write[2] ?? "").replace(/^["']|["']$/g, "");
const full = isAbsolute(rel) ? rel : join(opts.cwd, rel);
await mkdir(dirname(full), { recursive: true });
await writeFile(full, content + "\n", "utf8");
findings.push(`wrote ${rel}`);
continue;
}
const echo = /^!echo\s+(.*)$/.exec(trimmed);
if (echo) {
findings.push((echo[1] ?? "").replace(/^["']|["']$/g, ""));
}
}
return { ok: true, text: findings.join("\n") };
} catch (err) {
return {
ok: false,
text: findings.join("\n"),
error: err instanceof Error ? err.message : String(err),
};
}
};