Initial commit: pygenium as git submodule

This commit is contained in:
2026-08-07 14:54:45 -04:00
commit 581436ed23
61 changed files with 9331 additions and 0 deletions

181
src/agent-runner.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* 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 { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { loadAgents, type AgentDef } from "./agents.js";
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;
}
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 function runAgentTask(opts: AgentTaskOptions): Promise<AgentRunResult> {
return currentRunner(opts);
}
/** 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 agent =
opts.agent ?? (await loadAgents({ cwd: opts.cwd })).get(opts.agentName);
if (!agent) {
return {
ok: false,
text: "",
error: `Unknown agent definition: "${opts.agentName}". Add agents/${opts.agentName}.md.`,
};
}
// 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,
});
try {
let text = "";
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
text += event.assistantMessageEvent.delta;
}
});
await session.prompt(opts.task, { expandPromptTemplates: false });
unsubscribe();
return { ok: true, text };
} catch (err) {
return {
ok: false,
text: "",
error: err instanceof Error ? err.message : String(err),
};
} finally {
try {
session.dispose();
} catch {
/* ignore dispose errors */
}
}
}
/**
* 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),
};
}
};