import { randomUUID } from "node:crypto"; import type { Message } from "@earendil-works/pi-ai/compat"; import { type AgentToolResult, type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, type ToolCallEvent, type ToolCallEventResult, type ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; import { Box, Markdown, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { advisorAutoLoopGateRef, advisorBlockOnBlockedRef, advisorCollapseResponsesRef, advisorCompletionGateRef, advisorCustomInvocationRef, advisorEffortRef, advisorFailureGateRef, advisorFailureModeRef, advisorGitContextMaxCharsRef, advisorGitContextRef, advisorLoopThresholdRef, advisorMaxCallsPerSessionRef, advisorOutcomeLoggingRef, advisorPlanGateRef, advisorRedactSecretsRef, advisorRef, advisorScoutEnabledRef, advisorSessionSummaryRef, advisorTrackedFileContentRef, advisorUntrackedContentRef, contextMaxCharsRef, executorRef, isSimpleMode, loadConfig, } from "./config.js"; import { recentConversation, redactAndCapText, redactSecrets, textFrom, } from "./conversation.js"; import { capRepositoryContext, clampGitContextLevel, collectGitContext, escapeRepositoryText, type GitContextLevel, type GitContextResult, } from "./git.js"; import { herdrAdvisorActivity, herdrAdvisorBlock, notifyHerdrAdvisorFailure, } from "./herdr.js"; import { collectTextStream, resolveConfiguredModel } from "./model-stream.js"; import { ADOPTIONS, appendOutcome, VALIDATIONS } from "./outcomes.js"; import { readProjectPreferences } from "./preferences.js"; import { runAdvisorScout, type ScoutLifecycleEvent, type ScoutOutcome, } from "./scout.js"; import { buildScoutManifest, reconstructScoutConversation, } from "./scout-context.js"; import { AdvisorSessionState, type ConsultationTrigger, type GateDecision, type GateTrigger, } from "./session-state.js"; import type { BenchmarkTelemetry } from "./telemetry.js"; import { readTrackedFiles, readUntrackedFiles } from "./untracked.js"; export type { AdvisorInvocationRecord, ConsultationTrigger, GateDecision, GateTrigger, } from "./session-state.js"; export const advisorSessionState = new AdvisorSessionState(); export const SPINNER_FRAMES = [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ]; export const resolveAdvisorRequest = (question?: string) => question?.trim() || undefined; export const advisorMessageText = ( conversation: string, question?: string, changes?: string, draft?: string, preferences?: string, untracked?: string[], tracked?: string[] ) => { // Every interpolated region except `changes` is raw untrusted text. Repository // changes are escaped at collection time so their existing byte budget remains exact. const safeConversation = escapeRepositoryText(conversation); const safeDraft = draft ? escapeRepositoryText(draft) : undefined; const safePreferences = preferences ? escapeRepositoryText(preferences) : undefined; const safeUntracked = (untracked ?? []).map(escapeRepositoryText); const safeTracked = (tracked ?? []).map(escapeRepositoryText); const text = `${safeConversation ? `\n${safeConversation}\n` : ""}${ changes ? // Repository content is untrusted data, not instructions to the Advisor. `\n\n\n${changes}\n` : "" }${safeUntracked.length ? `\n\n\n${safeUntracked.join("\n\n")}\n` : ""}${safeTracked.length ? `\n\n\n${safeTracked.join("\n\n")}\n` : ""}${safePreferences ? `\n\n\n${safePreferences}\n` : ""}${safeDraft ? `\n\n\n${safeDraft}\n` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`; // A zero context limit with no targeted focus would otherwise send an empty // user message, which several providers reject outright. return ( text.trim() || "No conversation context is available. State that you cannot review without context." ); }; /** * Splits the character budget so repository context can never starve the * conversation: it may claim its own cap or half the budget, whichever is less. */ export const advisorGitContextBudget = ( contextMaxChars: number, gitContextMaxChars: number ) => Math.min(gitContextMaxChars, Math.floor(contextMaxChars / 2)); /** Explains a withheld or empty repository context to the Advisor. */ export const gitContextNote = ( result: GitContextResult, requested: GitContextLevel, allowed: GitContextLevel ): string | undefined => { if (requested !== allowed && LEVEL_WITHHELD[result.status]) { return `Repository context was limited to "${allowed}" by user configuration; a fuller view was requested but withheld.`; } switch (result.status) { case "disabled": return "Repository context was disabled or had no disclosure budget; it was withheld. Do not assume the working tree is clean."; case "no-changes": return "The working tree has no uncommitted changes."; case "not-a-repository": return "No Git repository is available for this session."; case "failed": return "Repository context could not be collected. Do not assume the working tree is clean."; default: return; } }; const LEVEL_WITHHELD: Record = { collected: true, "no-changes": false, }; export const advisorRepositoryContext = ( result: GitContextResult, requested: GitContextLevel, allowed: GitContextLevel, budget: number ) => { const note = gitContextNote(result, requested, allowed); const payload = capRepositoryContext( escapeRepositoryText(result.text), budget ).text; return [note, payload].filter(Boolean).join("\n\n"); }; /** * The conversation boundary for outgoing Advisor requests. Repository context is * the only other egress path; both are assembled by advisorMessageText and both * apply the same redaction. */ export const advisorRequestConversation = ( ctx: ExtensionContext, maxChars = contextMaxCharsRef ) => recentConversation(ctx, maxChars); export const renderAdvisorCallBox = ( question: string | undefined, theme: Theme ) => { const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); const label = theme.fg("customMessageLabel", theme.bold("[advisor]")); const title = theme.fg("customMessageText", "Executor → Advisor"); box.addChild( new Text( question ? `${label} ${title}\n${theme.fg("dim", ` ${question}`)}` : `${label} ${title}`, 0, 0 ) ); return box; }; const COLLAPSED_ADVICE_LINES = 12; // The system prompt requires this exact first line, so the match is exact too. const SOUND_VERDICT = /^Verdict:\s*sound$/; export const hasSoundVerdict = (advice: string) => SOUND_VERDICT.test( (advice.split("\n").find((line) => line.trim()) ?? "").trim() ); /** The single Advisor response header shared by tool and manual renderers. */ export const renderAdvisorResponseHeader = (sound: boolean, theme: Theme) => sound ? theme.fg("accent", theme.bold("◆ ADVISOR · SOUND")) : theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE")); export const adviceForDisplay = (advice: string, expanded: boolean) => { if (!advisorCollapseResponsesRef || expanded) { return advice; } const lines = advice.split("\n"); if (lines.length <= COLLAPSED_ADVICE_LINES) { return advice; } return `${lines.slice(0, COLLAPSED_ADVICE_LINES).join("\n")}\n\n… (${lines.length - COLLAPSED_ADVICE_LINES} more lines, Ctrl+O to expand)`; }; export const advisorInvocationGuidelines = () => { if (isSimpleMode()) { return [ "When uncertain and normal available tools cannot resolve it, call ask_advisor for a second opinion.", ]; } const guidelines: string[] = []; if (advisorPlanGateRef) { guidelines.push( "Before committing to a materially consequential plan, use ask_advisor with a concise draft after investigating and forming your own candidate direction. The draft must name proposed work, validation, and remaining risks. A draft claim is not verification evidence." ); } if (advisorFailureGateRef) { guidelines.push( "Use ask_advisor after two consecutive materially equivalent failed attempts, when a fix recreates an earlier failure, or after two actions produce no measurable progress. Do not make another materially equivalent attempt before consulting." ); } if (advisorCompletionGateRef) { guidelines.push( "Before declaring success, use ask_advisor with a concise draft naming changed work, validation, and remaining risks. A draft claim is not verification evidence. Skip this only for demonstrably trivial, low-risk work." ); } if (advisorCustomInvocationRef) { guidelines.push(`Also use ask_advisor when: ${advisorCustomInvocationRef}`); } if (guidelines.length > 0) { guidelines.push( "Call ask_advisor with an empty object by default. Do not invent a question merely to request a review: the Advisor already receives context. Include question only for a genuinely specific assumption or trade-off." ); } return guidelines; }; export const ADVISOR_SYSTEM = [ "You are the Advisor: a senior engineer giving a brief second opinion to an autonomous coding agent.", "You already have the relevant reconstructed conversation context. No question or other input from the Executor is needed for a general review.", "When no targeted focus is supplied, proactively review the task, risks, proposed direction, and validation from the context. Do not ask the Executor for a question, clarification, more input, or confirmation.", "The context may be truncated, so state any material uncertainty and make the best recommendation you can from what is present.", "A supplied draft is an unverified Executor claim, not evidence. Critique it concretely and never treat claimed changes or passing tests as independently verified.", "When the implementation is fully sound based on the supplied evidence and you have no material concern or recommended change, begin with exactly `Verdict: sound`. Do not use that verdict when uncertainty, a risk, or a recommendation remains.", "You do not act or take over planning. Answer the Executor's request directly in concise, human-readable Markdown. State uncertainty plainly and never claim verification that the supplied evidence does not show.", ].join(" "); export const ADVISOR_DECISION_SYSTEM = [ "You are the Advisor's automatic safety gate for a repeated-tool loop.", "Review the supplied context and decide whether the Executor may proceed.", "Answer in concise Markdown. Your first non-empty line must be exactly `Decision: proceed`, `Decision: revise`, or `Decision: blocked`.", "Use blocked only for a critical issue requiring the user. Never claim verification that the supplied evidence does not show.", ].join(" "); export type GateFailureCategory = | "provider-error" | "empty-response" | "missing-decision" | "malformed-decision" | "duplicate-decision" | "contradictory-decision" | "budget-exhausted"; export interface AdvisorGateFailure { category: GateFailureCategory; markdown?: string; message: string; ok: false; } export interface AdvisorConsultationResult { adviceId: string; draftBytes?: number; markdown: string; model: string; preferenceBytes?: number; scout?: Exclude; thinkingText: string; trackedBytes?: number; trigger: ConsultationTrigger; untrackedBytes?: number; usage?: unknown; } export interface AdvisorGateResult { decision: GateDecision; markdown: string; model: string; ok: true; thinkingText: string; trigger: GateTrigger; usage?: unknown; } export type AdvisorGateOutcome = AdvisorGateResult | AdvisorGateFailure; export const advisorUsageCost = (usage: unknown): number | undefined => { const value = usage as | { cost?: { total?: unknown }; totalCost?: unknown } | undefined; const cost = value?.cost?.total ?? value?.totalCost; return typeof cost === "number" ? cost : undefined; }; const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i; const CODE_FENCE = /^(?:```|~~~)/; const LINE_BREAK = /\r?\n/; export const parseAutomaticDecision = ( text: string ): AdvisorGateResult | AdvisorGateFailure => { const lines = text.split(LINE_BREAK); const nonEmpty = lines.findIndex((line) => line.trim().length > 0); if (nonEmpty === -1) { return { category: "empty-response", message: "Advisor returned an empty gate response.", ok: false, }; } const first = lines[nonEmpty].trim(); const match = DECISION_LINE.exec(first); if (!match) { return { category: first.toLowerCase().startsWith("decision:") ? "malformed-decision" : "missing-decision", markdown: text, message: "Advisor gate response must begin with Decision: proceed, Decision: revise, or Decision: blocked.", ok: false, }; } const decision = match[1].toLowerCase() as GateDecision; let insideFence = false; const decisions: string[] = []; let pendingFencedDecisions: string[] = []; for (const line of lines.slice(nonEmpty + 1)) { const trimmed = line.trim(); // Decisions in a balanced fenced example are illustrative. If the fence is // malformed and never closes, retain its decisions so malformed Markdown // cannot hide a blocked verdict and make the gate fail open. if (CODE_FENCE.test(trimmed)) { insideFence = !insideFence; if (!insideFence) { pendingFencedDecisions = []; } continue; } const subsequent = DECISION_LINE.exec(trimmed); if (!subsequent) { continue; } const repeated = subsequent[1].trim().toLowerCase(); if (insideFence) { pendingFencedDecisions.push(repeated); } else { decisions.push(repeated); } } if (insideFence) { decisions.push(...pendingFencedDecisions); } for (const repeated of decisions) { if (repeated === decision) { return { category: "duplicate-decision", markdown: text, message: "Advisor gate response contains duplicate decision lines.", ok: false, }; } return { category: "contradictory-decision", markdown: text, message: "Advisor gate response contains contradictory decision lines.", ok: false, }; } return { decision, markdown: text, model: "", ok: true, thinkingText: "", trigger: "repeated-tool-call", }; }; const adviceForText = (result: AdvisorGateResult) => `**Decision: ${result.decision}**\n\n${result.markdown}`; export const curateAdvisorConversation = async ( ctx: ExtensionContext, legacyConversation: string, signal?: AbortSignal, onScout?: (event: ScoutLifecycleEvent) => void, enabled = advisorScoutEnabledRef, runScout: typeof runAdvisorScout = runAdvisorScout, currentInvocationId?: string, maxChars?: number, telemetry?: BenchmarkTelemetry ): Promise<{ conversation: string; scout?: Exclude; }> => { if (!enabled) { return { conversation: legacyConversation }; } if (maxChars !== undefined && maxChars <= 0) { return { conversation: "" }; } const built = buildScoutManifest(ctx, { currentInvocationId, maxBytes: maxChars, }); if (!built.ok) { const scout: Exclude = { category: built.reason, message: built.message, metrics: { availableCount: 0, inputBytes: 0, latencyMs: 0, omittedBeforeScout: 0, selectedCount: 0, }, model: executorRef, ok: false, }; onScout?.({ outcome: scout, type: "fallback" }); return { conversation: legacyConversation, scout }; } const outcome = await runScout( ctx, built.manifest, signal, onScout, undefined, undefined, telemetry ); if (!outcome.ok && outcome.cancelled) { throw signal?.reason instanceof Error ? signal.reason : new Error("Advisor operation cancelled during Scout."); } let conversation = legacyConversation; if (outcome.ok) { conversation = maxChars === undefined ? outcome.conversation : reconstructScoutConversation( built.manifest, outcome.selection.selectedIds, outcome.selection.synthesis, maxChars ); } return { conversation, scout: outcome }; }; const collectAdvisorResponse = async ( ctx: ExtensionContext, systemPrompt: string, question: string | undefined, signal: AbortSignal | undefined, onChunk: ((thinking: string, text: string) => void) | undefined, gitContext?: GitContextLevel, draft?: string, includeUntracked?: string[], includeTracked?: string[], onScout?: (event: ScoutLifecycleEvent) => void, currentInvocationId?: string, telemetry?: BenchmarkTelemetry ) => { loadConfig(ctx); const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor"); // The user setting is the ceiling; the Executor may only narrow it. const allowed = advisorGitContextRef; const level = clampGitContextLevel(gitContext ?? allowed, allowed); const gitBudget = advisorGitContextBudget( contextMaxCharsRef, advisorGitContextMaxCharsRef ); const changes = collectGitContext( ctx.cwd, level, gitBudget, advisorRedactSecretsRef ? redactSecrets : undefined ); // The note is placed first so a cap can never drop the statement that the // Advisor's view of the repository is limited. // The disclosure warning is control metadata, not repository payload. Keep it // outside the zero-byte Git budget so disabling disclosure cannot erase it. const changeText = advisorRepositoryContext( changes, gitContext ?? allowed, level, gitBudget ); // Repository context spends part of the shared budget, so a large patch // cannot silently push the conversation past the model's context window. const conversationBudget = Math.max( 0, contextMaxCharsRef - changeText.length ); const legacyConversation = advisorRequestConversation( ctx, conversationBudget ); const curated = await curateAdvisorConversation( ctx, legacyConversation, signal, onScout, advisorScoutEnabledRef, runAdvisorScout, currentInvocationId, conversationBudget, telemetry ); const { conversation, scout } = curated; const preferences = await readProjectPreferences( ctx, 8 * 1024, advisorRedactSecretsRef ); const draftText = draft ? redactAndCapText(draft, 8 * 1024, advisorRedactSecretsRef) : undefined; const untracked = await readUntrackedFiles( ctx.cwd, includeUntracked ?? [], advisorUntrackedContentRef, advisorRedactSecretsRef ); const tracked = await readTrackedFiles( ctx.cwd, includeTracked ?? [], advisorTrackedFileContentRef, advisorRedactSecretsRef, Math.max( 0, 24 * 1024 - untracked.reduce((sum, item) => sum + item.bytes, 0) ) ); const messages: Message[] = [ { content: [ { text: advisorMessageText( conversation, question, changeText, draftText, preferences?.text, untracked.map( (item) => `\n${item.text}\n` ), tracked.map( (item) => `\n${item.text}\n` ) ), type: "text", }, ], role: "user", timestamp: Date.now(), }, ]; const streamed = await collectTextStream(resolved, { messages, onChunk, reasoning: advisorEffortRef, signal, systemPrompt, }); const markdown = streamed.text; if (!markdown.trim()) { throw new Error("Advisor returned no advice."); } return { draftBytes: draftText ? Buffer.byteLength(draftText, "utf8") : undefined, markdown, model: advisorRef, preferenceBytes: preferences?.bytes, thinkingText: streamed.thinking, trackedBytes: tracked.reduce((sum, item) => sum + item.bytes, 0) || undefined, untrackedBytes: untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined, usage: streamed.usage, ...(scout ? { scout } : {}), }; }; export const consultAdvisor = async ( ctx: ExtensionContext, question?: string, signal?: AbortSignal, onChunk?: (thinking: string, text: string) => void, trigger: ConsultationTrigger = "executor-requested", gitContext?: GitContextLevel, draft?: string, includeUntracked?: string[], includeTracked?: string[], onScout?: (event: ScoutLifecycleEvent) => void, currentInvocationId?: string, telemetry?: BenchmarkTelemetry ): Promise => { telemetry?.advisorStart({ model: advisorRef, question, trigger }); try { const result = await collectAdvisorResponse( ctx, ADVISOR_SYSTEM, question, signal, onChunk, gitContext, draft, includeUntracked, includeTracked, onScout, currentInvocationId, telemetry ); telemetry?.advisorEnd({ model: result.model, outcome: "completed", question, response: result.markdown, trigger, usage: result.usage, }); return { ...result, adviceId: randomUUID(), trigger }; } catch (error) { telemetry?.advisorError({ category: signal?.aborted ? "cancelled" : "provider-error", model: advisorRef, question, trigger, }); throw error; } }; export const runAdvisorGate = async ( ctx: ExtensionContext, question: string, trigger: GateTrigger = "repeated-tool-call", signal?: AbortSignal, onChunk?: (thinking: string, text: string) => void, onScout?: (event: ScoutLifecycleEvent) => void, currentInvocationId?: string, telemetry?: BenchmarkTelemetry ): Promise => { telemetry?.advisorStart({ model: advisorRef, question, trigger }); try { const result = await collectAdvisorResponse( ctx, ADVISOR_DECISION_SYSTEM, question, signal, onChunk, undefined, undefined, undefined, undefined, onScout, currentInvocationId, telemetry ); const parsed = parseAutomaticDecision(result.markdown); telemetry?.advisorEnd({ model: result.model, outcome: parsed.ok ? `decision:${parsed.decision}` : parsed.category, question, response: result.markdown, trigger, usage: result.usage, }); if (!parsed.ok) { return parsed; } return { ...parsed, model: result.model, thinkingText: result.thinkingText, trigger, usage: result.usage, }; } catch (error) { telemetry?.advisorError({ category: signal?.aborted ? "cancelled" : "provider-error", model: advisorRef, question, trigger, }); if (signal?.aborted) { throw error; } const message = error instanceof Error ? error.message : String(error); return { category: message === "Advisor returned no advice." ? "empty-response" : "provider-error", message, ok: false, }; } }; const notifyLocalFailure = ( ctx: ExtensionContext, message: string, sessionBlocked = false ) => { if (ctx.hasUI) { ctx.ui.notify( `Advisor ${sessionBlocked ? "gate failure; session blocked" : "consultation failed"}: ${message}`, "error" ); } }; export const gateFailureEffectForMode = ( mode: "block-session" | "block-tool" | "warn-and-continue" ) => { if (mode === "warn-and-continue") { return "continued" as const; } return mode === "block-tool" ? ("tool-blocked" as const) : ("session-blocked" as const); }; const gateDecisionEffect = (decision: GateDecision) => { if (decision === "proceed") { return "continued" as const; } return decision === "blocked" ? ("session-blocked" as const) : ("tool-blocked" as const); }; const failureEffect = ( category: GateFailureCategory, message: string, ctx: ExtensionContext, session: AdvisorSessionState ) => { const reason = `Advisor gate ${category}: ${message}`; notifyLocalFailure(ctx, message, advisorFailureModeRef === "block-session"); notifyHerdrAdvisorFailure("Advisor gate failure", reason); if (advisorFailureModeRef === "warn-and-continue") { return { block: false, effect: "continued" as const, reason }; } if (advisorFailureModeRef === "block-tool") { return { block: true, effect: "tool-blocked" as const, reason }; } session.block(reason); herdrAdvisorBlock.set(reason); if (advisorBlockOnBlockedRef) { ctx.abort(); } return { block: true, effect: "session-blocked" as const, reason }; }; const reserveAdvisorCall = ( event: ToolCallEvent, ctx: ExtensionContext, session: AdvisorSessionState, reservedCalls: Set ): ToolCallEventResult | undefined => { if (event.toolName !== "ask_advisor" || isSimpleMode()) { return; } if (!session.canConsult(advisorMaxCallsPerSessionRef)) { const message = "Advisor call budget exhausted for this session."; if (ctx.hasUI) { ctx.ui.notify(message, "warning"); } notifyHerdrAdvisorFailure("Advisor budget exhausted", message); return { block: true, reason: message }; } reservedCalls.add(event.toolCallId); return {}; }; const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => { pi.sendMessage( { content: "Automatic Advisor loop review", customType: "advisor-loop-call", details: { question: `Loop gate: ${event.toolName} repeated ${advisorLoopThresholdRef} times`, }, display: true, }, { deliverAs: "steer" } ); }; const sendAutomaticGateFailure = (pi: ExtensionAPI, markdown: string) => { pi.sendMessage( { content: markdown, customType: "advisor-loop-result", details: { text: markdown }, display: true, }, { deliverAs: "steer" } ); }; const sendAutomaticGateResult = ( pi: ExtensionAPI, result: AdvisorGateResult ) => { pi.sendMessage( { content: adviceForText(result), customType: "advisor-loop-result", details: { advisor: result.model, decision: result.decision, text: result.markdown, }, display: true, }, { deliverAs: "steer" } ); }; const handleAutomaticGate = async ( pi: ExtensionAPI, event: ToolCallEvent, ctx: ExtensionContext, session: AdvisorSessionState, runGate: typeof runAdvisorGate, scoutStatus: ScoutStatusManager, telemetry?: BenchmarkTelemetry ): Promise => { if ( isSimpleMode() || event.toolName === "ask_advisor" || !advisorAutoLoopGateRef || !session.recordToolCall( event.toolName, event.input, advisorLoopThresholdRef ) ) { return; } const reason = `Advisor loop gate: normalized signature for ${event.toolName} repeated ${advisorLoopThresholdRef} times without a materially different tool action.`; if (!session.canConsult(advisorMaxCallsPerSessionRef)) { const failure = failureEffect( "budget-exhausted", "Advisor gate call budget is exhausted.", ctx, session ); return failure.block ? { block: true, reason: failure.reason } : undefined; } session.consumeCall(); herdrAdvisorActivity.start(); let scoutDetails: ScoutToolDetails | undefined; const scoutStatusToken = Symbol("automatic-gate-scout"); scoutStatus.register(scoutStatusToken); let gateCallSent = false; const ensureGateCall = () => { if (!gateCallSent) { sendAutomaticGateCall(pi, event); gateCallSent = true; } }; if (!advisorScoutEnabledRef) { ensureGateCall(); } try { const result = await runGate( ctx, `${reason} Review the repeated actions and recommend the smallest safe next step.`, "repeated-tool-call", ctx.signal, undefined, (scoutEvent) => { scoutStatus.update(ctx, scoutStatusToken, scoutEvent); scoutDetails = appendScoutLifecycleEntry(pi, scoutEvent, scoutDetails); if (scoutEvent.type === "success" || scoutEvent.type === "fallback") { ensureGateCall(); } }, event.toolCallId, telemetry ); ensureGateCall(); if (!result.ok) { session.recordInvocation({ executionEffect: gateFailureEffectForMode(advisorFailureModeRef), failure: result.category, kind: "gate", model: advisorRef, trigger: "repeated-tool-call", }); const failure = failureEffect( result.category, result.message, ctx, session ); sendAutomaticGateFailure( pi, `**Advisor gate failure (${result.category}):** ${result.message}` ); return failure.block ? { block: true, reason: `${reason}\n${failure.reason}` } : undefined; } session.recordInvocation({ cost: advisorUsageCost(result.usage), decision: result.decision, executionEffect: gateDecisionEffect(result.decision), kind: "gate", model: result.model, trigger: result.trigger, usage: result.usage, }); sendAutomaticGateResult(pi, result); if (result.decision === "proceed") { session.resetRepetition(); return; } const gateReason = `Advisor loop review: ${result.markdown}`; if (result.decision === "blocked") { session.block(gateReason); herdrAdvisorBlock.set(gateReason); if (advisorBlockOnBlockedRef) { ctx.abort(); } } return { block: true, reason: gateReason }; } finally { scoutStatus.release(ctx, scoutStatusToken); herdrAdvisorActivity.finish(); } }; export interface ScoutToolDetails { availableCount?: number; fallbackReason?: string; latencyMs?: number; model: string; omittedBeforeScout?: number; selectedCount?: number; selectedLabels?: string[]; status: "calling" | "streaming" | "curated" | "fallback" | "cancelled"; synthesis?: string; text?: string; thinking?: string; usage?: unknown; } interface AdvisorToolDetails { adviceId?: string; advisor?: string; draftBytes?: number; preferenceBytes?: number; question?: string; scout?: ScoutToolDetails; text?: string; thinking?: string; trackedBytes?: number; untrackedBytes?: number; } interface AdvisorRenderState { phase?: string; scout?: ScoutToolDetails; timerId?: ReturnType; } interface AdvisorToolContext { invalidate: () => void; lastComponent: unknown; state: AdvisorRenderState; } const advisorResultDetails = (result: AgentToolResult) => result.details; export const scoutDetailsFromEvent = ( event: ScoutLifecycleEvent, previous?: ScoutToolDetails ): ScoutToolDetails => { if (event.type === "call") { return { model: event.model, status: "calling" }; } if (event.type === "chunk") { return { ...previous, model: event.model, status: "streaming", text: event.text, thinking: event.thinking, }; } if (event.type === "cancelled") { return { model: previous ? previous.model : executorRef, status: "cancelled", }; } const { outcome } = event; return outcome.ok ? { availableCount: outcome.metrics.availableCount, latencyMs: outcome.metrics.latencyMs, model: outcome.model, omittedBeforeScout: outcome.metrics.omittedBeforeScout, selectedCount: outcome.metrics.selectedCount, selectedLabels: outcome.selectedLabels, status: "curated", synthesis: outcome.selection.synthesis, usage: outcome.metrics.usage, } : { availableCount: outcome.metrics.availableCount, fallbackReason: `${outcome.category}: ${outcome.message}`, latencyMs: outcome.metrics.latencyMs, model: outcome.model, omittedBeforeScout: outcome.metrics.omittedBeforeScout, selectedCount: 0, status: "fallback", usage: outcome.metrics.usage, }; }; export const appendScoutLifecycleEntry = ( pi: ExtensionAPI, event: ScoutLifecycleEvent, previous?: ScoutToolDetails ) => { const scout = scoutDetailsFromEvent(event, previous); if ( event.type === "success" || event.type === "fallback" || event.type === "cancelled" ) { pi.appendEntry?.("advisor-scout-result", scout); } return scout; }; export class ScoutStatusManager { readonly #active = new Set(); readonly #known = new Set(); readonly #retired = new Set(); register(token: symbol) { if (!this.#retired.has(token)) { this.#known.add(token); } } update(ctx: ExtensionContext, token: symbol, event: ScoutLifecycleEvent) { if (this.#retired.has(token) || !ctx.hasUI) { return; } this.#known.add(token); if (event.type === "call" || event.type === "chunk") { this.#active.add(token); ctx.ui.setStatus("advisor-scout", "Scout curating…"); return; } this.release(ctx, token); } release(ctx: ExtensionContext, token: symbol) { this.#active.delete(token); this.#known.delete(token); this.#retired.add(token); if (!ctx.hasUI) { return; } ctx.ui.setStatus( "advisor-scout", this.#active.size > 0 ? "Scout curating…" : undefined ); } clear(ctx: ExtensionContext) { for (const token of this.#known) { this.#retired.add(token); } this.#known.clear(); this.#active.clear(); if (ctx.hasUI) { ctx.ui.setStatus("advisor-scout", undefined); } } } const scoutTitle = (scout: ScoutToolDetails, frame: string) => { if (scout.status === "calling" || scout.status === "streaming") { return `◆ SCOUT ${frame} · CURATING…`; } if (scout.status === "curated") { return "◆ SCOUT · CURATED"; } if (scout.status === "cancelled") { return "◆ SCOUT · CANCELLED"; } return "◆ SCOUT · FALLBACK"; }; export const renderScoutDetails = ( box: Box, scout: ScoutToolDetails, expanded: boolean, theme: Theme ) => { const active = scout.status === "calling" || scout.status === "streaming"; const frame = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length]; const title = scoutTitle(scout, frame); const lines = [ theme.fg( scout.status === "fallback" || scout.status === "cancelled" ? "warning" : "accent", theme.bold(title) ), theme.fg( "dim", ` ${scout.model}${scout.selectedCount === undefined ? "" : ` · ${scout.selectedCount} kept / ${Math.max(0, (scout.availableCount ?? 0) - scout.selectedCount)} omitted`}${scout.latencyMs === undefined ? "" : ` · ${(scout.latencyMs / 1000).toFixed(1)}s`}` ), ]; if (scout.fallbackReason) { lines.push(theme.fg("warning", ` ${scout.fallbackReason}`)); } if (scout.thinking && active) { lines.push( theme.fg( "thinkingText", ` 💭 ${scout.thinking.replace(/\n/g, " ").slice(-200)}` ) ); } if (expanded && scout.selectedLabels?.length) { lines.push( theme.fg("dim", ` Selected: ${scout.selectedLabels.join("; ")}`) ); } if (expanded && scout.synthesis) { lines.push( theme.fg( "dim", ` Scout synthesis (untrusted inference): ${scout.synthesis}` ) ); } if (expanded && scout.omittedBeforeScout) { lines.push( theme.fg( "dim", ` ${scout.omittedBeforeScout} group(s) omitted before Scout` ) ); } box.addChild(new Text(lines.join("\n"), 0, 0)); }; const syncRenderPhase = (context: AdvisorToolContext, phase: string) => { if (context.state.phase !== phase && context.state.timerId) { clearInterval(context.state.timerId); context.state.timerId = undefined; } context.state.phase = phase; }; const renderPartialAdvisorResult = ( box: Box, result: AgentToolResult, expanded: boolean, theme: Theme, context: AdvisorToolContext ) => { const details = advisorResultDetails(result); if (details?.scout) { context.state.scout = details.scout; } const scout = details?.scout ?? context.state.scout; const scoutActive = scout?.status === "calling" || scout?.status === "streaming"; syncRenderPhase(context, scoutActive ? "scout" : "advisor"); if (!context.state.timerId) { context.state.timerId = setInterval(() => context.invalidate(), 80); } if (scout) { renderScoutDetails(box, scout, expanded, theme); } if (scoutActive || scout?.status === "cancelled") { return; } const frame = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length]; const lines = [ `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`, ]; if (details?.thinking) { const thought = details.thinking.length > 200 ? details.thinking.slice(-200) : details.thinking; lines.push(theme.fg("thinkingText", ` 💭 ${thought.replace(/\n/g, " ")}`)); } box.addChild(new Text(lines.join("\n"), 0, 0)); if (details?.text) { box.addChild( new Markdown( adviceForDisplay(details.text, expanded), 0, 0, getMarkdownTheme() ) ); } }; const renderFinalAdvisorResult = ( box: Box, result: AgentToolResult, expanded: boolean, theme: Theme, context: AdvisorToolContext ) => { syncRenderPhase(context, "final"); if (context.state.timerId) { clearInterval(context.state.timerId); context.state.timerId = undefined; } const details = advisorResultDetails(result); if (details?.scout) { context.state.scout = details.scout; } const scout = details?.scout ?? context.state.scout; if (scout) { renderScoutDetails(box, scout, expanded, theme); } if (scout?.status === "cancelled") { return; } const advice = details?.text || textFrom(result.content); const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)]; if (details?.advisor) { lines.push(theme.fg("dim", ` ${details.advisor}`)); } const attachments = [ details?.draftBytes ? `Draft attached · ${details.draftBytes} B` : undefined, details?.preferenceBytes ? `Project preferences attached · ${details.preferenceBytes} B` : undefined, details?.trackedBytes ? `Tracked files attached · ${details.trackedBytes} B` : undefined, details?.untrackedBytes ? `Untracked files attached · ${details.untrackedBytes} B` : undefined, ].filter(Boolean); if (attachments.length) { lines.push(theme.fg("dim", ` ${attachments.join(" · ")}`)); } if (details?.thinking) { const thought = details.thinking.replace(/\n/g, " ").slice(0, 300); lines.push( theme.fg( "thinkingText", ` 💭 ${thought}${details.thinking.length > 300 ? "…" : ""}` ) ); } const displayAdvice = advice || "(Advisor returned no advice.)"; box.addChild(new Text(lines.join("\n"), 0, 0)); box.addChild( new Markdown( adviceForDisplay(displayAdvice, expanded), 0, 0, getMarkdownTheme() ) ); }; const renderAdvisorResult = ( result: AgentToolResult, { isPartial, expanded }: ToolRenderResultOptions, theme: Theme, context: AdvisorToolContext ) => { const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1, (text: string) => theme.bg("customMessageBg", text)); box.setBgFn((text) => theme.bg("customMessageBg", text)); box.clear(); if (isPartial) { renderPartialAdvisorResult(box, result, expanded, theme, context); } else { renderFinalAdvisorResult(box, result, expanded, theme, context); } return box; }; export const registerAdvisorTool = ( pi: ExtensionAPI, session: AdvisorSessionState = advisorSessionState, dependencies: { runGate?: typeof runAdvisorGate; statusManager?: ScoutStatusManager; telemetry?: BenchmarkTelemetry; } = {} ) => { const reservedCalls = new Set(); const scoutStatus = dependencies.statusManager ?? new ScoutStatusManager(); pi.registerEntryRenderer?.( "advisor-scout-result", (entry, { expanded }, theme) => { const scout = entry.data as ScoutToolDetails; const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); renderScoutDetails(box, scout, Boolean(expanded), theme); return box; } ); pi.registerMessageRenderer?.( "advisor-loop-call", (message, _options, theme) => { const details = message.details as { question?: string } | undefined; return renderAdvisorCallBox(details?.question, theme); } ); pi.registerMessageRenderer?.( "advisor-loop-result", (message, { expanded }, theme) => { const details = message.details as | { decision?: GateDecision; text?: string; advisor?: string } | undefined; const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); box.addChild( new Text( theme.fg( "warning", theme.bold(`◆ ADVISOR GATE: ${details?.decision ?? "failure"}`) ), 0, 0 ) ); if (details?.advisor) { box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0)); } if (details?.text) { box.addChild( new Markdown( adviceForDisplay(details.text, Boolean(expanded)), 0, 0, getMarkdownTheme() ) ); } else { box.addChild( new Text( theme.fg( "error", typeof message.content === "string" ? message.content : "Advisor gate failed." ), 0, 0 ) ); } return box; } ); pi.on("session_start", () => { session.resetTask(); reservedCalls.clear(); herdrAdvisorBlock.clear(); }); pi.on("before_agent_start", (_event, ctx) => { if (!pi.getActiveTools().includes("ask_advisor")) { return; } loadConfig(ctx); const guidelines = advisorInvocationGuidelines(); const budget = isSimpleMode() ? undefined : session.remainingCalls(advisorMaxCallsPerSessionRef); if (budget !== undefined) { guidelines.push( `Advisor calls remaining this session: ${budget}.\nReserve calls for material decisions, repeated failures, or final review.` ); } return guidelines.length > 0 ? { systemPrompt: `${ctx.getSystemPrompt()}\n\nAdvisor invocation settings:\n${guidelines.map((rule) => `- ${rule}`).join("\n")}`, } : undefined; }); pi.on("tool_call", (event, ctx) => { if (session.blocked) { return { block: true, reason: session.blockedReason ?? "Advisor session is blocked.", }; } if (!pi.getActiveTools().includes("ask_advisor")) { return; } loadConfig(ctx); const reservation = reserveAdvisorCall(event, ctx, session, reservedCalls); if (event.toolName === "ask_advisor") { return reservation; } return handleAutomaticGate( pi, event, ctx, session, dependencies.runGate ?? runAdvisorGate, scoutStatus, dependencies.telemetry ); }); pi.on("agent_settled", (_event, ctx) => { // Any reservation still present never reached execute (for example because // another handler blocked it or the turn was aborted). reservedCalls.clear(); if (isSimpleMode() || session.blocked || !advisorSessionSummaryRef) { return; } const summary = session.summary(advisorMaxCallsPerSessionRef); if (summary && ctx.hasUI) { ctx.ui.notify(summary, "info"); } }); pi.on("session_shutdown", (_event, ctx) => { reservedCalls.clear(); scoutStatus.clear(ctx); herdrAdvisorBlock.clear(); }); pi.registerTool({ description: "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review. If the Advisor explicitly names a missing file, you may make a sequential follow-up call with includeTrackedFiles when enabled and relevant.", async execute(_id, params, signal, onUpdate, ctx) { reservedCalls.delete(_id); if ( params.includeTrackedFiles?.length && !session.claimTrackedFiles(params.includeTrackedFiles) ) { throw new Error( "Tracked file handoff requires a prior Advisor response that explicitly names every requested path and is consumed once." ); } if (!isSimpleMode()) { if (!session.canConsult(advisorMaxCallsPerSessionRef)) { throw new Error("Advisor call budget exhausted for this session."); } session.consumeCall(); } herdrAdvisorActivity.start(); let scoutDetails: ScoutToolDetails | undefined; try { const result = await consultAdvisor( ctx, resolveAdvisorRequest(params.question), signal, (t, tx) => onUpdate?.({ content: [{ text: tx, type: "text" }], details: { advisor: advisorRef, question: resolveAdvisorRequest(params.question), scout: scoutDetails, text: tx, thinking: t, }, }), "executor-requested", // "none" is the model declining repository context for this call. params.gitContext === "none" ? "off" : params.gitContext, params.draft, params.includeUntracked, params.includeTrackedFiles, (event) => { scoutDetails = scoutDetailsFromEvent(event, scoutDetails); onUpdate?.({ content: [{ text: scoutDetails.text ?? "", type: "text" }], details: { advisor: advisorRef, question: resolveAdvisorRequest(params.question), scout: scoutDetails, }, }); }, _id, dependencies.telemetry ); session.issueAdvice( result.adviceId, result.markdown, result.trigger, Boolean(result.draftBytes) ); session.recordInvocation({ cost: advisorUsageCost(result.usage), executionEffect: "continued", kind: "markdown", model: result.model, trigger: "executor-requested", usage: result.usage, }); return { content: [ { text: `Advisor (${result.model})\n\n${result.markdown}`, type: "text", }, ], details: { adviceId: result.adviceId, advisor: result.model, draftBytes: result.draftBytes, preferenceBytes: result.preferenceBytes, question: resolveAdvisorRequest(params.question), scout: scoutDetails, text: result.markdown, thinking: result.thinkingText, trackedBytes: result.trackedBytes, untrackedBytes: result.untrackedBytes, }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); session.recordInvocation({ executionEffect: "continued", failure: "provider-error", kind: "markdown", model: advisorRef, trigger: "executor-requested", }); notifyLocalFailure(ctx, message); notifyHerdrAdvisorFailure("Advisor consultation failed", message); throw error; } finally { herdrAdvisorActivity.finish(); } }, label: "Ask Advisor", name: "ask_advisor", parameters: Type.Object({ draft: Type.Optional( Type.String({ description: "Concise untrusted draft for plan or completion review; claims are not verification evidence.", }) ), gitContext: Type.Optional( Type.Union( [Type.Literal("none"), Type.Literal("summary"), Type.Literal("full")], { description: "How much of the working tree to include. Use full when the review depends on the exact code changes, such as a completion review. Use summary for changed file names only, or none when the question is not about the current changes. The user's configured allowance is the ceiling and a larger request is narrowed to it.", } ) ), includeTrackedFiles: Type.Optional( Type.Array( Type.String({ description: "Exact tracked repository-relative files to attach after the Advisor explicitly names a file it cannot review. Requires global advisorTrackedFileContent consent; current working-tree contents are sent as untrusted data.", }) ) ), includeUntracked: Type.Optional( Type.Array( Type.String({ description: "Exact new repository-relative files to include only when user configuration allows it.", }) ) ), question: Type.Optional( Type.String({ description: "The specific question or decision to get advice on. Omit this for normal reviews: the Advisor already has the conversation context.", }) ), }), promptGuidelines: [ "Call ask_advisor with an empty object for general consultation. For a plan or completion review, include a concise draft naming work, validation, and remaining risks; its claims are not evidence. If the Advisor explicitly says it cannot review a specifically named file, you may make a sequential follow-up call with includeTrackedFiles when the file is relevant, permitted, and worth the shared call budget; do not infer paths or retry automatically.", ], promptSnippet: "Consult the Advisor using its existing context; attach a draft for plan or completion review", renderCall(args, theme) { return renderAdvisorCallBox(args.question?.trim(), theme); }, renderResult(result, options, theme, context) { return renderAdvisorResult( result as AgentToolResult, options, theme, context as AdvisorToolContext ); }, renderShell: "self", }); pi.registerTool({ description: "Voluntarily record the settled adoption and validation outcome for a displayed adviceId when global outcome logging is enabled.", async execute(_id, params, _signal, _update, ctx) { loadConfig(ctx); if (!advisorOutcomeLoggingRef) { return { content: [ { text: "Outcome logging is disabled globally.", type: "text" }, ], details: { recorded: false }, }; } const advice = session.claimAdvice(params.adviceId); if (!advice) { throw new Error("Unknown or already recorded adviceId."); } try { await appendOutcome({ adoption: params.adoption as (typeof ADOPTIONS)[number], advice: advice.advice, trigger: advice.trigger, validationStatus: params.validationStatus as (typeof VALIDATIONS)[number], }); return { content: [ { text: "Advisor outcome recorded locally.", type: "text" }, ], details: { recorded: true }, }; } catch { if (ctx.hasUI) { ctx.ui.notify( "Advisor outcome could not be recorded locally.", "warning" ); } return { content: [ { text: "Advisor outcome was not recorded; Advisor execution remains usable.", type: "text", }, ], details: { recorded: false }, }; } }, label: "Record Advisor Outcome", name: "record_advisor_outcome", parameters: Type.Object({ adoption: Type.String({ enum: ADOPTIONS }), adviceId: Type.String(), validationStatus: Type.String({ enum: VALIDATIONS }), }), renderCall: () => new Text("[advisor] Record outcome", 0, 0), renderResult: (result) => new Text(textFrom(result.content), 0, 0), }); };