feat(FN-3901): add builtin research web-search provider as default

The merge adds a builtin web-search research provider as the default, replacing the need for external research provider configuration and removing the setup gate for builtin defaults. Dashboard settings UX for research defaults was aligned (ResearchView, SettingsModal), CLI research commands were up

Fusion-Task-Id: FN-3901
This commit is contained in:
Fusion
2026-05-09 19:37:31 -07:00
committed by gsxdsm
parent 1caed5c586
commit d25e8cbfa9
21 changed files with 493 additions and 187 deletions

View File

@@ -6,16 +6,19 @@ describe("ResearchProviderRegistry", () => {
const registry = new ResearchProviderRegistry({}, process.cwd());
expect(registry.getProvider("web-search")).toBeDefined();
expect(registry.getProvider("page-fetch")).toBeDefined();
expect(registry.isProviderAvailable("web-search")).toBe(true);
expect(registry.isProviderAvailable("local-docs")).toBe(true);
expect(registry.isProviderAvailable("github")).toBe(false);
});
it("detects search backend from credentials", () => {
const tavily = new ResearchProviderRegistry({ researchGlobalTavilyApiKey: "key" }, process.cwd());
expect(tavily.isProviderAvailable("web-search")).toBe(true);
it("honors explicit none by disabling web-search", () => {
const registry = new ResearchProviderRegistry({ researchGlobalWebSearchProvider: "none" }, process.cwd());
expect(registry.isProviderAvailable("web-search")).toBe(false);
});
const searx = new ResearchProviderRegistry({ researchGlobalSearxngUrl: "https://sx.local" }, process.cwd());
expect(searx.isProviderAvailable("web-search")).toBe(true);
it("requires credentials for explicit external providers", () => {
const registry = new ResearchProviderRegistry({ researchGlobalWebSearchProvider: "tavily" }, process.cwd());
expect(registry.isProviderAvailable("web-search")).toBe(false);
});
it("returns only configured providers in available list", () => {
@@ -40,10 +43,4 @@ describe("ResearchProviderRegistry", () => {
registry.refreshSettings({ researchGlobalWebSearchProvider: "tavily", researchGlobalTavilyApiKey: "key" });
expect(registry.isProviderAvailable("web-search")).toBe(true);
});
it("gracefully degrades disabled providers", () => {
const registry = new ResearchProviderRegistry({ researchGlobalGitHubEnabled: false, researchGlobalLocalDocsEnabled: false }, process.cwd());
expect(registry.isProviderAvailable("github")).toBe(false);
expect(registry.isProviderAvailable("local-docs")).toBe(false);
});
});

View File

@@ -47,6 +47,8 @@ export class ResearchProviderRegistry {
const maxResults = Number(this.settings.researchGlobalMaxSearchResults ?? 10);
const fetchTimeoutMs = Number(this.settings.researchGlobalFetchTimeoutMs ?? 30_000);
const userAgent = this.settings.researchGlobalUserAgent ?? "FusionResearchBot/1.0";
const synthesisProvider = this.settings.researchGlobalDefaults?.synthesisProvider ?? this.settings.defaultProvider;
const synthesisModelId = this.settings.researchGlobalDefaults?.synthesisModelId ?? this.settings.defaultModelId;
this.providers = new Map<ResearchProviderType, ResearchProvider>([
[
@@ -61,6 +63,9 @@ export class ResearchProviderRegistry {
maxResults,
timeoutMs: fetchTimeoutMs,
userAgent,
projectRoot: this.projectRoot,
defaultProvider: synthesisProvider,
defaultModelId: synthesisModelId,
}),
],
["page-fetch", new PageFetchProvider({ timeoutMs: fetchTimeoutMs, userAgent })],
@@ -79,13 +84,8 @@ export class ResearchProviderRegistry {
private resolveSearchBackend(): WebSearchBackend {
const explicit = this.settings.researchGlobalWebSearchProvider;
if (explicit && explicit !== "none") return explicit;
if (this.settings.researchGlobalSearxngUrl) return "searxng";
if (this.settings.researchGlobalTavilyApiKey) return "tavily";
if (this.settings.researchGlobalBraveApiKey) return "brave";
if (this.settings.researchGlobalGoogleSearchApiKey && this.settings.researchGlobalGoogleSearchCx) return "google";
return "none";
if (explicit) return explicit;
return "builtin";
}
}

View File

@@ -1,12 +1,23 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ResearchProviderError } from "../../types.js";
import { WebSearchProvider } from "../web-search-provider.js";
const { createFnAgentMock, promptWithFallbackMock } = vi.hoisted(() => ({
createFnAgentMock: vi.fn(),
promptWithFallbackMock: vi.fn(),
}));
vi.mock("../../../pi.js", () => ({
createFnAgent: createFnAgentMock,
promptWithFallback: promptWithFallbackMock,
}));
describe("WebSearchProvider", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers();
createFnAgentMock.mockReset();
promptWithFallbackMock.mockReset();
});
afterEach(() => {
@@ -16,6 +27,7 @@ describe("WebSearchProvider", () => {
});
it("validates configuration for each backend", () => {
expect(new WebSearchProvider({ backend: "builtin", projectRoot: process.cwd() }).isConfigured()).toBe(true);
expect(new WebSearchProvider({ backend: "none" }).isConfigured()).toBe(false);
expect(new WebSearchProvider({ backend: "searxng", searxngUrl: "https://sx" }).isConfigured()).toBe(true);
expect(new WebSearchProvider({ backend: "brave", braveApiKey: "k" }).isConfigured()).toBe(true);
@@ -23,6 +35,58 @@ describe("WebSearchProvider", () => {
expect(new WebSearchProvider({ backend: "tavily", tavilyApiKey: "k" }).isConfigured()).toBe(true);
});
it("invokes builtin tools and maps json response", async () => {
const session = {
state: {
messages: [
{
role: "assistant",
content: "```json\n{\"results\":[{\"url\":\"https://a\",\"title\":\"A\",\"snippet\":\"Snippet\"}]}\n```",
},
],
},
dispose: vi.fn(),
};
createFnAgentMock.mockResolvedValue({ session });
promptWithFallbackMock.mockResolvedValue(undefined);
const provider = new WebSearchProvider({
backend: "builtin",
projectRoot: process.cwd(),
defaultProvider: "anthropic",
defaultModelId: "x",
});
const results = await provider.search("fusion", { maxResults: 3 });
expect(createFnAgentMock).toHaveBeenCalledWith(
expect.objectContaining({
builtinToolsAllowlist: ["WebSearch", "WebFetch"],
defaultProvider: "anthropic",
defaultModelId: "x",
}),
);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
reference: "https://a",
title: "A",
excerpt: "Snippet",
metadata: { backend: "builtin", rank: 1 },
});
});
it("throws provider-unavailable on malformed builtin response", async () => {
const session = {
state: { messages: [{ role: "assistant", content: "not-json" }] },
dispose: vi.fn(),
};
createFnAgentMock.mockResolvedValue({ session });
promptWithFallbackMock.mockResolvedValue(undefined);
const provider = new WebSearchProvider({ backend: "builtin", projectRoot: process.cwd() });
await expect(provider.search("fusion", {})).rejects.toMatchObject({ code: "provider-unavailable" });
});
it("normalizes searxng results", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
@@ -33,69 +97,6 @@ describe("WebSearchProvider", () => {
const results = await provider.search("fusion", {});
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
title: "A",
reference: "https://a",
excerpt: "Snippet",
metadata: { backend: "searxng", rank: 1 },
});
expect(results[0]).toMatchObject({ metadata: { backend: "searxng", rank: 1 } });
});
it("limits max results", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
web: {
results: [
{ url: "https://a", title: "A", description: "1" },
{ url: "https://b", title: "B", description: "2" },
],
},
}),
} as Response);
const provider = new WebSearchProvider({ backend: "brave", braveApiKey: "k" });
const results = await provider.search("fusion", { maxResults: 1 });
expect(results).toHaveLength(1);
});
it("retries 429 and succeeds", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 429 } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ items: [{ link: "https://x", title: "X", snippet: "S" }] }),
} as Response);
const provider = new WebSearchProvider({ backend: "google", googleApiKey: "k", googleCx: "cx" });
const promise = provider.search("fusion", {});
await vi.runAllTimersAsync();
const results = await promise;
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(results[0]?.reference).toBe("https://x");
});
it("classifies auth failures", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401 } as Response);
const provider = new WebSearchProvider({ backend: "tavily", tavilyApiKey: "k" });
await expect(provider.search("fusion", {})).rejects.toMatchObject({ code: "auth-failed" });
});
it("propagates abort signal", async () => {
global.fetch = vi.fn().mockImplementation(async (_url, init: RequestInit) => {
await new Promise((_, reject) => {
init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
});
return { ok: true, json: async () => ({}) } as Response;
});
const controller = new AbortController();
const provider = new WebSearchProvider({ backend: "brave", braveApiKey: "k" });
const pending = provider.search("fusion", {}, controller.signal);
controller.abort();
await expect(pending).rejects.toMatchObject({ code: "abort", providerType: "web-search" });
});
});

