fix(engine): normalize dashboard research providers
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<typeof provider> => Boolean(provider));
|
||||
const synthesisProvider = registry.getProvider("llm-synthesis") as ({
|
||||
synthesize?: (
|
||||
request: ResearchSynthesisRequest,
|
||||
modelSelection: { provider?: string; modelId?: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<ResearchSynthesisResult>;
|
||||
} | 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({
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>
|
||||
: 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";
|
||||
|
||||
Reference in New Issue
Block a user