fable-agent/src/examples/executors/fusion-executor.ts

417 lines
14 KiB
TypeScript

/**
* Fusion Executor — multi-model panel execution inspired by:
* - OpenRouter Fusion API (compound models via orchestration API)
* - fusion-fable (fan-out panel → judge → structured synthesis)
* - Fable 5's implicit multi-model orchestration capability
*
* The core pattern is the same across all three:
* DRAFT → Run N panelists in parallel, same prompt, independent context
* CRITIQUE → Judge evaluates every answer (consensus, contradictions, blind spots)
* FUSE → Judge writes a grounded final answer from the analysis
*
* As a PhaseExecutor, this can be used directly in the feedback loop:
* const fusion = new FusionExecutor();
* const result = await feedbackLoop.run(task, fusion);
*
* Or standalone for one-shot fusion:
* const result = await fusion.fuse(task, "opus4.8-gpt5.5");
* // { analysis, finalAnswer, panelists }
*/
import type { LoopIteration } from "../../core/types.js";
import type { PhaseExecutor } from "../../tier2-primitives/loops/feedback-loop.js";
import { SafetyBoundary } from "../../upgrades/safety-boundary.js";
import {
FUSION_PANELS,
type FusionPanelSlug,
type FusionResult,
type FusionPanelConfig,
type PanelistResult,
type FusionAnalysis,
} from "../../core/fusion-types.js";
import { callProxyChat } from "../../core/proxy-caller.js";
// ─── Configuration ──────────────────────────────────────────
export interface FusionExecutorConfig {
/** Default panel composition */
panel: FusionPanelSlug;
/** Safety boundary for model routing */
safetyBoundary?: SafetyBoundary;
/** Override the judge model (defaults to panel's judgeModel) */
judgeModelOverride?: string;
/** Custom API call for a panelist model */
callModel?: (modelId: string, prompt: string, maxTokens: number) => Promise<string>;
/** Enable verbose logging */
verbose?: boolean;
}
// ─── Fusion Executor ────────────────────────────────────────
export class FusionExecutor implements PhaseExecutor {
private config: FusionExecutorConfig;
private panel: FusionPanelConfig;
private safetyBoundary: SafetyBoundary;
private totalCalls = 0;
/** Stored from plan() so refine() can access it */
private currentTask = "";
constructor(config?: Partial<FusionExecutorConfig>) {
this.config = {
panel: "opus4.8-4.8",
verbose: false,
...config,
};
this.panel = FUSION_PANELS[this.config.panel];
this.safetyBoundary = this.config.safetyBoundary ?? new SafetyBoundary();
}
// ── PhaseExecutor Interface ─────────────────────────────
async plan(task: string, previousIteration: LoopIteration | null): Promise<string> {
// Fusion doesn't need a separate plan phase — the panel dispatch IS the plan
this.currentTask = task;
if (previousIteration) {
return this.fuseWithContext(task, previousIteration);
}
return this.fuse(task, this.config.panel).then(r => r.finalAnswer);
}
async execute(plan: string): Promise<string> {
return plan; // Fusion already produced the answer
}
async observe(executed: string): Promise<string> {
return executed;
}
async reflect(observation: string, _previousIteration: LoopIteration | null): Promise<string> {
// Self-critique: reflect on the fused answer
return this.critiqueAnswer(observation);
}
async refine(reflection: string): Promise<string> {
// Refine: re-fuse incorporating the reflection
return this.fuseWithReflection(this.currentTask, reflection);
}
// ── Core Fusion API ─────────────────────────────────────
/**
* Run a complete fusion cycle: dispatch panel → analyze → synthesize.
*/
async fuse(task: string, panelSlug?: FusionPanelSlug): Promise<FusionResult> {
const panel = panelSlug ? FUSION_PANELS[panelSlug] : this.panel;
const startTime = Date.now();
// 1. DRAFT — dispatch panelists in parallel
const panelResults = await this.dispatchPanel(task, panel);
// 2. CRITIQUE — analyze panelist responses
const analysis = this.analyzePanel(panelResults);
// 3. FUSE — synthesize final answer
const finalAnswer = analysis.blindSpots.length > 0
? await this.synthesizeFinal(task, panelResults, analysis)
: panelResults[0]?.raw ?? "No panelists returned results.";
return {
task,
panelSlug: panel.slug,
panelSize: panel.modelIds.length,
panelists: panelResults,
analysis,
finalAnswer,
judgeModel: panel.judgeModel,
totalDurationMs: Date.now() - startTime,
error: null,
};
}
/**
* One-shot fusion: just call a single model (OpenRouter Fusion API or direct).
*/
async callOnce(prompt: string, modelId?: string): Promise<string> {
const model = modelId ?? "claude-opus-4-8";
const route = this.safetyBoundary.route("reasoning");
return this.makeModelCall(model, prompt, 8192);
}
// ── Private: Panel Dispatch ──────────────────────────────
private async dispatchPanel(
task: string,
panel: FusionPanelConfig,
): Promise<PanelistResult[]> {
// Validate models through safety boundary
const validModels = panel.modelIds.filter(id => {
const route = this.safetyBoundary.route("reasoning");
return id !== "claude-fable-5" && id !== "mythos-5" && route.modelId !== "none";
});
if (validModels.length === 0) {
return [this.emptyResult("No available models in panel")];
}
// Build judge prompt using Fable 5-style framing
const prompt = this.buildPanelPrompt(task);
// Dispatch in parallel
const startTime = Date.now();
const results = await Promise.allSettled(
validModels.map((modelId, i) => this.callPanelist(modelId, prompt, panel, i))
);
const panelResults: PanelistResult[] = [];
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (r.status === "fulfilled") {
panelResults.push(r.value);
} else {
panelResults.push({
panelistIndex: i,
modelId: validModels[i] ?? "unknown",
modelName: validModels[i] ?? "unknown",
raw: "",
durationMs: Date.now() - startTime,
tokenCount: 0,
error: r.reason?.toString() ?? "Unknown error",
});
}
}
return panelResults;
}
private async callPanelist(
modelId: string,
prompt: string,
panel: FusionPanelConfig,
index: number,
): Promise<PanelistResult> {
const startTime = Date.now();
const raw = await this.makeModelCall(modelId, prompt, panel.maxTokensPerPanelist);
return {
panelistIndex: index,
modelId,
modelName: modelId,
raw,
durationMs: Date.now() - startTime,
tokenCount: Math.ceil(raw.length / 4),
error: null,
};
}
// ── Private: Analysis ───────────────────────────────────
private analyzePanel(panelists: PanelistResult[]): FusionAnalysis {
const nonEmpty = panelists.filter(p => p.raw.length > 0 && !p.error);
if (nonEmpty.length === 0) {
return {
consensus: [],
contradictions: [],
partialCoverage: [],
uniqueInsights: [],
blindSpots: ["No panelists produced results"],
};
}
if (nonEmpty.length === 1) {
return {
consensus: ["Single panelist — all findings treated as consensus"],
contradictions: [],
partialCoverage: [],
uniqueInsights: [],
blindSpots: ["Single-panelist fusion: no cross-verification available"],
};
}
// Extract common themes (simple heuristic: shared keywords/patterns)
const allWords = nonEmpty.map(p => new Set(p.raw.toLowerCase().split(/\W+/).filter(w => w.length > 4)));
const commonWords = [...allWords[0]].filter(w => allWords.every(s => s.has(w)));
const consensus = commonWords.length > 0
? [`Panel shares vocabulary around: ${commonWords.slice(0, 10).join(", ")}`]
: ["No strong lexical consensus detected"];
// Unique insights: words/themes only in one panelist
const uniqueInsights: Array<{ insight: string; source: string }> = [];
for (let i = 0; i < nonEmpty.length; i++) {
const unique = [...allWords[i]].filter(w =>
allWords.every((s, j) => j === i || !s.has(w))
);
if (unique.length > 0) {
uniqueInsights.push({
insight: `Unique terms: ${unique.slice(0, 5).join(", ")}`,
source: nonEmpty[i].modelId,
});
}
}
// Blind spots: what no one mentioned
const blindSpots = nonEmpty.length < panelists.length
? [`${panelists.length - nonEmpty.length} panelist(s) failed to respond`]
: [];
return {
consensus,
contradictions: [],
partialCoverage: [`All ${nonEmpty.length} panelists produced responses`],
uniqueInsights,
blindSpots,
};
}
// ── Private: Synthesis ─────────────────────────────────
private async synthesizeFinal(
task: string,
panelists: PanelistResult[],
analysis: FusionAnalysis,
): Promise<string> {
const synthesisPrompt = this.buildSynthesisPrompt(task, panelists, analysis);
return this.makeModelCall(this.panel.judgeModel, synthesisPrompt, 16384);
}
private async fuseWithContext(task: string, prev: LoopIteration): Promise<string> {
const prompt = [
`Previous iteration feedback:`,
` Reflection: ${prev.reflection ?? "none"}`,
` Refinement: ${prev.refinement ?? "none"}`,
``,
`Continue the fusion for: ${task}`,
].join("\n");
return this.makeModelCall(this.panel.judgeModel, prompt, 8192);
}
private async fuseWithReflection(task: string, reflection: string): Promise<string> {
const prompt = [
`Reflection on previous fusion: ${reflection}`,
``,
`Re-fuse with correction: ${task}`,
].join("\n");
return this.makeModelCall(this.panel.judgeModel, prompt, 8192);
}
private async critiqueAnswer(answer: string): Promise<string> {
const prompt = [
`Critique the following answer. Identify:`,
` 1. What is strongest about it`,
` 2. What is weakest or missing`,
` 3. What would you add or change`,
``,
`Answer:`,
answer,
].join("\n");
return this.makeModelCall(this.panel.judgeModel, prompt, 4096);
}
// ── Prompt Builders ─────────────────────────────────────
/**
* Fable 5-inspired prompt structure:
* Goal-oriented (not step-oriented). Context + goal framing.
* "I'm working on [larger goal]. With that in mind: [specific request]."
*/
private buildPanelPrompt(task: string): string {
return [
`I'm working on a comprehensive analysis task. With that in mind:`,
``,
task,
``,
`Provide your complete, detailed response. Include specific reasoning,`,
`evidence, and actionable conclusions. Be thorough.`,
].join("\n");
}
private buildSynthesisPrompt(
task: string,
panelists: PanelistResult[],
analysis: FusionAnalysis,
): string {
const panelBlocks = panelists
.filter(p => p.raw.length > 0 && !p.error)
.map((p, i) => [
`--- Panelist ${i + 1}: ${p.modelId} ---`,
p.raw,
].join("\n"))
.join("\n\n");
return [
`You are the judge. A panel of models has independently answered the following task.`,
``,
`Task: ${task}`,
``,
`Panel responses:`,
panelBlocks,
``,
`--- Analysis ---`,
`Consensus points: ${analysis.consensus.join("; ") || "none identified"}`,
`Contradictions: ${analysis.contradictions.length}`,
`Unique insights: ${analysis.uniqueInsights.map(i => i.insight).join("; ") || "none"}`,
`Blind spots: ${analysis.blindSpots.join("; ") || "none identified"}`,
``,
`Now produce a FINAL ANSWER that:`,
`1. Synthesizes the consensus into a coherent position`,
`2. Flags contradictions and explains why they exist`,
`3. Incorporates unique insights from individual panelists`,
`4. Acknowledges blind spots and limitations`,
`5. Is actionable and directly addresses the original task`,
``,
`Structure your output as:`,
`## Consensus`,
`## Contradictions & Resolutions`,
`## Unique Insights`,
`## Blind Spots & Limitations`,
`## Final Answer`,
].join("\n");
}
// ── Model Call ──────────────────────────────────────────
private async makeModelCall(
modelId: string,
prompt: string,
maxTokens: number,
): Promise<string> {
if (this.config.callModel) {
return this.config.callModel(modelId, prompt, maxTokens);
}
this.totalCalls++;
const prefix = `[Fusion call #${this.totalCalls} to ${modelId}]`;
if (this.config.verbose) {
console.error(`${prefix} sending ${prompt.length} chars, max ${maxTokens} tokens`);
}
if (modelId === "openrouter-fusion") {
return `${prefix} OpenRouter Fusion not available through proxy`;
}
try {
const result = await callProxyChat(modelId, prompt, maxTokens);
if (this.config.verbose) {
console.error(`${prefix} got ${result.length} chars back`);
}
return result;
} catch (e) {
const msg = (e as Error).message;
console.error(`${prefix} ERROR: ${msg}`);
return `${prefix} Error: ${msg}`;
}
}
private emptyResult(reason: string): PanelistResult {
return {
panelistIndex: 0,
modelId: "none",
modelName: "none",
raw: "",
durationMs: 0,
tokenCount: 0,
error: reason,
};
}
}