417 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|