Initial commit: pygenium as git submodule
This commit is contained in:
181
src/agent-runner.ts
Normal file
181
src/agent-runner.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user