/** * engine — supervisor analysis logic. * * Builds conversation snapshots from session history, * constructs prompts, and calls the supervisor model. * * System prompt discovery order (mirrors pi's SYSTEM.md convention): * 1. /.pi/SUPERVISOR.md — project-local * 2. ~/.pi/agent/SUPERVISOR.md — global * 3. Built-in template — fallback */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { ConversationMessage, SteeringDecision, SupervisorState } from "./types.js"; import { callSupervisorModel } from "./model-client.js"; import { buildSnapshotFromBranch } from "./snapshot.js"; // ---- System prompt loading ---- const SUPERVISOR_MD = "SUPERVISOR.md"; const CONFIG_DIR = ".pi"; const GLOBAL_AGENT_DIR = join(homedir(), ".pi", "agent"); /** Built-in fallback system prompt. */ const BUILTIN_SYSTEM_PROMPT = `You are a supervisor monitoring a coding AI assistant conversation. Your job: ensure the assistant fully achieves a specific outcome without needing the human to intervene. ═══ WHEN THE AGENT IS IDLE (finished its turn, waiting for user input) ═══ This is your most important moment. The agent has stopped and is waiting. You MUST choose "done" or "steer". Never return "continue" when the agent is idle. - "done" → only when the outcome is completely and verifiably achieved. - "steer" → everything else: incomplete work, partial progress, open questions, waiting for confirmation. If the agent asked a clarifying question or needs a decision: FIRST check: is this question necessary to achieve the goal? - YES (directly blocks goal progress): answer with a sensible default and tell agent to proceed. - NO (out of scope, nice-to-have, unrelated feature): do NOT answer it. Redirect: "That's outside the scope of the goal. Focus on: [restate the specific missing piece of the goal]." DO NOT answer: passwords, credentials, secrets, anything requiring real user knowledge. Your steer message speaks AS the user. Make it clear, direct, and actionable (1–3 sentences). Do not ask the agent to verify its own work — tell it what to do next. ═══ WHEN THE AGENT IS ACTIVELY WORKING (mid-turn) ═══ Only intervene if it is clearly heading in the wrong direction. Trust the agent to complete what it has started. Avoid interrupting productive work. ═══ STEERING RULES ═══ - Be specific: reference the outcome, missing pieces, or the question being answered. - Never repeat a steering message that had no effect — escalate or change approach. - A good steer answers the agent's question OR redirects to the missing piece of the outcome. - If the agent is taking shortcuts to satisfy the goal without properly achieving it, always steer and remind it not to take shortcuts. - Treat TOOL CALL and TOOL RESULT entries in the recent conversation as authoritative evidence. Do not claim a command was not run, or that output is missing, when a matching TOOL RESULT is present. - If recent context shows runtime/tool-protocol errors rather than agent negligence, avoid repeated command demands; if you must steer, ask for one focused retry instead. "done" CRITERIA: The core outcome is complete and functional. Minor polish, style tweaks, or optional improvements do NOT block "done". Prefer stopping when the goal is substantially achieved rather than looping forever chasing perfection. Respond ONLY with valid JSON — no prose, no markdown fences. Response schema (strict JSON): { "action": "continue" | "steer" | "done", "message": "...", // Required when action === "steer" "reasoning": "...", // Brief internal reasoning "confidence": 0.85 // Float 0-1 }`; /** * Load the supervisor system prompt. * Checks .pi/SUPERVISOR.md (project) then ~/.pi/agent/SUPERVISOR.md (global), * falling back to the built-in template if neither exists. * Returns both the prompt and its source path (or "built-in"). */ export function loadSystemPrompt(cwd: string): { prompt: string; source: string } { const projectPath = join(cwd, CONFIG_DIR, SUPERVISOR_MD); if (existsSync(projectPath)) { return { prompt: readFileSync(projectPath, "utf-8").trim(), source: projectPath }; } const globalPath = join(GLOBAL_AGENT_DIR, SUPERVISOR_MD); if (existsSync(globalPath)) { return { prompt: readFileSync(globalPath, "utf-8").trim(), source: globalPath }; } return { prompt: BUILTIN_SYSTEM_PROMPT, source: "built-in" }; } const MESSAGE_LIMITS: Record = { low: 6, medium: 12, high: 20, }; /** Extract the most recent compaction or branch summary from the session branch, if any. */ function extractCompactionSummary(ctx: ExtensionContext): string | null { let summary: string | null = null; for (const entry of ctx.sessionManager.getBranch()) { if ( (entry.type === "compaction" || entry.type === "branch_summary") && typeof (entry as any).summary === "string" ) { summary = (entry as any).summary; // keep overwriting — last one wins (most recent) } } return summary; } /** Extract recent messages, tool calls, and tool results from the session branch. */ function buildSnapshot(ctx: ExtensionContext, limit: number): ConversationMessage[] { return buildSnapshotFromBranch(ctx.sessionManager.getBranch(), limit); } function formatRole(role: ConversationMessage["role"]): string { if (role === "user") return "USER"; if (role === "assistant") return "ASSISTANT"; return "TOOL"; } /** Build the user-facing prompt for the supervisor LLM. */ function buildUserPrompt( state: SupervisorState, snapshot: ConversationMessage[], agentIsIdle: boolean, stagnating: boolean, compactionSummary: string | null ): string { const interventionHistory = state.interventions.length === 0 ? "None yet." : state.interventions .slice(-5) .map((iv, i) => `[${i + 1}] Turn ${iv.turnCount}: "${iv.message}"`) .join("\n"); const conversationText = snapshot.length === 0 ? "(No conversation yet)" : snapshot .map((m) => `${formatRole(m.role)}: ${m.content}`) .join("\n\n---\n\n"); const agentStatus = agentIsIdle ? `AGENT STATUS: IDLE — the agent has finished its turn and is now waiting for user input. You MUST return "done" or "steer". Returning "continue" here means the agent stays idle forever.` : `AGENT STATUS: WORKING — the agent is actively processing. Only intervene if clearly off track.`; const stagnationWarning = stagnating ? `\n⚠ STAGNATION: The supervisor has sent ${state.interventions.length} steering messages with no "done" verdict. The agent is making diminishing improvements. Apply a lenient standard: - If the core goal is substantially achieved (≥80%), return "done". - Only return "steer" if a CRITICAL piece is still missing — not minor polish. - Prefer stopping over looping forever on perfection.` : ""; const summarySection = compactionSummary ? `CONVERSATION SUMMARY (earlier history, before recent messages):\n${compactionSummary}\n\n` : ""; return `DESIRED OUTCOME: ${state.outcome} SENSITIVITY: ${state.sensitivity} (low = check only at end of each run, steer if seriously off track; medium = also check every 3rd tool cycle mid-run, steer on clear drift; high = check every tool cycle, steer proactively) ${agentStatus}${stagnationWarning} ${summarySection}RECENT CONVERSATION (last ${snapshot.length} messages/tool events): ${conversationText} PREVIOUS INTERVENTIONS BY YOU: ${interventionHistory} REMINDER — DESIRED OUTCOME: ${state.outcome} Has this outcome been fully achieved? Analyze and respond with JSON only.`; } /** * Analyze the current conversation and return a steering decision. * On analysis failures, return "continue" so we never inject blind fallback steering. */ export async function analyze( ctx: ExtensionContext, state: SupervisorState, agentIsIdle: boolean, stagnating: boolean, signal?: AbortSignal, onDelta?: (accumulated: string) => void ): Promise { const { prompt: systemPrompt } = loadSystemPrompt(ctx.cwd); const limit = MESSAGE_LIMITS[state.sensitivity] ?? 12; const snapshot = buildSnapshot(ctx, limit); const compactionSummary = extractCompactionSummary(ctx); const userPrompt = buildUserPrompt(state, snapshot, agentIsIdle, stagnating, compactionSummary); try { return await callSupervisorModel(ctx, state.provider, state.modelId, systemPrompt, userPrompt, signal, onDelta); } catch (err) { const detail = err instanceof Error ? err.message : String(err); return { action: "continue", reasoning: `Analysis error: ${detail}`, confidence: 0 }; } }