diff --git a/packages/engine/src/__tests__/research-orchestrator.test.ts b/packages/engine/src/__tests__/research-orchestrator.test.ts index d75187ff72..a0ae6e2438 100644 --- a/packages/engine/src/__tests__/research-orchestrator.test.ts +++ b/packages/engine/src/__tests__/research-orchestrator.test.ts @@ -113,6 +113,35 @@ describe("ResearchOrchestrator", () => { expect(status.phase).toBe("completed"); }); + it("normalizes dashboard string provider configs before searching", async () => { + const { store, stepRunner } = createHarness(); + const orchestrator = new ResearchOrchestrator({ + store: store as never, + stepRunner: stepRunner as never, + maxConcurrentRuns: 2, + }); + + const run = store.createRun({ + query: "dashboard research", + providerConfig: { + providers: ["web-search", "page-fetch", "llm-synthesis"], + maxResults: 3, + }, + }); + + const completed = await orchestrator.startRun(run.id, "dashboard research"); + expect(completed.status).toBe("completed"); + expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2); + expect(stepRunner.runSourceQuery).toHaveBeenNthCalledWith(1, "dashboard research", "web-search", undefined, expect.anything()); + expect(stepRunner.runSourceQuery).toHaveBeenNthCalledWith(2, "dashboard research", "page-fetch", undefined, expect.anything()); + expect(store.addEvent).toHaveBeenCalledWith( + run.id, + expect.objectContaining({ + message: "Search with web-search started", + }), + ); + }); + it("cancels a running run", async () => { const { store, stepRunner } = createHarness(); stepRunner.runSourceQuery.mockImplementation( diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 578d9d7d00..43be5db786 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -9,6 +9,9 @@ import type { AutomationStore as AutomationStoreType, ScheduledTask, AutomationRunResult, + ResearchModelSettings, + ResearchSynthesisRequest, + ResearchSynthesisResult, } from "@fusion/core"; import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; @@ -36,6 +39,7 @@ import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; import { ResearchOrchestrator } from "./research-orchestrator.js"; import { ResearchRunDispatcher } from "./research-dispatcher.js"; import { ResearchStepRunner } from "./research-step-runner.js"; +import { ResearchProviderRegistry } from "./research/provider-registry.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { computeVerificationFailureSignature, @@ -457,9 +461,26 @@ export class ProjectEngine { const settings = await store.getSettings(); if (typeof (store as { getResearchStore?: () => unknown }).getResearchStore === "function") { + const registry = new ResearchProviderRegistry(settings, cwd); + const providers = registry.getAvailableProviders() + .map((type) => registry.getProvider(type)) + .filter((provider): provider is NonNullable => Boolean(provider)); + const synthesisProvider = registry.getProvider("llm-synthesis") as ({ + synthesize?: ( + request: ResearchSynthesisRequest, + modelSelection: { provider?: string; modelId?: string }, + signal?: AbortSignal, + ) => Promise; + } | undefined); + const synthesisRunner = typeof synthesisProvider?.synthesize === "function" + ? (request: ResearchSynthesisRequest, _modelSettings: ResearchModelSettings, signal?: AbortSignal) => synthesisProvider.synthesize!(request, { + provider: settings.researchGlobalDefaults?.synthesisProvider ?? settings.defaultProvider, + modelId: settings.researchGlobalDefaults?.synthesisModelId ?? settings.defaultModelId, + }, signal) + : undefined; this.researchOrchestrator = new ResearchOrchestrator({ store: store.getResearchStore(), - stepRunner: new ResearchStepRunner(), + stepRunner: new ResearchStepRunner({ providers, synthesisRunner }), maxConcurrentRuns: settings.researchMaxConcurrentRuns ?? 3, }); this.researchDispatcher = new ResearchRunDispatcher({ diff --git a/packages/engine/src/research-orchestrator.ts b/packages/engine/src/research-orchestrator.ts index 72be515a89..0fd4e76573 100644 --- a/packages/engine/src/research-orchestrator.ts +++ b/packages/engine/src/research-orchestrator.ts @@ -77,7 +77,7 @@ export class ResearchOrchestrator { 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 config = this.normalizeConfig(run.providerConfig); const controller = new AbortController(); if (options.abortSignal) { options.abortSignal.addEventListener("abort", () => controller.abort(options.abortSignal?.reason), { once: true }); @@ -542,6 +542,53 @@ export class ResearchOrchestrator { return 1 + providers + Math.max(1, config.maxSources) + Math.max(1, config.maxSynthesisRounds) + 1; } + private normalizeConfig(rawConfig: ResearchRun["providerConfig"]): ResearchOrchestrationConfig { + const raw = (rawConfig ?? {}) as Record; + const rawProviders = Array.isArray(raw.providers) ? raw.providers : []; + const providers = rawProviders + .map((provider): ResearchOrchestrationConfig["providers"][number] | null => { + if (typeof provider === "string") { + const type = provider.trim(); + return type && type !== "llm-synthesis" ? { type } : null; + } + if (provider && typeof provider === "object") { + const candidate = provider as { type?: unknown; config?: unknown }; + if (typeof candidate.type === "string" && candidate.type.trim() && candidate.type !== "llm-synthesis") { + return { + type: candidate.type.trim(), + config: candidate.config && typeof candidate.config === "object" + ? candidate.config as ResearchOrchestrationConfig["providers"][number]["config"] + : undefined, + }; + } + } + return null; + }) + .filter((provider): provider is ResearchOrchestrationConfig["providers"][number] => Boolean(provider)); + + const maxSources = this.positiveNumber(raw.maxSources) ?? this.positiveNumber(raw.maxResults) ?? 20; + const maxSynthesisRounds = this.positiveNumber(raw.maxSynthesisRounds) ?? 2; + + return { + providers: providers.length ? providers : [{ type: "web-search" }], + maxSources, + maxSynthesisRounds, + phaseTimeoutMs: this.positiveNumber(raw.phaseTimeoutMs), + stepTimeoutMs: this.positiveNumber(raw.stepTimeoutMs), + rateLimitPerMinute: this.positiveNumber(raw.rateLimitPerMinute), + synthesisModel: raw.synthesisModel && typeof raw.synthesisModel === "object" + ? raw.synthesisModel as ResearchOrchestrationConfig["synthesisModel"] + : undefined, + metadata: raw.metadata && typeof raw.metadata === "object" + ? raw.metadata as Record + : undefined, + }; + } + + private positiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; + } + private statusToPhase(status: ResearchRun["status"]): ResearchOrchestrationPhase { if (status === "completed") return "completed"; if (status === "failed" || status === "timed_out" || status === "retry_exhausted") return "failed";