/** * Skeptic panel: spawn N adversarial verifiers, wait, aggregate. * * Timeout / infra policy (B011/B015): * - No bus or subagents dead → caller uses degraded evidence path (not this module). * - Bus present but all spawns/waits fail or timeout → fail-CLOSED: * NotAchieved with gap "verifier panel unavailable" (prefer honesty over * fail-open Achieved when we claimed to run a panel). * - Partial verdicts: aggregate what we got (bias-to-refute); empty set → fail-CLOSED. */ import { aggregateVerdicts, buildVerifierPrompt, parseVerifierVerdict, } from "../roles/verifier.ts"; import { pingSubagents, spawnSubagent, waitSubagent, type EventBus, } from "../subagents/client.ts"; export interface PanelInput { objective: string; planMarkdown: string; evidenceIndex?: string; gaps?: string[]; /** Clamped 1–5 by caller. */ skepticN: number; timeoutMs: number; /** Subagent type (default Explore). */ agentType?: string; /** * Called after all spawns succeed (and before waits). Use to emit * verify_started with agent ids on the critical path (B015). */ onSpawned?: (agentIds: string[]) => void; } export interface PanelResult { /** Whether the panel could run at all (ping + ≥1 spawn). */ ran: boolean; /** Aggregate achieved (only meaningful if ran && verdicts.length > 0). */ achieved: boolean; gaps: string[]; agentIds: string[]; /** Parsed per-agent outcomes (length may be < agentIds if some timed out). */ verdicts: Array<{ agentId: string; refuted: boolean; gaps: string[] }>; /** Infra / total failure (no usable verdicts despite bus). */ panelUnavailable: boolean; error?: string; } /** * Spawn N skeptics, wait for each (up to timeoutMs each), parse + aggregate. * Does not decide degraded-mode fallback — caller handles bus-missing. */ export async function runSkepticPanel( bus: EventBus, input: PanelInput, ): Promise { const n = Math.max(1, Math.min(5, Math.trunc(input.skepticN) || 1)); const timeoutMs = Math.max(1, input.timeoutMs); const agentType = input.agentType ?? "Explore"; const alive = await pingSubagents(bus, Math.min(2000, timeoutMs)); if (!alive) { return { ran: false, achieved: false, gaps: [], agentIds: [], verdicts: [], panelUnavailable: false, error: "subagents ping failed", }; } const prompt = buildVerifierPrompt({ objective: input.objective, planMarkdown: input.planMarkdown, evidenceIndex: input.evidenceIndex, gaps: input.gaps, }); const agentIds: string[] = []; for (let i = 0; i < n; i++) { try { const r = await spawnSubagent( bus, { type: agentType, prompt, description: `goal-verifier-${i}`, isBackground: true, }, Math.min(30_000, timeoutMs), ); agentIds.push(r.id); } catch { // stop spawning on first spawn failure; use what we have break; } } if (agentIds.length === 0) { return { ran: false, achieved: false, gaps: ["verifier panel unavailable"], agentIds: [], verdicts: [], panelUnavailable: true, error: "no skeptics spawned", }; } // Emit agent ids BEFORE wait (critical-path event order) try { input.onSpawned?.(agentIds); } catch { /* non-fatal */ } const waits = await Promise.all( agentIds.map(async (id) => { try { return await waitSubagent(bus, id, timeoutMs); } catch (err) { return { id, status: "failed", error: err instanceof Error ? err.message : String(err), result: undefined as string | undefined, }; } }), ); const verdicts: PanelResult["verdicts"] = []; for (const w of waits) { const text = w.result?.trim(); if (!text) { // timeout / failed / empty → bias-to-refute as malformed verdicts.push({ agentId: w.id, refuted: true, gaps: [ w.error ? `verifier ${w.id}: ${w.error}` : `verifier ${w.id}: empty or missing result`, ], }); continue; } const parsed = parseVerifierVerdict(text); verdicts.push({ agentId: w.id, refuted: parsed.refuted, gaps: parsed.gaps, }); } // All waits produced no parseable text and all failed hard with no result → unavailable const anyText = waits.some((w) => Boolean(w.result?.trim())); if (!anyText) { return { ran: true, achieved: false, gaps: ["verifier panel unavailable"], agentIds, verdicts, panelUnavailable: true, error: "all skeptics failed or timed out", }; } const agg = aggregateVerdicts(verdicts.map((v) => ({ refuted: v.refuted, gaps: v.gaps }))); return { ran: true, achieved: agg.achieved, gaps: agg.gaps, agentIds, verdicts, panelUnavailable: false, }; }