/** * Pi Loop Extension - Runtime Engine * * The core loop runtime that orchestrates node execution, transitions, * steering, evidence collection, and run finalization. * * The runtime is the central coordinator: * 1. Creates runs from loop definitions * 2. Executes nodes (code, gate, llm, finalizer) * 3. Evaluates gate results and performs transitions * 4. Manages retry policies and budgets * 5. Sends steering messages when rules trigger * 6. Saves/restores state via session entries */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { GateResult, LoopDefinition, LoopReport, NodeAttempt, NodeInstanceState, TokenUsageMetrics, RunState, SteeringEvent, } from "./types.ts"; import { checkEditsWithoutValidation, checkRepeatedCommandFailure, checkStall, recordToolCall, } from "./evidence.ts"; import { executeCodeNode, executeFinalizerNode, executeGateNode, executeRoomNode, getToolsForNode, prepareLlmInstruction, } from "./nodes.ts"; import { createRunState, formatRunStatus, getCurrentNode, saveRunState, updateRunState } from "./state.ts"; import { renderLoopWidget } from "./ui.ts"; export interface RuntimeServices { pi: ExtensionAPI; ctx: ExtensionContext; onStatusUpdate?: (status: string) => void; } /** * The Loop Runtime manages a single run's lifecycle. */ export class LoopRuntime { private services: RuntimeServices; private definition: LoopDefinition; private run: RunState; private currentAttempt: NodeAttempt | null = null; private activeLlmNode = false; private abortController: AbortController | null = null; private pendingReport: LoopReport | null = null; private onStateChange: ((run: RunState) => void) | null = null; private executionInProgress = false; private continueRequested = false; private widgetAnimationTimer: ReturnType | null = null; constructor( services: RuntimeServices, definition: LoopDefinition, existingRun?: RunState, input?: Record, ) { this.services = services; this.definition = definition; this.run = existingRun ?? this.initializeRun(input); this.onStateChange = null; } /** * Get the current run state. */ getRun(): RunState { return this.run; } /** * Get the loop definition. */ getDefinition(): LoopDefinition { return this.definition; } /** * Set a callback for state changes (for UI updates). */ onStateChangeCallback(cb: (run: RunState) => void): void { this.onStateChange = cb; } /** * Handle a loop_report from the agent. * Called by the tool handler in tools.ts. */ handleReport(report: LoopReport): void { const currentNode = getCurrentNode(this.run); const isCurrentLlmNode = currentNode?.nodeDef.kind === "llm" && currentNode.status === "running"; if (!this.activeLlmNode && !isCurrentLlmNode) { this.services.ctx.ui.notify("[Loop] loop_report received, but no running LLM node is active.", "warning"); return; } // Fill in the current run/node context report.runId = this.run.id; report.nodeId = currentNode?.nodeDef.id ?? ""; this.pendingReport = report; this.services.ctx.ui.notify(`[Loop] Report accepted for node: ${report.nodeId}`, "info"); // Be resilient if the original async continuation was interrupted by the // host turn lifecycle. If execution is already waiting on this report, this // only records a follow-up request and does not re-enter the loop. this.continueRequested = true; setTimeout(() => void this.continueExecution(), 0); } /** * Cancel the current run. */ cancel(): void { this.abortController?.abort(); this.activeLlmNode = false; updateRunState(this.run, { status: "cancelled" }); this.persistAndNotify(); } /** * Pause the current run. */ pause(): void { if (this.run.status !== "running") return; updateRunState(this.run, { status: "paused" }); this.persistAndNotify(); } /** * Resume a paused run. */ resume(): void { if (this.run.status === "paused") { updateRunState(this.run, { status: "running" }); this.persistAndNotify(); } if (this.run.status === "running") { this.continueExecution(); } } // --------------------------------------------------------------------------- // Private: Initialization // --------------------------------------------------------------------------- private initializeRun(input?: Record): RunState { const runId = generateRunId(); const run = createRunState(runId, this.definition.id, input); // Build node instances from the definition run.nodes = this.definition.nodes.map((nodeDef) => ({ nodeDef, status: "pending" as const, attempts: [], })); return run; } // --------------------------------------------------------------------------- // Public: Start / Continue Execution // --------------------------------------------------------------------------- /** * Start the run from the beginning. */ async start(): Promise { this.notifyStatus("starting"); this.persistAndNotify(); await this.continueExecution(); } /** * Continue execution from the current state. * Handles running the next node, transitions, etc. */ async continueExecution(): Promise { if (this.executionInProgress) { this.continueRequested = true; return; } this.executionInProgress = true; try { do { this.continueRequested = false; while (this.run.status === "running") { const node = getCurrentNode(this.run); if (!node) { updateRunState(this.run, { status: "failed" }); this.persistAndNotify(); return; } if (node.status === "completed") { // Move to next node, unless an explicit transition already moved us. const currentIndexBeforeTransition = this.run.currentNodeIndex; const nextIndex = currentIndexBeforeTransition + 1; if (nextIndex >= this.run.nodes.length) { // Loop complete updateRunState(this.run, { status: "completed" }); this.persistAndNotify(); return; } // Check transitions const transitionResult = await this.evaluateTransitions(); if (!transitionResult.shouldContinue) { return; // stopped by approval or failure } if (this.run.currentNodeIndex === currentIndexBeforeTransition) { updateRunState(this.run, { currentNodeIndex: nextIndex, currentAttemptNumber: 1 }); } this.persistAndNotify(); continue; } // Execute the current node node.status = "running"; node.startedAt = Date.now(); this.currentAttempt = { attemptNumber: this.run.currentAttemptNumber, startedAt: Date.now(), steeringEvents: [], toolCalls: [], status: "running", }; node.attempts.push(this.currentAttempt); this.persistAndNotify(); const usageBefore = this.getSessionUsageTotals(); await this.executeNode(node); const endedAt = Date.now(); if (this.currentAttempt) { this.currentAttempt.endedAt = endedAt; this.currentAttempt.metrics = { ...(this.currentAttempt.metrics ?? {}), durationMs: endedAt - this.currentAttempt.startedAt, }; if (node.nodeDef.kind === "llm") { this.currentAttempt.metrics.tokens = subtractUsage(this.getSessionUsageTotals(), usageBefore); } } // After execution, run gate for non-gate nodes if (node.nodeDef.kind !== "gate" && node.nodeDef.kind !== "finalizer") { const gateResult = await this.runGate(node); node.gateResult = gateResult; node.status = gateResult.verdict === "pass" ? "completed" : "failed"; if (this.currentAttempt) { this.currentAttempt.status = node.status; this.currentAttempt.endedAt = this.currentAttempt.endedAt ?? Date.now(); this.currentAttempt.metrics = { ...(this.currentAttempt.metrics ?? {}), durationMs: this.currentAttempt.endedAt - this.currentAttempt.startedAt, }; } this.persistAndNotify(); // Handle transition const shouldContinue = await this.handleGateTransition(gateResult, node); if (!shouldContinue) return; } else if (node.nodeDef.kind === "gate") { // Gate was already run - mark completed node.status = "completed"; this.persistAndNotify(); } else if (node.nodeDef.kind === "finalizer") { node.status = "completed"; updateRunState(this.run, { status: "completed", completedAt: Date.now() }); this.persistAndNotify(); return; } } } while (this.continueRequested && String(this.run.status) === "running"); } finally { this.executionInProgress = false; if (this.continueRequested && String(this.run.status) === "running") { setTimeout(() => void this.continueExecution(), 0); } } } // --------------------------------------------------------------------------- // Private: Node Execution // --------------------------------------------------------------------------- private async executeNode(state: NodeInstanceState): Promise { const kind = state.nodeDef.kind; switch (kind) { case "code": await this.executeCode(state); break; case "llm": await this.executeLlm(state); break; case "room": await this.executeRoom(state); break; case "gate": // Gates are handled inline in continueExecution break; case "finalizer": await this.executeFinalizer(state); break; case "approval": await this.executeApproval(state); break; default: state.result = { status: "failed", summary: `Unknown node kind: ${kind}`, }; } } private async executeCode(state: NodeInstanceState): Promise { const result = await executeCodeNode(this.run, state.nodeDef, this.services); state.result = result; if (result.artifacts) { Object.assign(this.run.artifacts, result.artifacts); } } private async executeRoom(state: NodeInstanceState): Promise { const result = await executeRoomNode(this.run, state.nodeDef, this.services); state.result = result; if (result.artifacts) { Object.assign(this.run.artifacts, result.artifacts); } } private async executeLlm(state: NodeInstanceState): Promise { this.activeLlmNode = true; this.pendingReport = null; const instruction = prepareLlmInstruction(state.nodeDef, this.run); const requestedTools = getToolsForNode(state.nodeDef); const availableTools = new Set(this.services.pi.getAllTools().map((t) => t.name)); const tools = requestedTools.filter((tool) => availableTools.has(tool)); this.firstEditSeen = false; this.preEditResearchToolCount = 0; // Scope tools for this node this.services.pi.setActiveTools(tools); if (state.nodeDef.model) { const model = this.services.ctx.modelRegistry.find(state.nodeDef.model.provider, state.nodeDef.model.id); if (model) { await this.services.pi.setModel(model); } else { this.services.ctx.ui.notify( `[Loop] Requested model not found: ${state.nodeDef.model.provider}/${state.nodeDef.model.id}`, "warning", ); } if (state.nodeDef.model.thinking) { this.services.pi.setThinkingLevel(state.nodeDef.model.thinking); } } // Apply steering rules (set up watchers) this.setupSteeringForNode(state); // Update status this.notifyStatus(`Running LLM node: ${state.nodeDef.label}`); // Send the instruction to the agent. The marker lets the context-cleaning // hook drop older node history while preserving this node's own tool calls // and follow-up turns. const markedInstruction = `[Pi Loop Node Context]\nRun: ${this.run.id}\nLoop: ${this.run.loopId}\nNode: ${state.nodeDef.id}\n\n${instruction}`; this.services.pi.sendUserMessage( [ { type: "text" as const, text: markedInstruction }, ], { deliverAs: "followUp" as const }, ); // We need to wait for the agent to complete. // The loop_report tool handler will set this.pendingReport. // We poll/wait in a loop until the agent finishes or report arrives. await this.waitForLlmCompletion(state); // Process the report const report = this.pendingReport as LoopReport | null; if (report) { state.result = { status: report.status === "failed" ? "failed" : "completed", summary: report.summary, artifacts: report.artifacts, changedFiles: report.changedFiles, commandsRun: report.commandsRun, risks: report.risks, confidence: report.confidence, }; if (report.artifacts) { Object.assign(this.run.artifacts, report.artifacts); } } else { // Agent finished without calling loop_report state.result = { status: "ready_for_validation", summary: "Agent completed work without submitting a formal report.", }; } this.activeLlmNode = false; this.cleanupSteering(); } private async executeFinalizer(state: NodeInstanceState): Promise { const result = executeFinalizerNode(this.run); state.result = result; } private async executeApproval(state: NodeInstanceState): Promise { const ctx = this.services.ctx; const choice = await ctx.ui.confirm( `Approval needed: ${state.nodeDef.goal}`, state.nodeDef.label, ); state.result = { status: choice ? "completed" : "failed", summary: choice ? "Approved" : "Denied", }; } // --------------------------------------------------------------------------- // Private: LLM Completion Waiting // --------------------------------------------------------------------------- /** * Wait for the LLM node to complete. * Polls for loop_report or timeout. Resolves when: * 1. pendingReport is set (loop_report was called by agent) * 2. The run was cancelled/paused * 3. Max timeout is reached */ private async waitForLlmCompletion(state: NodeInstanceState): Promise { const timeout = state.nodeDef.timeout ?? 300_000; // default 5 min this.abortController = new AbortController(); return new Promise((resolve) => { const timerId = setInterval(() => { if (this.pendingReport) { clearInterval(timerId); clearTimeout(failsafeTimer); resolve(); } else if (this.run.status !== "running") { clearInterval(timerId); clearTimeout(failsafeTimer); resolve(); } }, 300); const failsafeTimer = setTimeout(() => { clearInterval(timerId); resolve(); }, timeout); this.abortController?.signal.addEventListener("abort", () => { clearInterval(timerId); clearTimeout(failsafeTimer); resolve(); }); }); } // --------------------------------------------------------------------------- // Private: Gates and Transitions // --------------------------------------------------------------------------- private async runGate(state: NodeInstanceState): Promise { return executeGateNode(this.run, state.nodeDef); } private async evaluateTransitions(): Promise<{ shouldContinue: boolean }> { const currentNode = getCurrentNode(this.run); if (!currentNode?.gateResult) { return { shouldContinue: true }; } return { shouldContinue: await this.handleGateTransition(currentNode.gateResult, currentNode) }; } private async handleGateTransition( gateResult: GateResult, node: NodeInstanceState, ): Promise { const transitions = this.definition.transitions[this.run.currentNodeIndex]; const rule = transitions?.find((t) => t.fromGateVerdict === gateResult.verdict); if (!rule) { // No matching rule - default behaviors switch (gateResult.verdict) { case "pass": return true; // continue to next node case "correctable": return await this.handleRetry(node, gateResult.feedback); case "terminal": updateRunState(this.run, { status: "failed" }); this.persistAndNotify(); return false; default: updateRunState(this.run, { status: "failed" }); this.persistAndNotify(); return false; } } switch (rule.target.type) { case "node": { const target = rule.target; if (target.nodeId === "implement_fix_from_review") { const cycles = Number(this.run.artifacts.reviewFixCycles ?? 0); if (cycles >= 2) { updateRunState(this.run, { status: "needs_attention" }); this.services.ctx.ui.notify("Review fix cycle limit reached (2). Needs attention.", "warning"); this.persistAndNotify(); return false; } this.run.artifacts.reviewFixCycles = cycles + 1; } const targetIndex = this.definition.nodes.findIndex((n) => n.id === target.nodeId); if (targetIndex >= 0) { this.resetNodeForFreshExecution(targetIndex); updateRunState(this.run, { currentNodeIndex: targetIndex, currentAttemptNumber: 1, }); return true; } return true; // fallback: continue normally } case "retry": return await this.handleRetry(node, rule.feedback ?? gateResult.feedback); case "approval": { const approved = await this.services.ctx.ui.confirm( "Loop requires approval", gateResult.feedback ?? "Continue?", ); return approved; } case "evaluator": // For now, treat evaluator as pass-through return true; case "finalizer": { const finalIndex = this.definition.nodes.findIndex((n) => n.kind === "finalizer"); if (finalIndex >= 0) { updateRunState(this.run, { currentNodeIndex: finalIndex }); return true; } return true; } case "fail": updateRunState(this.run, { status: "failed" }); this.persistAndNotify(); return false; case "complete": updateRunState(this.run, { status: "completed" }); this.persistAndNotify(); return false; } } private async handleRetry( node: NodeInstanceState, feedback?: string, ): Promise { const policy = node.nodeDef.retryPolicy; const maxAttempts = policy?.maxAttempts ?? 3; if (this.run.currentAttemptNumber < maxAttempts) { // Increment attempt updateRunState(this.run, { currentAttemptNumber: this.run.currentAttemptNumber + 1, }); // Send steering with feedback if (feedback) { // Merge evidence from this attempt into the run this.run.evidence; // Send steering message to the agent this.services.pi.sendMessage( { customType: "loop-steering", content: `[Loop Steering - Attempt ${this.run.currentAttemptNumber}/${maxAttempts}]\n${feedback}\n\nPlease address the feedback and try again.`, display: true, }, { triggerTurn: false }, ); } return true; // continue (will retry the node) } // Exhausted retries const onExhausted = policy?.onExhausted ?? "fail"; switch (onExhausted) { case "approval": return await this.services.ctx.ui.confirm( "Retries exhausted. Continue anyway?", `Node ${node.nodeDef.label} failed after ${maxAttempts} attempts`, ); case "needs_attention": updateRunState(this.run, { status: "needs_attention" }); this.persistAndNotify(); return false; default: updateRunState(this.run, { status: "failed" }); this.persistAndNotify(); return false; } } // --------------------------------------------------------------------------- // Private: Steering // --------------------------------------------------------------------------- private steeringCheckInterval: ReturnType | null = null; private setupSteeringForNode(state: NodeInstanceState): void { const rules = state.nodeDef.steeringRules ?? []; if (rules.length === 0) { // Set up default steering watchers this.steeringCheckInterval = setInterval(() => { this.checkDefaultSteering(); }, 5_000); } else { this.steeringCheckInterval = setInterval(() => { for (const rule of rules) { this.checkSteeringRule(rule); } }, 5_000); } } private cleanupSteering(): void { if (this.steeringCheckInterval) { clearInterval(this.steeringCheckInterval); this.steeringCheckInterval = null; } } private checkDefaultSteering(): void { if (!this.activeLlmNode) return; // Check for repeated command failures const repeatResult = checkRepeatedCommandFailure(this.run.evidence); if (repeatResult.triggered) { this.sendSteerMessage( `You have run the same command repeatedly (${repeatResult.count}x): "${repeatResult.command}". Stop repeating it, identify the root cause, and choose a different approach.`, "repeated_command_failure", ); return; } // Check for edits without validation const editsResult = checkEditsWithoutValidation(this.run.evidence); if (editsResult.triggered) { this.sendSteerMessage( "You have made several code changes without running any validation commands. Run the required validation or explain why it's not applicable.", "too_many_edits_no_validation", ); return; } // Check for stalls const stallResult = checkStall(this.run.evidence, 120_000); if (stallResult.triggered) { this.sendSteerMessage( "The loop node appears stalled. Make progress on the assigned task or call loop_report if you are ready for validation.", "stalled", ); } } private checkSteeringRule(rule: { trigger: { type: string; maxCount?: number; idleMs?: number }; message: string; }): void { if (!this.activeLlmNode) return; switch (rule.trigger.type) { case "repeated_command_failure": { const result = checkRepeatedCommandFailure(this.run.evidence, rule.trigger.maxCount ?? 3); if (result.triggered) this.sendSteerMessage(rule.message, rule.trigger.type); break; } case "stalled": { const result = checkStall(this.run.evidence, rule.trigger.idleMs ?? 60_000); if (result.triggered) this.sendSteerMessage(rule.message, rule.trigger.type); break; } case "too_many_edits_no_validation": { const result = checkEditsWithoutValidation(this.run.evidence); if (result.triggered) this.sendSteerMessage(rule.message, rule.trigger.type); break; } } } private sentSteerMessages = new Set(); private firstEditSeen = false; private preEditResearchToolCount = 0; private sendSteerMessage(message: string, triggerType: string): void { // Avoid sending the same steer message repeatedly const key = `${triggerType}:${message.slice(0, 50)}`; if (this.sentSteerMessages.has(key)) return; this.sentSteerMessages.add(key); // Record steering event const steerEvent: SteeringEvent = { timestamp: Date.now(), message, triggerType, }; if (this.currentAttempt) { this.currentAttempt.steeringEvents.push(steerEvent); } this.run.steeringEventsTotal++; this.run.evidence.warnings.push(`[Steer:${triggerType}] ${message}`); // Send steering message to the agent this.services.pi.sendMessage( { customType: "loop-steering", content: `[Loop Steering] ${message}`, display: true, }, { deliverAs: "steer" }, ); this.persistAndNotify(); } // --------------------------------------------------------------------------- // Private: Evidence Collection // --------------------------------------------------------------------------- /** * Record a tool call in the run's evidence. */ recordToolCallEvidence( toolName: string, input: unknown, isError?: boolean, ): void { recordToolCall(this.run.evidence, toolName, input, isError); this.run.totalToolCalls++; if (toolName === "edit" || toolName === "write") { this.firstEditSeen = true; } if (!this.firstEditSeen && ["read", "grep", "find", "ls", "bash"].includes(toolName)) { this.preEditResearchToolCount++; const current = getCurrentNode(this.run); if (current?.nodeDef.id === "implement" && this.preEditResearchToolCount === 20) { this.sendSteerMessage( "Research was already completed. Stop spending time researching and start implementing unless you have a concrete blocker.", "implementation_over_research", ); } } if (this.currentAttempt) { this.currentAttempt.toolCalls.push({ toolName, input, timestamp: Date.now(), isError, }); } } enforceToolPolicy(toolName: string, input: unknown): { block: true; reason: string } | undefined { const current = getCurrentNode(this.run); const node = current?.nodeDef; if (!node || !this.activeLlmNode) return undefined; if (node.allowedTools && !node.allowedTools.includes(toolName)) { return { block: true, reason: `Loop node ${node.id} does not allow tool: ${toolName}` }; } if (node.prohibitedActions?.includes("no_write") && (toolName === "edit" || toolName === "write")) { return { block: true, reason: `Loop node ${node.id} is read-only.` }; } if (node.prohibitedActions?.includes("bash_read_only") && toolName === "bash") { const command = typeof input === "object" && input !== null ? String((input as { command?: unknown }).command ?? "") : ""; const destructive = /\b(rm|mv|cp|chmod|chown|git\s+(add|commit|push|checkout|merge|rebase|reset)|npm\s+(install|ci)|pnpm\s+(install|add)|yarn\s+(install|add)|sudo)\b|>|>>/i; if (destructive.test(command)) { return { block: true, reason: `Loop node ${node.id} allows only read-only bash commands.` }; } } return undefined; } shouldCleanContextForActiveNode(): boolean { const current = getCurrentNode(this.run); if (!current || !this.activeLlmNode) return false; return ["implement", "review_quality", "implement_fix_from_review"].includes(current.nodeDef.id); } private resetNodeForFreshExecution(index: number): void { const node = this.run.nodes[index]; if (!node || node.status === "pending") return; node.status = "pending"; node.result = undefined; node.gateResult = undefined; node.startedAt = undefined; node.completedAt = undefined; } private getSessionUsageTotals(): TokenUsageMetrics { const totals: TokenUsageMetrics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 }; for (const entry of this.services.ctx.sessionManager.getEntries()) { if (entry.type !== "message") continue; const message: any = entry.message; if (message?.role !== "assistant" || !message.usage) continue; totals.input += Number(message.usage.input ?? 0); totals.output += Number(message.usage.output ?? 0); totals.cacheRead += Number(message.usage.cacheRead ?? 0); totals.cacheWrite += Number(message.usage.cacheWrite ?? 0); totals.totalTokens += Number(message.usage.totalTokens ?? 0); totals.cost += Number(message.usage.cost?.total ?? 0); } return totals; } // --------------------------------------------------------------------------- // Private: Persistence & Notifications // --------------------------------------------------------------------------- private persistAndNotify(): void { saveRunState(this.services.pi, this.run); this.onStateChange?.(this.run); const statusLine = formatRunStatus(this.run); this.services.ctx.ui.setStatus("pi-loop", statusLine); this.services.ctx.ui.setWidget("pi-loop", renderLoopWidget(this.run)); this.syncWidgetAnimationTimer(); } private syncWidgetAnimationTimer(): void { const shouldAnimate = this.run.status === "running" && this.run.nodes.some((node) => node.status === "running"); if (shouldAnimate && !this.widgetAnimationTimer) { this.widgetAnimationTimer = setInterval(() => { if (this.run.status !== "running" || !this.run.nodes.some((node) => node.status === "running")) { this.syncWidgetAnimationTimer(); return; } this.services.ctx.ui.setWidget("pi-loop", renderLoopWidget(this.run)); }, 350); } else if (!shouldAnimate && this.widgetAnimationTimer) { clearInterval(this.widgetAnimationTimer); this.widgetAnimationTimer = null; } } private notifyStatus(message: string): void { this.services.ctx.ui.notify(`[Loop] ${message}`, "info"); this.services.onStatusUpdate?.(message); } } // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- let counter = 0; function generateRunId(): string { counter++; const ts = Date.now().toString(36); const rand = Math.random().toString(36).slice(2, 6); return `run_${ts}_${rand}_${counter}`; } function subtractUsage(after: TokenUsageMetrics, before: TokenUsageMetrics): TokenUsageMetrics { return { input: Math.max(0, after.input - before.input), output: Math.max(0, after.output - before.output), cacheRead: Math.max(0, after.cacheRead - before.cacheRead), cacheWrite: Math.max(0, after.cacheWrite - before.cacheWrite), totalTokens: Math.max(0, after.totalTokens - before.totalTokens), cost: Math.max(0, after.cost - before.cost), }; }