/** * Pi Loop Extension - Node Implementations * * Contains the execution logic for each node kind: * - code: deterministic extension logic * - gate: deterministic validation * - llm: supervised agent phase * - approval: user decision point * - finalizer: run completion */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { GateResult, NodeDefinition, NodeInstanceState, NodeResult, RunState, } from "./types.ts"; // --------------------------------------------------------------------------- // Code Node Handlers // --------------------------------------------------------------------------- /** * Registry of code node handlers. * Loop definitions reference handlers by name. */ const codeHandlers = new Map(); const roomHandlers = new Map(); export type CodeHandler = ( run: RunState, node: NodeDefinition, input: Record, services?: { pi: ExtensionAPI; ctx: ExtensionContext }, ) => Promise<{ artifacts: Record; summary: string; status?: "completed" | "failed" }>; export type RoomHandler = CodeHandler; /** * Register a code handler function. */ export function registerCodeHandler(name: string, handler: CodeHandler): void { codeHandlers.set(name, handler); } /** * Get a registered code handler. */ export function getCodeHandler(name: string): CodeHandler | undefined { return codeHandlers.get(name); } /** Register a room node handler. */ export function registerRoomHandler(name: string, handler: RoomHandler): void { roomHandlers.set(name, handler); } /** Get a registered room handler. */ export function getRoomHandler(name: string): RoomHandler | undefined { return roomHandlers.get(name); } /** * Execute a code node. */ export async function executeCodeNode( run: RunState, node: NodeDefinition, services?: { pi: ExtensionAPI; ctx: ExtensionContext }, ): Promise { if (!node.handler) { return { status: "failed", summary: `Code node ${node.id} has no handler defined`, }; } const handler = getCodeHandler(node.handler); if (!handler) { return { status: "failed", summary: `Code handler "${node.handler}" not found`, }; } try { const result = await handler(run, node, run.input ?? {}, services); return { status: result.status ?? "completed", summary: result.summary, artifacts: result.artifacts, }; } catch (err) { return { status: "failed", summary: `Code handler "${node.handler}" threw: ${err instanceof Error ? err.message : String(err)}`, }; } } // --------------------------------------------------------------------------- // Room Node Handlers // --------------------------------------------------------------------------- /** * Execute a room node. * A room is a first-class multi-worker node. The generic runtime owns lifecycle, * while the registered handler owns packet strategy, worker prompts, reduction, * and any domain-specific transfer/gate behavior. */ export async function executeRoomNode( run: RunState, node: NodeDefinition, services?: { pi: ExtensionAPI; ctx: ExtensionContext }, ): Promise { const handlerName = node.room?.handler ?? node.handler; if (!handlerName) { return { status: "failed", summary: `Room node ${node.id} has no room.handler defined` }; } const handler = getRoomHandler(handlerName) ?? getCodeHandler(handlerName); if (!handler) { return { status: "failed", summary: `Room handler "${handlerName}" not found` }; } try { const result = await handler(run, node, run.input ?? {}, services); return { status: result.status ?? "completed", summary: result.summary, artifacts: result.artifacts, }; } catch (err) { return { status: "failed", summary: `Room handler "${handlerName}" threw: ${err instanceof Error ? err.message : String(err)}`, }; } } // --------------------------------------------------------------------------- // Gate Node Execution // --------------------------------------------------------------------------- /** * Execute a gate node. * The gate runs deterministic validation over the run's evidence and artifacts. */ export async function executeGateNode( run: RunState, node: NodeDefinition, ): Promise { const criteria = node.successCriteria ?? []; const passedCriteria: string[] = []; const failedCriteria: string[] = []; for (const criterion of criteria) { const result = evaluateCriterion(criterion, run); if (result.passed) { passedCriteria.push(criterion); } else { failedCriteria.push(criterion); } } // Get the previous node's result for context const prevNode = run.nodes[run.currentNodeIndex]; const prevResult = prevNode?.result; if (failedCriteria.length === 0) { return { verdict: "pass", passedCriteria, details: { nodeId: node.id, criteriaCount: criteria.length }, }; } // Check if failures are correctable or terminal // For demo: if we have a result from a code node, it's usually correctable if (prevResult?.status === "completed" || criteria.length === 0) { return { verdict: "correctable", feedback: `Failed criteria: ${failedCriteria.join(", ")}`, failedCriteria, passedCriteria, }; } return { verdict: "terminal", feedback: `Terminal failures: ${failedCriteria.join(", ")}`, failedCriteria, passedCriteria, }; } /** * Evaluate a single criterion string against the run state. * Supports simple patterns: * - "X exists/contains/...": checks run.artifacts * - "X is non-empty": checks that a string/non-null value exists * - Custom: falls back to checking run state */ function getArtifactPath(artifacts: Record, path: string): unknown { return path.split(".").reduce((value, part) => { if (!value || typeof value !== "object") return undefined; const obj = value as Record; if (part in obj) return obj[part]; const actualKey = Object.keys(obj).find((key) => key.toLowerCase() === part.toLowerCase()); return actualKey ? obj[actualKey] : undefined; }, artifacts); } function hasArtifactKey(artifacts: Record, field: string): boolean { return Object.keys(artifacts ?? {}).some((key) => key.toLowerCase() === field.toLowerCase()); } function evaluateCriterion( criterion: string, run: RunState, ): { passed: boolean } { const lower = criterion.toLowerCase(); // Check for artifact-related criteria const artifactMatch = criterion.match(/artifact contains ['"]?(\w+)['"]?/i); if (artifactMatch) { const field = artifactMatch[1]; return { passed: hasArtifactKey(run.artifacts ?? {}, field) }; } // Check "X is non-empty" or "X is provided" const nonEmptyMatch = lower.match(/^(\w+) is (non-empty|provided)/); if (nonEmptyMatch) { const field = nonEmptyMatch[1]; const val = (run.artifacts as Record)?.[field]; return { passed: val !== undefined && val !== null && val !== "" }; } // Check "X exists" if (lower.includes("exists") || lower.includes("found")) { const wordMatch = lower.match(/(\w+) (exists|found)/); if (wordMatch) { const field = wordMatch[1]; return { passed: field in (run.artifacts ?? {}) }; } } // Check for evidence-based criteria if (lower.includes("no errors")) { return { passed: run.evidence.errorCount === 0 }; } // Generic artifact path criteria, e.g.: // - Artifact contains 'cleanContext' // - Artifact path 'testResults.allPassed' is true const artifactPathTrueMatch = criterion.match(/artifact path ['"]([^'"]+)['"] is true/i); if (artifactPathTrueMatch) { return { passed: getArtifactPath(run.artifacts, artifactPathTrueMatch[1]) === true }; } // Check for summary/confidence if (lower.includes("report summary") || lower.includes("summary exists")) { const prevNode = run.nodes[run.currentNodeIndex]; return { passed: !!prevNode?.result?.summary }; } if (lower.includes("confidence") || lower.includes("confidence level")) { const prevNode = run.nodes[run.currentNodeIndex]; return { passed: !!prevNode?.result?.confidence }; } // Default: if we have no way to evaluate, pass return { passed: true }; } // --------------------------------------------------------------------------- // LLM Node Execution // --------------------------------------------------------------------------- /** * Prepare the instruction for an LLM node. * Interpolates variables like {goal} into the instruction template. */ export function prepareLlmInstruction( node: NodeDefinition, run: RunState, ): string { let instruction = node.instruction ?? `Complete the goal: ${node.goal}`; // Interpolate common variables instruction = instruction.replace(/\{goal\}/g, node.goal); instruction = instruction.replace(/\{nodeId\}/g, node.id); if (run.input) { for (const [key, value] of Object.entries(run.input)) { instruction = instruction.replace( new RegExp(`\\{input\\.${key}\\}`, "g"), String(value), ); } } for (const [key, value] of Object.entries(run.artifacts ?? {})) { instruction = instruction.replace( new RegExp(`\\{artifact\\.${key}\\}`, "g"), typeof value === "string" ? value : JSON.stringify(value, null, 2), ); } // Do not append all artifacts automatically. // Loop definitions must explicitly interpolate only the artifacts an LLM node // needs, e.g. {artifact.currentPagePayload}. This prevents large parser // outputs, queues, prior reports, and tool noise from leaking into later // LLM nodes. return instruction; } /** * Build the tool list for an LLM node based on node policy. */ export function getToolsForNode(node: NodeDefinition): string[] { return node.allowedTools ?? ["read", "bash", "grep", "find", "ls", "loop_report"]; } // --------------------------------------------------------------------------- // Finalizer Node // --------------------------------------------------------------------------- /** * Execute the finalizer node: builds the final result summary. */ export function executeFinalizerNode(run: RunState): NodeResult { const completedNodes = run.nodes .filter((n) => n.status === "completed") .map((n) => ({ id: n.nodeDef.id, label: n.nodeDef.label, attempts: n.attempts.length, })); const summary = [ `Loop ${run.loopId} completed successfully.`, `Nodes completed: ${completedNodes.length}/${run.nodes.length}`, `Total turns: ${run.totalTurns}`, `Total tool calls: ${run.totalToolCalls}`, ]; if (run.evidence.filesChanged.length > 0) { summary.push(`Files changed: ${run.evidence.filesChanged.join(", ")}`); } return { status: "completed", summary: summary.join("\n"), artifacts: { ...run.artifacts, _completedNodes: completedNodes, _duration: Date.now() - run.startedAt, _evidence: { commandsRun: run.evidence.commandsRun.length, filesChanged: run.evidence.filesChanged, errorCount: run.evidence.errorCount, }, }, }; } // --------------------------------------------------------------------------- // Built-in Demo Code Handlers // --------------------------------------------------------------------------- /** * Register built-in demo handlers. */ export function registerDefaultCodeHandlers(): void { registerCodeHandler("prepareHandler", async (_run, _node, input) => { const message = (input.message as string) ?? "hello"; return { artifacts: { message, normalized: message.trim().toLowerCase(), length: message.length, timestamp: Date.now(), }, summary: `Prepared message: "${message}"`, }; }); }