fable-agent/src/fable5/dynamic-workflows.ts

390 lines
13 KiB
TypeScript

import type { LoopIteration, GodModeRaceResult, PerturbationResult } from "../core/types.js";
import { IndependentVerifier, type VerificationResult } from "./independent-verifier.js";
import { ModelRouter, type TaskComplexity, type RoutingResponse } from "./model-router.js";
import {
type FusionPanelSlug,
type FusionResult,
FUSION_PANELS,
type GodModePanelSlug,
} from "../core/fusion-types.js";
import { GodModeClassic } from "../upgrades/godmode-classic.js";
import { Parseltongue } from "../upgrades/parseltongue.js";
// ── Types ──────────────────────────────────────────────────
export type WorkflowPattern = "fan-out-synthesize" | "adversarial-verify" | "loop-until-done" | "fusion-panel" | "godmode-race" | "parseltongue-test";
export type FanOutSubtaskFlow = "direct" | "planning" | "review";
export interface FanOutSubtaskRouteContext {
task: string;
subTask: string;
index: number;
flow: FanOutSubtaskFlow;
reason: string;
route: RoutingResponse;
}
export type FanOutExecutor = (
subTask: string,
index: number,
routeContext?: FanOutSubtaskRouteContext,
) => LoopIteration | Promise<LoopIteration>;
export interface WorkflowStep {
id: string;
label: string;
agent: (ctx: Record<string, unknown>) => LoopIteration | Promise<LoopIteration>;
dependsOn?: string[];
}
export interface WorkflowDefinition {
id: string;
task: string;
pattern: WorkflowPattern;
steps: WorkflowStep[];
maxConcurrency?: number;
}
export interface MixtureOfAgentsMetadata {
provider: "fable-fan-out";
workers: number;
synthesizer: "caller-provided";
maxConcurrency: number;
}
export interface FanOutResult {
iterations: LoopIteration[];
routeContexts: FanOutSubtaskRouteContext[];
synthesized: LoopIteration;
synthesisVerdict: VerificationResult;
mixtureOfAgents: MixtureOfAgentsMetadata;
}
export interface AdversarialResult {
maker: LoopIteration;
verifier: VerificationResult;
passed: boolean;
gapSummary: string;
}
export interface LoopUntilDoneResult {
iterations: LoopIteration[];
finalVerdict: VerificationResult;
totalRounds: number;
}
export interface FusionPanelResult {
task: string;
panelSlug: FusionPanelSlug;
panelists: LoopIteration[];
synthesized: LoopIteration;
synthesisVerdict: VerificationResult;
fusionAnalysis: string;
}
// ── Dynamic Workflows ──────────────────────────────────────
export class DynamicWorkflows {
private verifier: IndependentVerifier;
private modelRouter: ModelRouter;
private godMode: GodModeClassic | null;
private parseltongue: Parseltongue | null;
constructor(verifier?: IndependentVerifier, modelRouter?: ModelRouter) {
this.verifier = verifier ?? new IndependentVerifier();
this.modelRouter = modelRouter ?? new ModelRouter();
this.godMode = null;
this.parseltongue = null;
}
/**
* Enable GodMode Classic racing workflows.
*/
enableGodMode(gm?: GodModeClassic): void {
this.godMode = gm ?? new GodModeClassic();
}
/**
* Enable Parseltongue perturbation testing workflows.
*/
enableParseltongue(pt?: Parseltongue): void {
this.parseltongue = pt ?? new Parseltongue();
}
/**
* Fan-out-and-synthesize: split work into N independent pieces,
* run an agent on each in parallel, synthesize results.
*
* Best when each sub-task benefits from its own clean context window —
* e.g., evaluating each rule in a Skill against historical examples.
*/
async fanOutAndSynthesize(
task: string,
subTasks: string[],
executor: FanOutExecutor,
synthesizer: (results: LoopIteration[], originalTask: string) => LoopIteration | Promise<LoopIteration>,
maxConcurrency: number = 3,
): Promise<FanOutResult> {
// Phase 1: Fan out — run all sub-tasks (with concurrency limit)
const routeContexts: FanOutSubtaskRouteContext[] = new Array(subTasks.length);
const iterations = await this.mapWithConcurrency(
subTasks.length,
maxConcurrency,
(index) => {
const subTask = subTasks[index];
const routeContext = this.routeFanOutSubtask(task, subTask, index);
routeContexts[index] = routeContext;
return Promise.resolve(executor(subTask, index, routeContext));
},
);
// Phase 2: Synthesize
const synthesized = await Promise.resolve(synthesizer(iterations, task));
// Phase 3: Verify the synthesis
const synthesisVerdict = this.verifier.verify(synthesized, task);
return {
iterations,
routeContexts,
synthesized,
synthesisVerdict,
mixtureOfAgents: {
provider: "fable-fan-out",
workers: subTasks.length,
synthesizer: "caller-provided",
maxConcurrency,
},
};
}
/**
* Adversarial verification: for a maker agent, spawn an independent verifier
* with no exposure to the maker's reasoning. The structural fix for
* self-preferential bias.
*
* The maker cannot see the verifier's criteria evaluation; the verifier only
* sees the artifact and the rubric. This guarantees independent assessment.
*/
async adversarialVerify(
makerIteration: LoopIteration,
task: string,
verifierConfig?: { criteria?: Array<{ id: string; label: string; description: string }>; minPassRate?: number },
): Promise<AdversarialResult> {
// Create an independent verifier with optional custom criteria
const verifier = verifierConfig
? new IndependentVerifier({
criteria: verifierConfig.criteria ?? [
{ id: "correctness", label: "Correctness", description: "Output is technically correct" },
{ id: "completeness", label: "Completeness", description: "All requirements met" },
{ id: "consistency", label: "Consistency", description: "No contradictions within output" },
],
minPassRate: verifierConfig.minPassRate ?? 0.75,
requireAllEssential: true,
})
: this.verifier;
// The verifier sees ONLY the artifact (executed/observation), NOT the reasoning trail
const sanitized: LoopIteration = {
...makerIteration,
plan: "",
reflection: "",
refinement: "",
};
const result = verifier.verify(sanitized, task);
const passed = result.verdict === "PASS";
const gapSummary = result.gaps.length > 0 ? result.gaps.join("; ") : "All criteria met";
return { maker: makerIteration, verifier: result, passed, gapSummary };
}
/**
* Loop-until-done: loop spawning agents until a stop condition is met.
* Pairs with /goal to set a hard completion requirement.
*/
async loopUntilDone(
task: string,
executor: (iteration: number, previous: LoopIteration | null) => LoopIteration | Promise<LoopIteration>,
options?: {
maxIterations?: number;
convergenceThreshold?: number;
stopCondition?: (iteration: LoopIteration, result: VerificationResult) => boolean;
},
): Promise<LoopUntilDoneResult> {
const maxIterations = options?.maxIterations ?? 10;
const convergenceThreshold = options?.convergenceThreshold ?? 0.75;
const iterations: LoopIteration[] = [];
let finalVerdict: VerificationResult = {
verdict: "FAIL",
criteriaResults: [],
gaps: ["No iterations completed"],
matches: [],
suggestion: "No iterations ran",
};
for (let i = 0; i < maxIterations; i++) {
const previous = iterations.length > 0 ? iterations[iterations.length - 1] : null;
const iteration = await Promise.resolve(executor(i, previous));
iterations.push(iteration);
finalVerdict = this.verifier.verify(iteration, task);
const passRate = finalVerdict.criteriaResults.filter((r) => r.passed).length / Math.max(finalVerdict.criteriaResults.length, 1);
// Check stop conditions
if (options?.stopCondition && options.stopCondition(iteration, finalVerdict)) {
break;
}
if (finalVerdict.verdict === "PASS" && passRate >= convergenceThreshold) {
break;
}
}
return { iterations, finalVerdict, totalRounds: iterations.length };
}
/**
* Fusion Panel: dispatch the same task to N panelists in parallel,
* then synthesize results with the judge model.
*
* Implements the draft → critique → fuse pattern from fusion-fable:
* - All panelists get the same prompt, independently
* - No "lenses" or personas — just raw independent execution
* - Judge synthesizes with structured analysis
*/
async fusionPanel(
task: string,
executor: (prompt: string, panelistIndex: number) => LoopIteration | Promise<LoopIteration>,
judge: (panelists: LoopIteration[], originalTask: string) => LoopIteration | Promise<LoopIteration>,
panelSlug: FusionPanelSlug = "opus4.8-4.8",
maxConcurrency: number = 3,
): Promise<FusionPanelResult> {
const panel = FUSION_PANELS[panelSlug];
const panelistCount = panel.modelIds.length;
// Phase 1: Run all panelists in parallel (with concurrency limit)
const panelists = await this.mapWithConcurrency(
panelistCount,
maxConcurrency,
(index) => Promise.resolve(executor(task, index)),
);
// Phase 2: Judge synthesizes
const synthesized = await Promise.resolve(judge(panelists, task));
// Phase 3: Verify synthesis
const synthesisVerdict = this.verifier.verify(synthesized, task);
return {
task,
panelSlug,
panelists,
synthesized,
synthesisVerdict,
fusionAnalysis: synthesized.observation ?? "Fusion complete",
};
}
/**
* Get the recommended grader model for a workflow subtask.
* Routes by complexity — cheap graders for simple tasks.
*/
getGraderForWorkflow(complexity: TaskComplexity): string {
return this.modelRouter.getGraderForWorkflow(complexity);
}
// ── GodMode Race ────────────────────────────────────────────
/**
* GodMode Race: Run N models in parallel, return best/first result.
* Inspired by G0DM0D3's GODMODE CLASSIC.
*/
async godmodeRace(
task: string,
panelSlug?: GodModePanelSlug,
): Promise<GodModeRaceResult> {
if (!this.godMode) {
this.enableGodMode();
}
const gm = this.godMode!;
return await gm.race(task, panelSlug);
}
// ── Parseltongue Test ───────────────────────────────────────
/**
* Parseltongue Test: Run perturbation suite against a safety gate.
* Inspired by G0DM0D3's Parseltongue red-teaming engine.
*/
async parseltongueTest(
input: string,
gateFn: (perturbed: string) => Promise<boolean> | boolean,
options?: {
category?: "encoding" | "injection" | "obfuscation" | "framing" | "logic_trap" | "adversarial";
intensity?: "low" | "medium" | "high";
},
): Promise<{
results: PerturbationResult[];
summary: { totalTests: number; bypassesDetected: number; gateWeaknesses: string[] };
}> {
if (!this.parseltongue) {
this.enableParseltongue();
}
const pt = this.parseltongue!;
return await pt.testGate(input, gateFn, options);
}
private routeFanOutSubtask(task: string, subTask: string, index: number): FanOutSubtaskRouteContext {
const lower = `${subTask} ${task}`.toLowerCase();
const isPlanning = /\b(plan|design|architecture|strategy|roadmap|approach|proposal|scope|spec|mvp)\b/i.test(lower);
const isReview = /\b(review|audit|check|verify|validation|assessment|safety|test|diff)\b/i.test(lower);
const flow: FanOutSubtaskFlow = isPlanning
? "planning"
: isReview
? "review"
: "direct";
const complexity = flow === "direct" ? "simple" : "medium";
const domain: "code" | "planning" | "review" =
flow === "planning" ? "planning" : flow === "review" ? "review" : "code";
const route = this.modelRouter.route({
task: `${task} ${subTask}`,
domain,
complexity,
requiresVision: false,
});
return {
task,
subTask,
index,
flow,
reason: flow === "direct" ? "Direct/simple fanout -> cheap flow" : `Plan/review fanout -> routed as ${flow}`,
route,
};
}
private async mapWithConcurrency<T>(
count: number,
maxConcurrency: number,
worker: (index: number) => Promise<T>,
): Promise<T[]> {
const results: T[] = new Array(count);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(maxConcurrency, count));
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < count) {
const index = nextIndex++;
results[index] = await worker(index);
}
}),
);
return results;
}
}