View File

@@ -1,6 +1,7 @@
import type { ResearchProviderConfig, ResearchSource, WebSearchBackend } from "@fusion/core";
import type { ResearchProvider } from "../../research-step-runner.js";
import { createLogger } from "../../logger.js";
import { createFnAgent, promptWithFallback } from "../../pi.js";
import { ResearchProviderError } from "../types.js";
const log = createLogger("research:web-search");
@@ -15,6 +16,9 @@ export interface WebSearchProviderOptions {
maxResults?: number;
timeoutMs?: number;
userAgent?: string;
projectRoot?: string;
defaultProvider?: string;
defaultModelId?: string;
}
const DEFAULT_MAX_RESULTS = 10;
@@ -29,7 +33,8 @@ export class WebSearchProvider implements ResearchProvider {
constructor(private readonly options: WebSearchProviderOptions = {}) {}
isConfigured(): boolean {
const backend = this.options.backend ?? "none";
const backend = this.options.backend ?? "builtin";
if (backend === "builtin") return true;
if (backend === "none") return false;
if (backend === "searxng") return Boolean(this.options.searxngUrl);
if (backend === "brave") return Boolean(this.options.braveApiKey);
@@ -39,7 +44,7 @@ export class WebSearchProvider implements ResearchProvider {
}
async search(query: string, config: ResearchProviderConfig = {}, signal?: AbortSignal): Promise<ResearchSource[]> {
const backend = this.options.backend ?? "none";
const backend = this.options.backend ?? "builtin";
if (!this.isConfigured()) return [];
const maxResults = Number(config.maxResults ?? this.options.maxResults ?? DEFAULT_MAX_RESULTS);
@@ -48,6 +53,7 @@ export class WebSearchProvider implements ResearchProvider {
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const results = await this.withHttpRetry(async () => {
if (backend === "builtin") return this.searchBuiltin(query, maxResults, requestSignal);
if (backend === "searxng") return this.searchSearxng(query, maxResults, requestSignal);
if (backend === "brave") return this.searchBrave(query, maxResults, requestSignal);
if (backend === "google") return this.searchGoogle(query, maxResults, requestSignal);
@@ -77,6 +83,76 @@ export class WebSearchProvider implements ResearchProvider {
return { content: "", metadata: {} };
}
private async searchBuiltin(query: string, maxResults: number, signal: AbortSignal) {
if (!this.options.projectRoot) {
throw new ResearchProviderError({
providerType: "web-search",
code: "provider-unavailable",
message: "Built-in web search requires a project root",
});
}
try {
const { session } = await createFnAgent({
cwd: this.options.projectRoot,
tools: "readonly",
builtinToolsAllowlist: ["WebSearch", "WebFetch"],
systemPrompt: "You return concise web search results as JSON.",
defaultProvider: this.options.defaultProvider,
defaultModelId: this.options.defaultModelId,
});
try {
const prompt = [
`Run WebSearch for the query: ${query}`,
`Return at most ${maxResults} results as strict JSON with this shape only:`,
'{"results": [{"url": "string", "title": "string", "snippet": "string"}]}',
"Do not include commentary.",
].join("\n");
await Promise.race([
promptWithFallback(session, prompt),
new Promise<never>((_, reject) => {
signal.addEventListener(
"abort",
() =>
reject(
new ResearchProviderError({
providerType: "web-search",
code: signal.reason instanceof Error && signal.reason.name === "TimeoutError" ? "timeout" : "abort",
message: signal.reason instanceof Error && signal.reason.name === "TimeoutError" ? "Search timed out" : "Search aborted",
}),
),
{ once: true },
);
}),
]);
const assistantText = extractAssistantText(session);
if (!assistantText) {
throw new ResearchProviderError({
providerType: "web-search",
code: "provider-unavailable",
message: "No builtin search response received",
});
}
const parsed = parseBuiltinResults(assistantText);
return parsed.results.slice(0, maxResults);
} finally {
session.dispose();
}
} catch (error) {
if (error instanceof ResearchProviderError) throw error;
throw new ResearchProviderError({
providerType: "web-search",
code: "provider-unavailable",
message: error instanceof Error ? error.message : "Built-in search failed",
cause: error,
});
}
}
private async searchSearxng(query: string, maxResults: number, signal: AbortSignal) {
const base = this.options.searxngUrl?.replace(/\/$/, "") ?? "";
const url = new URL(`${base}/search`);
@@ -257,6 +333,46 @@ export class WebSearchProvider implements ResearchProvider {
}
}
function extractAssistantText(session: { state?: { messages?: Array<{ role?: string; content?: unknown }> } }): string | undefined {
const messages = session.state?.messages;
if (!Array.isArray(messages)) return undefined;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
if (typeof msg.content === "string" && msg.content.trim()) return msg.content;
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (typeof part === "object" && part && "text" in part && typeof (part as { text?: unknown }).text === "string") {
return (part as { text: string }).text;
}
}
}
}
return undefined;
}
function parseBuiltinResults(text: string): { results: Array<{ url: string; title: string; snippet: string }> } {
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? text;
try {
const parsed = JSON.parse(jsonBlock) as { results?: Array<{ url?: unknown; title?: unknown; snippet?: unknown }> };
const results = (parsed.results ?? [])
.filter((entry) => typeof entry.url === "string")
.map((entry) => ({
url: entry.url as string,
title: typeof entry.title === "string" ? entry.title : (entry.url as string),
snippet: typeof entry.snippet === "string" ? entry.snippet : "",
}));
return { results };
} catch (error) {
throw new ResearchProviderError({
providerType: "web-search",
code: "provider-unavailable",
message: "Built-in search returned malformed JSON",
cause: error,
});
}
}
async function sleep(ms: number, signal: AbortSignal): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);