Brings the deterministic Han-flow conductor into BooCode: launch any read-only flow from BooChat or BooCoder, watch each agent stream live in a Paseo-style run pane, get an evidence-disciplined report — on local Qwen, persisted and resumable. Read-only enforced hard via qwen --approval-mode plan (orchestrator tasks fail closed if qwen is unavailable; never fall to write-capable native). Backend (apps/coder): re-homed conductor defs, flow_runs/flow_steps schema, flow-runner + dispatcher onTaskTerminal hook, restart-resume, runs routes (launch/list/get/cancel), user-channel WS. Contracts: two flow_run_* frames. Web: orchestrator pane kind + OrchestratorPane, Workflow button + slash flows (BooChat/BooCoder parity), FlowLauncherDialog, "New Orchestrator" in the + and split menus, runs history + export. Plan: openspec/changes/orchestrator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
/**
|
||
* Spine factory: turns a declarative `Spine` (a Han skill expressed as data)
|
||
* into a runnable `Flow`. The shape is the one ~most Han skills share —
|
||
*
|
||
* angle₁ ─┐
|
||
* angle₂ ─┼─▶ fold (code) ─▶ [synthesizer] ─▶ adversarial gate ─▶ render
|
||
* angle₃ ─┘ (fan-in) (optional) (validator)
|
||
*
|
||
* — so new skills are added as config (flows/*.ts), not new code. Band gating
|
||
* selects how many angles fan out (small = core only; large = all). Skills
|
||
* with a genuinely different shape (code-review's per-finding verify pipeline)
|
||
* get a bespoke Flow instead of a Spine.
|
||
*/
|
||
import type { Band, Flow, Spine, Step, StepContext } from './types.js';
|
||
import { produceContract, reviewContract, type Contract } from './contracts.js';
|
||
import { slugify } from './render.js';
|
||
|
||
const BAND_ORDER: Record<Band, number> = { small: 0, medium: 1, large: 2 };
|
||
|
||
export function readBand(input: StepContext['input']): Band {
|
||
const b = input.band;
|
||
return typeof b === 'string' && b in BAND_ORDER ? (b as Band) : 'small';
|
||
}
|
||
|
||
function bandAtLeast(ctx: StepContext, min: Band = 'small'): boolean {
|
||
return BAND_ORDER[readBand(ctx.input)] >= BAND_ORDER[min];
|
||
}
|
||
|
||
/** Appended to every worker when --fast is set — caps the slow tool loop. */
|
||
export function fastNote(ctx: StepContext): string {
|
||
if (!ctx.input.concise) return '';
|
||
return '\n\nFAST MODE — optimise for speed over exhaustiveness: limit external/tool calls to the few that matter, cite only decisive evidence, keep every section short, return quickly.';
|
||
}
|
||
|
||
interface ResolvedGate {
|
||
agent: string;
|
||
label: string;
|
||
task: (ctx: StepContext) => string;
|
||
}
|
||
|
||
/** The adversarial gate, built with the Han review checklists for the spine's contracts. */
|
||
function defaultValidator(contracts: Contract[]): ResolvedGate {
|
||
return {
|
||
agent: 'adversarial-validator',
|
||
label: 'Validation (adversarial-validator)',
|
||
task: (ctx) =>
|
||
[
|
||
`Adversarially validate the analysis below, for: "${String(ctx.input.question)}".`,
|
||
'Attack the evidence, the framing, the conclusion, and the integrity of how the evidence was gathered.',
|
||
'Emit findings as V1, V2, … each with a severity and whether it changes the conclusion.',
|
||
reviewContract(contracts).trim(),
|
||
'End with, in this order: a one-line VERDICT (does the conclusion survive?); a plain-language SUMMARY (2–3 sentences, no jargon or IDs); and a CONFIDENCE rating on its own line — `Confidence: High | Medium | Low`.',
|
||
'',
|
||
'----- ANALYSIS TO ATTACK -----',
|
||
ctx.results.synthesis ?? ctx.results.fold ?? '',
|
||
].join('\n'),
|
||
};
|
||
}
|
||
|
||
export function buildSpineFlow(spine: Spine): Flow {
|
||
const contracts: Contract[] = spine.contracts ?? ['evidence'];
|
||
const validator: ResolvedGate = spine.validator ?? defaultValidator(contracts);
|
||
const angleIds = spine.angles.map((a) => a.id);
|
||
const steps: Step[] = [];
|
||
|
||
for (const angle of spine.angles) {
|
||
steps.push({
|
||
id: angle.id,
|
||
kind: 'agent',
|
||
agent: angle.agent,
|
||
when: (ctx) => bandAtLeast(ctx, angle.minBand) && (angle.when ? angle.when(ctx) : true),
|
||
run: (ctx) => angle.task(ctx) + produceContract(contracts) + fastNote(ctx),
|
||
});
|
||
}
|
||
|
||
// Code fold: concatenate whatever angles produced (skipped angles absent).
|
||
steps.push({
|
||
id: 'fold',
|
||
kind: 'code',
|
||
deps: angleIds,
|
||
run: (ctx) => foldAngles(spine, ctx),
|
||
});
|
||
|
||
if (spine.synthesizer) {
|
||
steps.push({
|
||
id: 'synthesis',
|
||
kind: 'agent',
|
||
agent: spine.synthesizer.agent,
|
||
deps: ['fold'],
|
||
run: (ctx) => spine.synthesizer!.task(ctx) + produceContract(contracts) + fastNote(ctx),
|
||
});
|
||
}
|
||
|
||
steps.push({
|
||
id: 'validation',
|
||
kind: 'agent',
|
||
agent: validator.agent,
|
||
deps: [spine.synthesizer ? 'synthesis' : 'fold'],
|
||
run: (ctx) => validator.task(ctx) + fastNote(ctx),
|
||
});
|
||
|
||
return {
|
||
name: spine.name,
|
||
description: spine.description,
|
||
steps,
|
||
render: (ctx) => renderSpine(spine, validator, contracts, ctx),
|
||
output: (ctx) => `conductor-report-${spine.name}-${slugify(String(ctx.input.question))}.md`,
|
||
};
|
||
}
|
||
|
||
function foldAngles(spine: Spine, ctx: StepContext): string {
|
||
const blocks: string[] = [];
|
||
for (const angle of spine.angles) {
|
||
const out = ctx.results[angle.id];
|
||
if (out) blocks.push(`### ${angle.label}\n\n${out}`);
|
||
}
|
||
return blocks.join('\n\n') || '_(no angle produced output)_';
|
||
}
|
||
|
||
function renderSpine(spine: Spine, validator: ResolvedGate, contracts: Contract[], ctx: StepContext): string {
|
||
const question = String(ctx.input.question ?? '');
|
||
// model is injected by the flow-runner from flow_runs.model — no env var fallback
|
||
const model = ctx.model ?? 'llama-swap/qwen3.6-35b-a3b-mxfp4';
|
||
const band = readBand(ctx.input);
|
||
const chain: string[] = [];
|
||
const rules = [
|
||
contracts.includes('evidence') ? 'evidence-rule (trust classes · single-source web gate · no-evidence labeling)' : '',
|
||
contracts.includes('yagni') ? 'YAGNI gate' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
|
||
const parts: string[] = [
|
||
`# Conductor Report — ${spine.name}: ${question}`,
|
||
`> BooCode code conductor · band=${band}${ctx.input.concise ? ' · fast' : ''} · workers on \`${model}\`. Sequencing, fan-out, and fold are deterministic code; each agent ran as a bounded single-task worker. Han rules applied: ${rules}. The plain-language summary, the **Confidence** rating, and any \`## Deferred (YAGNI)\` items are in the **Validation** section — read it before trusting the conclusion.`,
|
||
];
|
||
|
||
for (const angle of spine.angles) {
|
||
if (ctx.results[angle.id]) {
|
||
parts.push(`## ${angle.label}\n\n${ctx.results[angle.id]}`);
|
||
chain.push(angle.agent);
|
||
}
|
||
}
|
||
if (spine.synthesizer && ctx.results.synthesis) {
|
||
parts.push(`## ${spine.synthesizer.label}\n\n${ctx.results.synthesis}`);
|
||
chain.push(spine.synthesizer.agent);
|
||
}
|
||
parts.push(`## ${validator.label}\n\n${ctx.results.validation ?? '_no validation output_'}`);
|
||
chain.push(validator.agent);
|
||
|
||
parts.push(
|
||
`---\n\n_Conducted by the code conductor: ${chain.join(' → ')}. Band=${band}. The conductor chose every step and passed full outputs forward; no model decided the sequence._`,
|
||
);
|
||
|
||
return parts.join('\n\n') + '\n';
|
||
}
|