feat: in-app Orchestrator (Phase 2) — multi-agent conductor
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>
This commit is contained in:
155
conductor/src/spine.ts
Normal file
155
conductor/src/spine.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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 ?? '');
|
||||
const model = process.env.CONDUCTOR_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';
|
||||
}
|
||||
Reference in New Issue
Block a user