Files
fusion/packages/engine/src/research-orchestrator.ts
Fusion 3a97bf2386 feat(FN-3292): document research boundary contract
Documents the research boundary contract in the architecture docs and research hardening preflight guide, updating architecture documentation and adding a new boundary definition file.

Fusion-Task-Id: FN-3292
2026-05-04 13:17:57 -07:00

569 lines
18 KiB
TypeScript

import type { ResearchStore } from "@fusion/core";
import type {
ResearchCancellationState,
ResearchOrchestrationConfig,
ResearchOrchestrationPhase,
ResearchOrchestrationStep,
ResearchRun,
ResearchSource,
ResearchSynthesisRequest,
} from "@fusion/core";
import { AgentSemaphore } from "./concurrency.js";
import { createLogger, formatError } from "./logger.js";
import type { ResearchStepRunnerApi } from "./research-step-runner.js";
const log = createLogger("research-orchestrator");
export interface ResearchOrchestratorStatus {
runId: string;
status: ResearchRun["status"];
phase: ResearchOrchestrationPhase;
stepIndex: number;
totalSteps: number;
progress: number;
active: boolean;
}
export interface ResearchOrchestratorStartOptions {
abortSignal?: AbortSignal;
}
export interface ResearchOrchestratorOptions {
store: ResearchStore;
stepRunner: ResearchStepRunnerApi;
maxConcurrentRuns?: number;
}
interface ActiveRunState {
controller: AbortController;
phase: ResearchOrchestrationPhase;
stepIndex: number;
totalSteps: number;
config: ResearchOrchestrationConfig;
cancellationTimer?: NodeJS.Timeout;
}
const CANCELLATION_GRACE_MS = 2_000;
export class ResearchOrchestrator {
private readonly store: ResearchStore;
private readonly stepRunner: ResearchStepRunnerApi;
private readonly semaphore: AgentSemaphore;
private readonly activeRuns = new Map<string, ActiveRunState>();
private readonly cancellation = new Map<string, ResearchCancellationState>();
constructor(options: ResearchOrchestratorOptions) {
this.store = options.store;
this.stepRunner = options.stepRunner;
this.semaphore = new AgentSemaphore(options.maxConcurrentRuns ?? 3);
}
createRun(config: ResearchOrchestrationConfig): string {
const run = this.store.createRun({
query: "",
providerConfig: config as unknown as Record<string, unknown>,
metadata: {
orchestration: {
phase: "planning",
stepIndex: 0,
totalSteps: this.computeTotalSteps(config),
},
},
});
return run.id;
}
async startRun(runId: string, query: string, options: ResearchOrchestratorStartOptions = {}): Promise<ResearchRun> {
const run = this.store.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
const config = (run.providerConfig ?? {}) as unknown as ResearchOrchestrationConfig;
const controller = new AbortController();
if (options.abortSignal) {
options.abortSignal.addEventListener("abort", () => controller.abort(options.abortSignal?.reason), { once: true });
}
const totalSteps = this.computeTotalSteps(config);
this.activeRuns.set(runId, {
controller,
phase: "planning",
stepIndex: 0,
totalSteps,
config,
});
const queued = this.store.getRun(runId);
if (queued?.status === "retry_waiting") {
this.store.updateStatus(runId, "queued");
}
await this.semaphore.run(async () => {
this.store.updateRun(runId, { query, startedAt: new Date().toISOString(), error: null });
this.store.updateStatus(runId, "running");
await this.runPhases(runId, query, config, controller.signal);
});
const updated = this.store.getRun(runId);
if (!updated) throw new Error(`Research run not found after start: ${runId}`);
return updated;
}
cancelRun(runId: string): boolean {
const active = this.activeRuns.get(runId);
const run = this.store.getRun(runId);
if (!run) return false;
this.store.requestCancellation(runId);
if (!active) {
this.store.updateStatus(runId, "cancelled", { error: "Cancelled by user" });
return true;
}
if (active.cancellationTimer) return true;
const state: ResearchCancellationState = {
runId,
controller: active.controller,
requestedAt: new Date().toISOString(),
gracefulShutdown: true,
reason: "Cancelled by user",
};
this.cancellation.set(runId, state);
active.controller.abort(new Error("Research run cancelled"));
active.cancellationTimer = setTimeout(() => {
this.onCancelled(runId);
}, CANCELLATION_GRACE_MS);
return true;
}
retryRun(runId: string): string {
const run = this.store.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (run.status !== "failed" && run.status !== "cancelled") {
throw new Error(`Research run ${runId} is not retryable (status=${run.status})`);
}
const next = this.store.createRun({
query: run.query,
topic: run.topic,
providerConfig: run.providerConfig,
tags: [...run.tags],
metadata: {
...(run.metadata ?? {}),
retryOfRunId: run.id,
},
});
this.store.addEvent(next.id, {
type: "info",
message: `Retry run created from ${run.id}`,
metadata: { retryOfRunId: run.id },
});
return next.id;
}
getRunStatus(runId: string): ResearchOrchestratorStatus {
const run = this.store.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
const active = this.activeRuns.get(runId);
const metadata = (run.metadata?.orchestration as Record<string, unknown> | undefined) ?? {};
const phase = (active?.phase ?? metadata.phase ?? this.statusToPhase(run.status)) as ResearchOrchestrationPhase;
const stepIndex = active?.stepIndex ?? Number(metadata.stepIndex ?? 0);
const totalSteps = active?.totalSteps ?? Number(metadata.totalSteps ?? 0);
return {
runId,
status: run.status,
phase,
stepIndex,
totalSteps,
progress: totalSteps > 0 ? Math.min(1, stepIndex / totalSteps) : 0,
active: this.activeRuns.has(runId),
};
}
private async runPhases(
runId: string,
query: string,
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<void> {
try {
await this.runPlanning(runId, query, config, signal);
const sources = await this.runSearching(runId, query, config, signal);
const fetchedSources = await this.runFetching(runId, sources, config, signal);
const synthesis = await this.runSynthesis(runId, query, fetchedSources, config, signal);
await this.runFinalizing(runId, synthesis.output, synthesis.citations, synthesis.confidence, signal);
this.store.updateStatus(runId, "completed");
this.transitionPhase(runId, "completed", "Research run completed");
} catch (err) {
if (signal.aborted) {
this.onCancelled(runId);
} else {
const { message, detail } = formatError(err);
this.store.addEvent(runId, {
type: "error",
message: `Research run failed: ${message}`,
metadata: { detail },
});
this.store.updateStatus(runId, "failed", { error: message });
this.transitionPhase(runId, "failed", "Research run failed", { error: message });
}
} finally {
const active = this.activeRuns.get(runId);
if (active?.cancellationTimer) {
clearTimeout(active.cancellationTimer);
}
this.activeRuns.delete(runId);
this.cancellation.delete(runId);
}
}
private async runPlanning(runId: string, query: string, config: ResearchOrchestrationConfig, _signal: AbortSignal): Promise<void> {
this.transitionPhase(runId, "planning", "Planning research execution");
this.stepStarted(runId, {
id: `${runId}-planning`,
type: "synthesis-pass",
phase: "planning",
status: "running",
order: 0,
name: "Create plan",
input: { query, providerCount: config.providers.length },
startedAt: new Date().toISOString(),
});
this.stepCompleted(runId, `${runId}-planning`, { query });
}
private async runSearching(
runId: string,
query: string,
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<ResearchSource[]> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "searching", "Searching sources");
const allSources: ResearchSource[] = [];
for (const provider of config.providers) {
this.throwIfAborted(signal);
const step = this.createStep(runId, "source-query", "searching", `Search with ${provider.type}`, {
query,
provider: provider.type,
});
this.stepStarted(runId, step);
const result = await this.stepRunner.runSourceQuery(query, provider.type, provider.config, signal);
if (!result.ok || !result.data) {
this.stepFailed(runId, step.id, result.error?.message ?? `Provider ${provider.type} returned no data`, result.error);
continue;
}
for (const source of result.data.slice(0, Math.max(0, config.maxSources - allSources.length))) {
const saved = this.store.addSource(runId, {
...source,
metadata: {
...(source.metadata ?? {}),
providerType: provider.type,
},
});
allSources.push(saved);
this.store.addEvent(runId, {
type: "source_added",
message: `Source found: ${saved.reference}`,
metadata: { sourceId: saved.id, provider: provider.type },
});
}
this.stepCompleted(runId, step.id, { sourceCount: result.data.length });
if (allSources.length >= config.maxSources) break;
}
if (allSources.length === 0) {
throw new Error("No sources discovered during search phase");
}
return allSources;
}
private async runFetching(
runId: string,
sources: ResearchSource[],
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<ResearchSource[]> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "fetching", "Fetching source content");
const fetched: ResearchSource[] = [];
const provider = config.providers[0];
for (const source of sources.slice(0, config.maxSources)) {
this.throwIfAborted(signal);
const step = this.createStep(runId, "content-fetch", "fetching", `Fetch ${source.reference}`, {
sourceId: source.id,
});
this.stepStarted(runId, step);
const sourceProvider = this.getSourceProviderType(source);
const providerConfig = sourceProvider ? config.providers.find((p) => p.type === sourceProvider)?.config : provider?.config;
const result = await this.stepRunner.runContentFetch(source.reference, sourceProvider, providerConfig, signal);
if (!result.ok || !result.data) {
this.stepFailed(runId, step.id, result.error?.message ?? "Failed to fetch source content", result.error);
continue;
}
const updated: ResearchSource = {
...source,
content: result.data.content,
metadata: {
...(source.metadata ?? {}),
...(result.data.metadata ?? {}),
},
status: "completed",
fetchedAt: new Date().toISOString(),
};
this.store.updateSource(runId, source.id, updated);
fetched.push(updated);
this.stepCompleted(runId, step.id, { fetched: true });
}
if (fetched.length === 0) {
throw new Error("No source content fetched");
}
return fetched;
}
private async runSynthesis(
runId: string,
query: string,
sources: ResearchSource[],
config: ResearchOrchestrationConfig,
signal: AbortSignal,
): Promise<{ output: string; citations: string[]; confidence?: number }> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "synthesizing", "Synthesizing findings");
let final: { output: string; citations: string[]; confidence?: number } | undefined;
for (let round = 1; round <= Math.max(1, config.maxSynthesisRounds); round++) {
this.throwIfAborted(signal);
const step = this.createStep(runId, "synthesis-pass", "synthesizing", `Synthesis round ${round}`, {
round,
});
this.stepStarted(runId, step);
const request: ResearchSynthesisRequest = {
query,
sources,
round,
desiredFormat: "markdown",
};
const result = await this.stepRunner.runSynthesis(request, config.synthesisModel, signal);
if (!result.ok || !result.data) {
this.stepFailed(runId, step.id, result.error?.message ?? "Synthesis failed", result.error);
continue;
}
final = result.data;
this.store.addEvent(runId, {
type: "progress",
message: `Synthesis round ${round} completed`,
metadata: { round, confidence: result.data.confidence },
});
this.stepCompleted(runId, step.id, { round, citations: result.data.citations.length });
}
if (!final) {
throw new Error("All synthesis rounds failed");
}
return final;
}
private async runFinalizing(
runId: string,
output: string,
citations: string[],
confidence: number | undefined,
signal: AbortSignal,
): Promise<void> {
this.throwIfAborted(signal);
this.transitionPhase(runId, "finalizing", "Finalizing research results");
if (!this.canWriteRunData(runId)) return;
this.store.setResults(runId, {
summary: output,
findings: [
{
heading: "Synthesis",
content: output,
sources: citations,
confidence,
},
],
citations,
synthesizedOutput: output,
});
}
private onCancelled(runId: string): void {
const run = this.store.getRun(runId);
if (!run || run.status === "cancelled") return;
const cancellation = this.cancellation.get(runId);
this.store.addEvent(runId, {
type: "warning",
message: "Research run cancelled",
metadata: {
requestedAt: cancellation?.requestedAt,
reason: cancellation?.reason,
},
});
this.store.updateStatus(runId, "cancelled", {
cancelledAt: new Date().toISOString(),
error: cancellation?.reason,
});
this.transitionPhase(runId, "cancelled", "Research run cancelled");
}
private transitionPhase(
runId: string,
phase: ResearchOrchestrationPhase,
message: string,
metadata?: Record<string, unknown>,
): void {
if (!this.canWriteRunData(runId) && phase !== "cancelled" && phase !== "completed" && phase !== "failed") return;
const active = this.activeRuns.get(runId);
if (active) {
active.phase = phase;
}
this.store.updateRun(runId, {
metadata: {
orchestration: {
phase,
stepIndex: active?.stepIndex ?? 0,
totalSteps: active?.totalSteps ?? 0,
},
},
});
this.store.addEvent(runId, {
type: "progress",
message,
metadata: {
orchestrationEventType: "phase-changed",
phase,
...(metadata ?? {}),
},
});
log.log(`${runId}: phase changed -> ${phase}`);
}
private stepStarted(runId: string, step: ResearchOrchestrationStep): void {
if (!this.canWriteRunData(runId)) return;
this.bumpStep(runId, step.order);
this.store.addEvent(runId, {
type: "progress",
message: `${step.name} started`,
metadata: {
orchestrationEventType: "step-started",
step,
},
});
}
private stepCompleted(runId: string, stepId: string, output?: Record<string, unknown>): void {
if (!this.canWriteRunData(runId)) return;
this.store.addEvent(runId, {
type: "progress",
message: `${stepId} completed`,
metadata: {
orchestrationEventType: "step-completed",
stepId,
output,
},
});
}
private stepFailed(
runId: string,
stepId: string,
errorMessage: string,
errorMeta?: Record<string, unknown>,
): void {
if (!this.canWriteRunData(runId)) return;
this.store.addEvent(runId, {
type: "error",
message: `${stepId} failed: ${errorMessage}`,
metadata: {
orchestrationEventType: "step-failed",
stepId,
...(errorMeta ?? {}),
},
});
}
private bumpStep(runId: string, stepIndex: number): void {
const active = this.activeRuns.get(runId);
if (!active) return;
active.stepIndex = stepIndex;
this.store.updateRun(runId, {
metadata: {
orchestration: {
phase: active.phase,
stepIndex: active.stepIndex,
totalSteps: active.totalSteps,
},
},
});
}
private createStep(
runId: string,
type: ResearchOrchestrationStep["type"],
phase: ResearchOrchestrationPhase,
name: string,
input?: Record<string, unknown>,
): ResearchOrchestrationStep {
const active = this.activeRuns.get(runId);
const order = (active?.stepIndex ?? 0) + 1;
return {
id: `${runId}-${phase}-${order}`,
type,
phase,
status: "running",
order,
name,
input,
startedAt: new Date().toISOString(),
};
}
private computeTotalSteps(config: ResearchOrchestrationConfig): number {
const providers = Math.max(1, config.providers.length);
return 1 + providers + Math.max(1, config.maxSources) + Math.max(1, config.maxSynthesisRounds) + 1;
}
private statusToPhase(status: ResearchRun["status"]): ResearchOrchestrationPhase {
if (status === "completed") return "completed";
if (status === "failed" || status === "timed_out" || status === "retry_exhausted") return "failed";
if (status === "cancelled" || status === "cancelling") return "cancelled";
return "planning";
}
private throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason ?? new Error("Research run aborted");
}
}
private getSourceProviderType(source: ResearchSource): string | undefined {
const providerType = source.metadata?.providerType;
return typeof providerType === "string" && providerType.length > 0 ? providerType : undefined;
}
private canWriteRunData(runId: string): boolean {
const run = this.store.getRun(runId);
if (!run) return false;
return !["cancelled", "completed", "failed", "timed_out", "retry_exhausted"].includes(run.status);
}
}