fable-agent/src/fable5/compound-stack.ts

492 lines
18 KiB
TypeScript

import { ModelRouter, type RoutingResponse, type TaskComplexity } from "./model-router.js";
import { IndependentVerifier, type VerificationResult } from "./independent-verifier.js";
import { FiveStageStateFile, type FiveStageState } from "./state-file-5stage.js";
import { GoalPattern, type GoalDefinition } from "./goal-pattern.js";
import { WorktreeManager } from "./worktree-isolation.js";
import { DynamicWorkflows, type AdversarialResult, type FanOutResult, type FusionPanelResult } from "./dynamic-workflows.js";
import { ProxyClient } from "../pai/proxy-client.js";
import { SkillSync } from "../pai/skill-sync.js";
import type { LoopIteration } from "../core/types.js";
import { SafetyBoundary } from "../upgrades/safety-boundary.js";
import {
VisionSelfCheck,
type VisualArtifact,
type VisionCheckResult,
} from "../upgrades/vision-self-check.js";
import { FeedbackLoop, type PhaseExecutor } from "../tier2-primitives/loops/feedback-loop.js";
import { type FusionPanelSlug } from "../core/fusion-types.js";
export interface StackConfig {
modelRouter: boolean;
verifier: boolean;
stateFile: boolean;
goalPattern: boolean;
worktree: boolean;
visionCheck: boolean;
safetyBoundary: boolean;
dynamicWorkflows: boolean;
skillCompounding: boolean;
lifecycleHooks: boolean;
fusionPanel: boolean;
godModeRace: boolean;
ultraPlinian: boolean;
parseltongue: boolean;
autoTune: boolean;
stmModules: boolean;
abliteration: boolean;
transparency: boolean;
promptObservatory: boolean;
promptLiberation: boolean;
}
export interface StackStatus {
layers: Record<string, string>;
modelRoute: RoutingResponse | null;
verification: VerificationResult | null;
stateFile: FiveStageState | null;
activeGoal: string | null;
worktrees: number;
sessionId: string;
}
const DEFAULT_CONFIG: StackConfig = {
modelRouter: true,
verifier: true,
stateFile: true,
goalPattern: true,
worktree: false,
visionCheck: true, // enabled by default; only runs for UI/visual tasks
safetyBoundary: true,
dynamicWorkflows: false,
skillCompounding: true,
lifecycleHooks: true,
fusionPanel: false,
godModeRace: false, // enabled when GodModeClassic workflow is requested
ultraPlinian: false, // enabled when UltraPlinian evaluation is requested
parseltongue: false, // enabled when perturbation testing is requested
autoTune: false, // enabled when adaptive params are configured
stmModules: false, // enabled when STM pipeline is configured
abliteration: false, // enabled when refusal analysis is needed
transparency: true, // enabled by default — records all decisions
promptObservatory: false, // enabled when observatory is queried
promptLiberation: false, // enabled when liberation testing is active
};
/** Keywords that suggest a task produces UI or visual output. */
const VISUAL_HINTS = new Set([
"image", "picture", "photo", "screenshot", "ui", "ux", "interface", "render",
"design", "visual", "graphic", "layout", "frontend", "web page", "webpage",
"html", "css", "svg", "canvas", "figma", "mockup", "wireframe", "icon", "logo",
"banner", "thumbnail", "diagram", "chart", "plot", "illustration",
]);
export class CompoundStack {
readonly modelRouter: ModelRouter;
readonly verifier: IndependentVerifier;
readonly stateFile: FiveStageStateFile;
readonly goalPattern: GoalPattern;
readonly worktree: WorktreeManager;
readonly safetyBoundary: SafetyBoundary;
readonly dynamicWorkflows: DynamicWorkflows;
readonly skillSync: SkillSync;
readonly proxyClient: ProxyClient;
readonly visionSelfCheck: VisionSelfCheck;
public config: StackConfig;
private activeGoalId: string | null = null;
private currentProject: string = "";
private currentTask: string = "";
private sessionId: string;
constructor(config?: Partial<StackConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.modelRouter = new ModelRouter();
this.verifier = new IndependentVerifier();
this.stateFile = new FiveStageStateFile();
this.goalPattern = new GoalPattern();
this.worktree = new WorktreeManager();
this.safetyBoundary = new SafetyBoundary();
this.dynamicWorkflows = new DynamicWorkflows(this.verifier, this.modelRouter);
this.skillSync = new SkillSync();
this.proxyClient = new ProxyClient();
this.visionSelfCheck = new VisionSelfCheck({ proxyClient: this.proxyClient });
this.sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
async run(task: string, project: string): Promise<StackStatus> {
this.currentProject = project;
this.currentTask = task;
const status: StackStatus = {
layers: {},
modelRoute: null,
verification: null,
stateFile: null,
activeGoal: null,
worktrees: 0,
sessionId: this.sessionId,
};
// ── Layer 1: Lifecycle — read state file at session start ──
if (this.config.lifecycleHooks && this.config.stateFile) {
const { contextPrompt, isResume } = this.stateFile.beginSession(project, this.sessionId);
status.layers["lifecycle"] = isResume ? `resumed (${this.sessionId.slice(0, 16)}...)` : "fresh";
status.stateFile = this.stateFile.load(project);
}
// ── Layer 2: Model Router ──
if (this.config.modelRouter) {
const complexity = this.modelRouter.taskComplexity(task);
const route = this.modelRouter.route({ task, domain: "code", complexity, requiresVision: false });
status.modelRoute = route;
status.layers["model-router"] = `${route.primary.tier}(${route.primary.displayName})`;
}
// ── Layer 3: Safety Boundary ──
if (this.config.safetyBoundary) {
const available = this.safetyBoundary.getAvailable();
if (available.length === 0) {
status.layers["safety-boundary"] = "BLOCKED: No available models";
await this.writeExit("BLOCKED — No available models", []);
return status;
}
const route = this.safetyBoundary.route("task");
if (route.modelId === "none") {
status.layers["safety-boundary"] = `BLOCKED: ${route.reason}`;
await this.writeExit(`BLOCKED — ${route.reason}`, []);
return status;
}
status.layers["safety-boundary"] = `PASS (${route.modelName})`;
}
// ── Layer 4: Goal Pattern ──
if (this.config.goalPattern) {
const goalDef: GoalDefinition = {
text: task,
criteria: [
{ label: "Correctness", description: "Output is technically correct", weight: 0.4, required: true },
{ label: "Completeness", description: "All requirements addressed", weight: 0.3, required: true },
{ label: "Clarity", description: "Output is clear", weight: 0.2, required: false },
{ label: "Efficiency", description: "Solution is efficient", weight: 0.1, required: false },
],
maxIterations: 10,
minScore: 0.7,
};
this.activeGoalId = this.goalPattern.setGoal(goalDef);
status.activeGoal = this.activeGoalId;
status.layers["goal-pattern"] = `goal set: ${goalDef.text.slice(0, 40)}...`;
}
// ── Layer 5: Verifier ──
if (this.config.verifier) {
status.layers["verifier"] = "ready";
}
// ── Layer 6: State File ──
if (this.config.stateFile && !status.stateFile) {
const existing = this.stateFile.load(project);
status.stateFile = existing;
status.layers["state-file"] = existing ? `loaded (${existing.verifiedFacts.length} facts, ${existing.generalRules.length} rules)` : "created";
}
// ── Layer 7: Dynamic Workflows (fan-out + adversarial + fusion) ──
if (this.config.dynamicWorkflows) {
status.layers["dynamic-workflows"] = "ready (fan-out + adversarial + loop-until-done + fusion)";
}
// ── Layer 7b: Fusion Panel ──
if (this.config.fusionPanel) {
status.layers["fusion-panel"] = "ready (draft → critique → fuse)";
}
// ── Layer 8: Worktrees ──
if (this.config.godModeRace) {
status.layers["godmode-race"] = "ready (parallel model racing)";
}
if (this.config.ultraPlinian) {
status.layers["ultra-plinian"] = "ready (5-tier output evaluation)";
}
if (this.config.parseltongue) {
status.layers["parseltongue"] = "ready (safety-gate perturbation testing)";
}
if (this.config.autoTune) {
status.layers["auto-tune"] = "ready (adaptive sampling parameters)";
}
if (this.config.stmModules) {
status.layers["stm-modules"] = "ready (semantic output transforms)";
}
if (this.config.abliteration) {
status.layers["abliteration-awareness"] = "ready (refusal pattern analysis)";
}
if (this.config.transparency) {
status.layers["transparency"] = "ready (prompt and routing records)";
}
if (this.config.promptObservatory) {
status.layers["prompt-observatory"] = "ready (system prompt catalog)";
}
if (this.config.promptLiberation) {
status.layers["prompt-liberation"] = "ready (prompt constraint analysis)";
}
if (this.config.worktree) {
const trees = this.worktree.list();
status.worktrees = trees.length;
status.layers["worktree"] = `${trees.length} active`;
}
// ── Layer 9: Vision Check ──
if (this.config.visionCheck) {
const visual = this.isVisualTask(task);
status.layers["vision-check"] = visual
? "ready (visual task detected — vision check active)"
: "ready (non-visual task — vision check skipped)";
}
return status;
}
/**
* Determine whether a task likely produces UI/visual output.
* Used to gate vision self-checks so they only run when relevant.
*/
isVisualTask(task: string): boolean {
const lower = task.toLowerCase();
for (const hint of VISUAL_HINTS) {
if (lower.includes(hint)) return true;
}
return false;
}
/**
* Run a vision self-check after a loop iteration.
* If the task is non-visual or vision checks are disabled, returns null.
* Results are recorded in the state file: Lessons Learned for PASS/PARTIAL,
* Open Failures for FAIL.
*/
async visionCheckIteration(
iteration: LoopIteration,
task?: string,
artifact?: VisualArtifact
): Promise<VisionCheckResult | null> {
const goal = task ?? this.currentTask;
if (!this.config.visionCheck || !this.isVisualTask(goal)) {
return null;
}
const resolvedArtifact = artifact ?? this.extractVisualArtifact(iteration);
if (!resolvedArtifact) {
return null;
}
const result = await this.visionSelfCheck.check(resolvedArtifact, goal);
this.recordVisionResult(result, goal, iteration.number);
return result;
}
/**
* Run a full feedback loop with vision self-checks hooked after each iteration.
* Only runs vision checks when the task hints at UI/visual output.
*/
async runLoopWithVision(
task: string,
executor: PhaseExecutor,
options?: {
maxIterations?: number;
convergenceThreshold?: number;
loopDelayMs?: number;
}
): Promise<{ loopResult: import("../core/types.js").LoopResult; visionResults: VisionCheckResult[] }> {
this.currentTask = task;
const loop = new FeedbackLoop(undefined, this.sessionId, options);
const visionResults: VisionCheckResult[] = [];
loop.onIteration(async (iteration) => {
const result = await this.visionCheckIteration(iteration, task);
if (result) {
visionResults.push(result);
}
});
const loopResult = await loop.run(task, executor);
return { loopResult, visionResults };
}
private extractVisualArtifact(iteration: LoopIteration): VisualArtifact | null {
const haystack = `${iteration.executed ?? ""}\n${iteration.observation ?? ""}`;
// Look for base64 data URIs
const dataUriMatch = haystack.match(/data:image\/[a-zA-Z0-9.+]+;base64,[A-Za-z0-9+/=]+/);
if (dataUriMatch) {
const source = dataUriMatch[0];
const mime = source.match(/data:([^;]+)/)?.[1] ?? "image/png";
return { source, mimeType: mime, type: "image", provenance: "extracted from iteration output" };
}
// Look for file paths or URLs ending in image extensions
const imageRefMatch = haystack.match(/(?:file:\/\/|\b)\S+\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?\S*)?/i);
if (imageRefMatch) {
const source = imageRefMatch[0];
return { source, mimeType: this.guessMimeType(source), type: "image", provenance: "extracted from iteration output" };
}
return null;
}
private recordVisionResult(result: VisionCheckResult, goal: string, iterationNumber: number): void {
if (!this.config.stateFile || !this.currentProject) return;
const summary = `Iteration ${iterationNumber} vision check for "${goal.slice(0, 80)}...": ${result.verdict} (${(result.confidence * 100).toFixed(0)}% confidence)`;
if (result.verdict === "FAIL") {
const detail = result.gaps.length > 0 ? `${result.gaps.join("; ")}` : "";
this.stateFile.addFailure(this.currentProject, `${summary}${detail}`);
} else {
const detail = result.gaps.length > 0
? ` — gaps: ${result.gaps.join("; ")}`
: ` — matches: ${result.matches.join("; ") || "visual output aligned with goal"}`;
this.stateFile.addLesson(this.currentProject, `${summary}${detail}`);
}
}
private guessMimeType(path: string): string {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
const mimeMap: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
svg: "image/svg+xml",
};
return mimeMap[ext] ?? "image/png";
}
/**
* Execute a fan-out-and-synthesize dynamic workflow.
* Splits a task into N sub-tasks, runs each independently, synthesizes results.
*/
async fanOut(
task: string,
subTasks: string[],
executor: (subTask: string, index: number) => LoopIteration | Promise<LoopIteration>,
synthesizer: (results: LoopIteration[], originalTask: string) => LoopIteration | Promise<LoopIteration>,
maxConcurrency?: number,
): Promise<FanOutResult> {
this.ensureConfig("dynamicWorkflows");
return this.dynamicWorkflows.fanOutAndSynthesize(task, subTasks, executor, synthesizer, maxConcurrency);
}
/**
* Execute adversarial verification: maker artifact → independent verifier.
*/
async adversarialVerify(
makerIteration: LoopIteration,
task: string,
): Promise<AdversarialResult> {
this.ensureConfig("dynamicWorkflows");
return this.dynamicWorkflows.adversarialVerify(makerIteration, task);
}
/**
* Execute a fusion panel: dispatch task to multiple panelists in parallel,
* then synthesize results with the judge model.
*
* Implements the draft → critique → fuse pattern.
*/
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,
): Promise<FusionPanelResult> {
this.ensureConfig("fusionPanel");
return this.dynamicWorkflows.fusionPanel(task, executor, judge, panelSlug, maxConcurrency);
}
/**
* Compound a verified failure into the most relevant PAI skill.
* Implements the "write the lesson into the Skill" pattern.
*/
compoundLesson(lesson: string, context: string): { skillName: string; path: string } | null {
if (!this.config.skillCompounding) return null;
const relevant = this.skillSync.findRelevantSkill(context);
if (!relevant) return null;
const path = this.skillSync.compoundLesson(relevant.name, lesson, "compound-stack");
if (!path) return null;
return { skillName: relevant.name, path };
}
/**
* End the current session: write summary + next actions to state file.
* Implements the "write before walking away" rule.
*/
async writeExit(summary: string, nextActions: string[], options?: { lessons?: string[]; facts?: string[]; failures?: string[] }): Promise<void> {
if (!this.config.lifecycleHooks || !this.config.stateFile) return;
this.stateFile.endSession(this.currentProject, summary, nextActions, this.sessionId, {
addLesson: options?.lessons?.[0],
addFact: options?.facts?.[0],
failures: options?.failures,
});
}
/**
* Get a status report string for display.
*/
getStatus(): string {
const parts: string[] = ["Fable 5 Compound Stack", "━━━━━━━━━━━━━━━━━"];
const layerOrder: Array<keyof StackConfig> = [
"lifecycleHooks",
"modelRouter",
"safetyBoundary",
"goalPattern",
"verifier",
"dynamicWorkflows",
"fusionPanel",
"godModeRace",
"ultraPlinian",
"parseltongue",
"autoTune",
"stmModules",
"abliteration",
"transparency",
"promptObservatory",
"promptLiberation",
"stateFile",
"worktree",
"visionCheck",
"skillCompounding",
];
for (const layer of layerOrder) {
const enabled = this.config[layer];
parts.push(` ${enabled ? "✓" : "○"} ${layer}: ${enabled ? "enabled" : "disabled"}`);
}
const goals = this.goalPattern.getStatus();
parts.push(` ${goals}`);
parts.push(` ${this.activeGoalId ? `Active goal: ${this.activeGoalId}` : "No active goal"}`);
parts.push(` Session: ${this.sessionId.slice(0, 16)}...`);
return parts.join("\n");
}
private ensureConfig(key: keyof StackConfig): void {
if (!this.config[key]) {
throw new Error(`Layer "${key}" is disabled in config. Enable it to use this feature.`);
}
}
}