feat(FN-2993): merge fusion/fn-2993

Commits merged:
- fix(FN-2993): resolve lint and build verification issues
- fix(FN-2993): address provider registry review feedback
- feat(FN-2993): complete Step 7 — add provider registry
- feat(FN-2993): complete Step 6 — add LLM synthesis provider
- feat(FN-2993): complete Step 5 — add local docs provider
- feat(FN-2993): complete Step 4 — add GitHub provider
- fix(FN-2993): align provider config types with step runner contract
- fix(FN-2993): resolve page fetch test module ordering
- test(FN-2993): expand page fetch coverage for timeout and truncation
- feat(FN-2993): complete Step 3 — add page fetch provider
- fix(FN-2993): align web search provider interface and abort assertions
- feat(FN-2993): complete Step 2 — add web search provider
- feat(FN-2993): complete Step 1 — define provider types and settings fields

Files changed:
packages/core/src/index.ts                         |   2 +-
 packages/core/src/settings-schema.ts               |  11 +
 packages/core/src/types.ts                         |  25 ++
 .../app/components/CustomProvidersSection.tsx      |   1 -
 packages/engine/src/index.ts                       |  19 ++
 .../research/__tests__/provider-registry.test.ts   |  49 +++
 packages/engine/src/research/provider-registry.ts  | 110 +++++++
 .../providers/__tests__/github-provider.test.ts    | 143 +++++++++
 .../__tests__/llm-synthesis-provider.test.ts       | 107 +++++++
 .../__tests__/local-docs-provider.test.ts          |  79 +++++
 .../__tests__/page-fetch-provider.test.ts          |  92 ++++++
 .../__tests__/web-search-provider.test.ts          | 101 +++++++
 .../src/research/providers/github-provider.ts      | 327 +++++++++++++++++++++
 packages/engine/src/research/providers/index.ts    |   5 +
 .../research/providers/llm-synthesis-provider.ts   | 216 ++++++++++++++
 .../src/research/providers/local-docs-provider.ts  | 238 +++++++++++++++
 .../src/research/providers/page-fetch-provider.ts  | 123 ++++++++
 .../src/research/providers/web-search-provider.ts  | 269 +++++++++++++++++
 packages/engine/src/research/types.ts              |  50 ++++
 19 files changed, 1965 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-2993
This commit is contained in:
Fusion
2026-04-30 02:19:38 -07:00
committed by gsxdsm
parent a227d5b305
commit 9bea0a1367
19 changed files with 1965 additions and 2 deletions

View File

@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { runGhAsyncMock, runGhJsonAsyncMock, isGhAvailableMock, isGhAuthenticatedMock } = vi.hoisted(() => ({
runGhAsyncMock: vi.fn(),
runGhJsonAsyncMock: vi.fn(),
isGhAvailableMock: vi.fn(),
isGhAuthenticatedMock: vi.fn(),
}));
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
runGhAsync: runGhAsyncMock,
runGhJsonAsync: runGhJsonAsyncMock,
isGhAvailable: isGhAvailableMock,
isGhAuthenticated: isGhAuthenticatedMock,
};
});
import { GitHubProvider } from "../github-provider.js";
describe("GitHubProvider", () => {
beforeEach(() => {
vi.clearAllMocks();
isGhAvailableMock.mockReturnValue(true);
isGhAuthenticatedMock.mockReturnValue(true);
});
it("searches repositories", async () => {
runGhJsonAsyncMock.mockResolvedValueOnce([
{ fullName: "org/repo", description: "desc", htmlUrl: "https://github.com/org/repo", stargazersCount: 12, language: "ts", updatedAt: "2026" },
]);
const provider = new GitHubProvider();
const results = await provider.search("query", { metadata: { searchType: "repos" } });
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({ type: "github", title: "org/repo", reference: "https://github.com/org/repo" });
});
it("searches issues", async () => {
runGhJsonAsyncMock.mockResolvedValueOnce([
{ title: "Issue", body: "body", htmlUrl: "https://github.com/org/repo/issues/1", state: "open", labels: [{ name: "bug" }] },
]);
const provider = new GitHubProvider();
const results = await provider.search("query", { metadata: { searchType: "issues" } });
expect(results[0]?.metadata).toMatchObject({ resultType: "issue", state: "open", labels: ["bug"] });
});
it("supports combined search", async () => {
runGhJsonAsyncMock
.mockResolvedValueOnce([{ fullName: "org/repo", htmlUrl: "https://github.com/org/repo" }])
.mockResolvedValueOnce([{ title: "Issue", htmlUrl: "https://github.com/org/repo/issues/1" }]);
const provider = new GitHubProvider();
const results = await provider.search("query", {});
expect(results).toHaveLength(2);
});
it("fetches repo README", async () => {
runGhJsonAsyncMock.mockResolvedValueOnce({
content: Buffer.from("# Hello").toString("base64"),
encoding: "base64",
name: "README.md",
});
const provider = new GitHubProvider();
const result = await provider.fetchContent("https://github.com/org/repo", {});
expect(result.content).toContain("# Hello");
expect(result.metadata).toMatchObject({ kind: "repo-readme" });
});
it("fetches issue content", async () => {
runGhAsyncMock.mockResolvedValueOnce("Issue body\nComments");
const provider = new GitHubProvider();
const result = await provider.fetchContent("https://github.com/org/repo/issues/123", {});
expect(result.content).toContain("Issue body");
expect(result.metadata).toMatchObject({ kind: "issue", number: "123" });
});
it("fetches pr content", async () => {
runGhAsyncMock.mockResolvedValueOnce("PR body\nComments");
const provider = new GitHubProvider();
const result = await provider.fetchContent("https://github.com/org/repo/pull/9", {});
expect(result.metadata).toMatchObject({ kind: "pr", number: "9" });
});
it("fetches file content from blob url", async () => {
runGhJsonAsyncMock.mockResolvedValueOnce({
content: Buffer.from("file content").toString("base64"),
encoding: "base64",
name: "index.ts",
});
const provider = new GitHubProvider();
const result = await provider.fetchContent("https://github.com/org/repo/blob/main/src/index.ts", {});
expect(result.content).toContain("file content");
expect(result.metadata).toMatchObject({ kind: "file", path: "src/index.ts" });
});
it("reports configuration state", () => {
const provider = new GitHubProvider();
expect(provider.isConfigured()).toBe(true);
isGhAvailableMock.mockReturnValue(false);
expect(provider.isConfigured()).toBe(false);
isGhAvailableMock.mockReturnValue(true);
isGhAuthenticatedMock.mockReturnValue(false);
expect(provider.isConfigured()).toBe(false);
});
it("maps abort and timeout errors", async () => {
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("gh command aborted"), { code: "ABORT_ERR", stderr: "", stdout: "" }));
const provider = new GitHubProvider();
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "abort" });
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("gh command timed out after 30000ms"), { code: null, stderr: "", stdout: "" }));
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "timeout" });
});
it("maps rate limit and auth failures", async () => {
const provider = new GitHubProvider();
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("API rate limit exceeded"), { code: 403, stderr: "", stdout: "" }));
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "rate-limited", retryable: true });
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("authentication required"), { code: 401, stderr: "", stdout: "" }));
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "auth-failed" });
});
it("errors on unsupported urls", async () => {
const provider = new GitHubProvider();
await expect(provider.fetchContent("https://example.com/a", {})).rejects.toMatchObject({ code: "provider-unavailable" });
});
});

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createFnAgentMock, promptWithFallbackMock, disposeMock } = vi.hoisted(() => ({
disposeMock: vi.fn(),
createFnAgentMock: vi.fn(),
promptWithFallbackMock: vi.fn(),
}));
vi.mock("../../../pi.js", () => ({
createFnAgent: createFnAgentMock,
promptWithFallback: promptWithFallbackMock,
}));
import { LLMSynthesisProvider, buildSynthesisPrompt, extractCitations } from "../llm-synthesis-provider.js";
describe("LLMSynthesisProvider", () => {
beforeEach(() => {
vi.clearAllMocks();
const session = { state: { messages: [] as Array<{ role: string; content: string }> }, dispose: disposeMock };
createFnAgentMock.mockResolvedValue({ session });
promptWithFallbackMock.mockImplementation(async (s: typeof session, _prompt: string) => {
s.state.messages.push({ role: "assistant", content: '```json\n{"summary":"ok","confidence":0.8}\n```\n[1]' });
});
});
it("builds synthesis output with citations", async () => {
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
const result = await provider.synthesize(
{
query: "q",
round: 1,
sources: [{ id: "1", type: "web", reference: "https://a", status: "completed", title: "A", content: "hello" }],
},
{ provider: "openai", modelId: "gpt-5" },
);
expect(promptWithFallbackMock).toHaveBeenCalled();
expect(result.citations).toEqual(["https://a"]);
expect(result.confidence).toBe(0.8);
expect(disposeMock).toHaveBeenCalled();
});
it("includes query and sources in prompt", () => {
const prompt = buildSynthesisPrompt(
{ query: "query", round: 2, sources: [{ id: "s", type: "web", reference: "https://x", status: "completed", title: "Title", content: "Body" }] },
[{ id: "s", type: "web", reference: "https://x", status: "completed", title: "Title", content: "Body" }],
);
expect(prompt).toContain("Query: query");
expect(prompt).toContain("Reference: https://x");
expect(prompt).toContain("Return valid JSON");
});
it("extracts citations by index", () => {
const citations = extractCitations("Findings [1] and [2]", [
{ id: "1", type: "web", reference: "a", status: "completed" },
{ id: "2", type: "web", reference: "b", status: "completed" },
]);
expect(citations).toEqual(["a", "b"]);
});
it("maps abort and timeout", async () => {
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp", timeoutMs: 20 });
promptWithFallbackMock.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
await expect(
provider.synthesize({ query: "q", round: 1, sources: [] }, { provider: "openai", modelId: "gpt-4o" }),
).rejects.toMatchObject({ code: "timeout" });
promptWithFallbackMock.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 80));
});
const controller = new AbortController();
const pending = provider.synthesize({ query: "q", round: 1, sources: [] }, { provider: "openai", modelId: "gpt-4o" }, controller.signal);
controller.abort();
await expect(pending).rejects.toMatchObject({ code: "abort" });
});
it("reports provider availability", () => {
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
expect(provider.isConfigured()).toBe(true);
});
it("truncates sources to character budget", async () => {
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
const manySources = Array.from({ length: 30 }, (_, i) => ({
id: String(i),
type: "web" as const,
reference: `https://src/${i}`,
status: "completed" as const,
content: "x".repeat(8000),
metadata: { confidence: i },
}));
await provider.synthesize({ query: "q", round: 1, sources: manySources }, { provider: "openai", modelId: "small-model" });
const promptArg = promptWithFallbackMock.mock.calls[0]?.[1] as string;
expect(promptArg.length).toBeLessThan(35_000);
});
it("maps model failures", async () => {
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
promptWithFallbackMock.mockRejectedValueOnce(new Error("model down"));
await expect(provider.synthesize({ query: "q", round: 1, sources: [] }, { provider: "openai", modelId: "gpt-4o" })).rejects.toMatchObject({
code: "provider-unavailable",
});
});
});

View File

@@ -0,0 +1,79 @@
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { afterEach, describe, expect, it } from "vitest";
import { LocalDocsProvider } from "../local-docs-provider.js";
describe("LocalDocsProvider", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
tempDirs.length = 0;
});
function makeProject() {
const root = mkdtempSync(join(os.tmpdir(), "fn-test-research-"));
tempDirs.push(root);
mkdirSync(join(root, "docs"), { recursive: true });
writeFileSync(join(root, "README.md"), "Fusion research provider docs");
writeFileSync(join(root, "docs", "guide.md"), "This guide covers provider architecture and confidence scoring");
return root;
}
it("searches keywords across docs", async () => {
const root = makeProject();
const provider = new LocalDocsProvider({ projectRoot: root });
const results = await provider.search("provider confidence", {});
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.reference).toContain("docs/guide.md");
});
it("fetches local file content", async () => {
const root = makeProject();
const provider = new LocalDocsProvider({ projectRoot: root });
const result = await provider.fetchContent("README.md", {});
expect(result.content).toContain("Fusion research");
expect(result.metadata).toMatchObject({ extension: ".md" });
});
it("prevents path traversal", async () => {
const root = makeProject();
const provider = new LocalDocsProvider({ projectRoot: root });
await expect(provider.fetchContent("../../etc/passwd", {})).rejects.toMatchObject({ code: "provider-unavailable" });
});
it("skips binary files during search", async () => {
const root = makeProject();
writeFileSync(join(root, "docs", "binary.dat"), Buffer.from([0, 1, 2, 3, 4]));
const provider = new LocalDocsProvider({ projectRoot: root });
const results = await provider.search("binary", {});
expect(results.find((entry) => String(entry.reference).includes("binary.dat"))).toBeUndefined();
});
it("skips oversized files during search", async () => {
const root = makeProject();
writeFileSync(join(root, "docs", "large.md"), "x".repeat(1024 * 1024 + 128));
const provider = new LocalDocsProvider({ projectRoot: root });
const results = await provider.search("xxxx", {});
expect(results.find((entry) => String(entry.reference).includes("large.md"))).toBeUndefined();
});
it("supports abort signal", async () => {
const root = makeProject();
for (let i = 0; i < 20; i += 1) {
writeFileSync(join(root, "docs", `doc-${i}.md`), `line ${i}`);
}
const provider = new LocalDocsProvider({ projectRoot: root });
const controller = new AbortController();
const promise = provider.search("line", {}, controller.signal);
controller.abort();
await expect(promise).rejects.toMatchObject({ code: "abort" });
});
});

View File

@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { MAX_CONTENT_CHARS, PageFetchProvider } from "../page-fetch-provider.js";
describe("PageFetchProvider", () => {
const originalFetch = global.fetch;
afterEach(() => {
vi.restoreAllMocks();
global.fetch = originalFetch;
});
it("extracts readable html content", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "text/html" }),
text: async () =>
"<html><head><title>Hello</title><meta name='description' content='Desc'></head><body><main><h1>Title</h1><p>Body</p></main></body></html>",
} as Response);
const provider = new PageFetchProvider();
const result = await provider.fetchContent("https://example.com", {});
expect(result.content).toContain("Title Body");
expect(result.metadata).toMatchObject({ title: "Hello", description: "Desc", contentType: "text/html" });
expect(result.mimeType).toBe("text/html");
});
it("pretty prints json", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "application/json" }),
text: async () => '{"a":1}',
} as Response);
const provider = new PageFetchProvider();
const result = await provider.fetchContent("https://example.com", {});
expect(result.content).toContain('"a": 1');
});
it("passes through text", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "text/plain" }),
text: async () => "hello world",
} as Response);
const provider = new PageFetchProvider();
const result = await provider.fetchContent("https://example.com", {});
expect(result.content).toBe("hello world");
});
it("maps status errors", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 } as Response);
const provider = new PageFetchProvider();
await expect(provider.fetchContent("https://example.com", {})).rejects.toMatchObject({ code: "network-error" });
});
it("maps 500 as provider unavailable", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response);
const provider = new PageFetchProvider();
await expect(provider.fetchContent("https://example.com", {})).rejects.toMatchObject({ code: "provider-unavailable" });
});
it("truncates large content", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "text/plain" }),
text: async () => "x".repeat(600 * 1024),
} as Response);
const provider = new PageFetchProvider();
const result = await provider.fetchContent("https://example.com", {});
expect(result.content.length).toBe(MAX_CONTENT_CHARS);
});
it("maps timeout", async () => {
global.fetch = vi.fn().mockRejectedValue(Object.assign(new Error("Timed out"), { name: "TimeoutError" }));
const provider = new PageFetchProvider({ timeoutMs: 50 });
await expect(provider.fetchContent("https://example.com", {})).rejects.toMatchObject({ code: "timeout" });
});
it("maps abort", 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, headers: new Headers(), text: async () => "" } as Response;
});
const provider = new PageFetchProvider();
const controller = new AbortController();
const pending = provider.fetchContent("https://example.com", {}, controller.signal);
controller.abort();
await expect(pending).rejects.toMatchObject({ code: "abort" });
});
});

View File

@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ResearchProviderError } from "../../types.js";
import { WebSearchProvider } from "../web-search-provider.js";
describe("WebSearchProvider", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
global.fetch = originalFetch;
});
it("validates configuration for each backend", () => {
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);
expect(new WebSearchProvider({ backend: "google", googleApiKey: "k", googleCx: "cx" }).isConfigured()).toBe(true);
expect(new WebSearchProvider({ backend: "tavily", tavilyApiKey: "k" }).isConfigured()).toBe(true);
});
it("normalizes searxng results", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: [{ url: "https://a", title: "A", content: "Snippet" }] }),
} as Response);
const provider = new WebSearchProvider({ backend: "searxng", searxngUrl: "https://sx" });
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 },
});
});
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

@@ -0,0 +1,327 @@
import {
isGhAuthenticated,
isGhAvailable,
runGhAsync,
runGhJsonAsync,
type GhError,
type ResearchProviderConfig,
type ResearchSource,
} from "@fusion/core";
import type { ResearchProvider } from "../../research-step-runner.js";
import { createLogger } from "../../logger.js";
import { ResearchProviderError, type ResearchFetchResult } from "../types.js";
const log = createLogger("research:github");
const DEFAULT_TIMEOUT_MS = 30_000;
type SearchType = "repos" | "issues" | "both";
interface GitHubRepoResult {
fullName: string;
description?: string;
htmlUrl: string;
stargazersCount?: number;
language?: string;
updatedAt?: string;
}
interface GitHubIssueResult {
title: string;
body?: string;
htmlUrl: string;
state?: string;
labels?: Array<{ name?: string }>;
}
interface ParsedGitHubUrl {
owner: string;
repo: string;
kind: "repo" | "issue" | "pr" | "file";
number?: string;
filePath?: string;
ref?: string;
}
export class GitHubProvider implements ResearchProvider {
readonly type = "github";
async search(query: string, config: ResearchProviderConfig = {}, signal?: AbortSignal): Promise<ResearchSource[]> {
const timeoutMs = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const maxResults = Number(config.maxResults ?? 10);
const searchType = ((config.metadata?.searchType as SearchType | undefined) ?? "both");
const sources: ResearchSource[] = [];
try {
if (searchType === "repos" || searchType === "both") {
const repos = await runGhJsonAsync<GitHubRepoResult[]>(
[
"search",
"repos",
query,
"--json",
"fullName,description,htmlUrl,stargazersCount,language,updatedAt",
"--limit",
String(maxResults),
],
{ signal, timeoutMs },
);
sources.push(
...repos.slice(0, maxResults).map((repo, idx) => ({
id: `github-repo-${idx}-${repo.htmlUrl}`,
type: "github" as const,
reference: repo.htmlUrl,
title: repo.fullName,
excerpt: repo.description ?? "",
status: "completed" as const,
metadata: {
resultType: "repo",
stars: repo.stargazersCount ?? 0,
language: repo.language,
updatedAt: repo.updatedAt,
rank: idx + 1,
},
})),
);
}
if (searchType === "issues" || searchType === "both") {
const issues = await runGhJsonAsync<GitHubIssueResult[]>(
[
"search",
"issues",
query,
"--json",
"title,body,htmlUrl,state,labels",
"--limit",
String(maxResults),
],
{ signal, timeoutMs },
);
sources.push(
...issues.slice(0, maxResults).map((issue, idx) => ({
id: `github-issue-${idx}-${issue.htmlUrl}`,
type: "github" as const,
reference: issue.htmlUrl,
title: issue.title,
excerpt: issue.body?.slice(0, 280) ?? "",
status: "completed" as const,
metadata: {
resultType: "issue",
state: issue.state,
labels: (issue.labels ?? []).map((label) => label.name).filter(Boolean),
rank: idx + 1,
},
})),
);
}
return sources;
} catch (error) {
throw this.mapGhError(error);
}
}
async fetchContent(url: string, config: ResearchProviderConfig = {}, signal?: AbortSignal): Promise<ResearchFetchResult> {
const parsed = parseGitHubUrl(url);
if (!parsed) {
throw new ResearchProviderError({
providerType: "github",
code: "provider-unavailable",
message: "Unsupported GitHub URL",
});
}
const timeoutMs = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
try {
if (parsed.kind === "repo") {
const readme = await runGhJsonAsync<{ content?: string; encoding?: string; name?: string }>(
["api", `repos/${parsed.owner}/${parsed.repo}/readme`],
{ signal, timeoutMs },
);
if (readme.encoding !== "base64" || !readme.content) {
throw new ResearchProviderError({
providerType: "github",
code: "provider-unavailable",
message: "Unsupported README encoding",
});
}
const content = Buffer.from(readme.content.replace(/\n/g, ""), "base64").toString("utf-8");
return {
content,
metadata: {
url,
owner: parsed.owner,
repo: parsed.repo,
kind: "repo-readme",
name: readme.name,
},
mimeType: "text/markdown",
};
}
if (parsed.kind === "issue") {
const issue = await runGhAsync(["issue", "view", parsed.number ?? "", "--repo", `${parsed.owner}/${parsed.repo}`, "--comments"], {
signal,
timeoutMs,
});
return {
content: issue,
metadata: { url, owner: parsed.owner, repo: parsed.repo, kind: "issue", number: parsed.number },
mimeType: "text/plain",
};
}
if (parsed.kind === "pr") {
const pr = await runGhAsync(["pr", "view", parsed.number ?? "", "--repo", `${parsed.owner}/${parsed.repo}`, "--comments"], {
signal,
timeoutMs,
});
return {
content: pr,
metadata: { url, owner: parsed.owner, repo: parsed.repo, kind: "pr", number: parsed.number },
mimeType: "text/plain",
};
}
const apiPath = `repos/${parsed.owner}/${parsed.repo}/contents/${parsed.filePath ?? ""}${parsed.ref ? `?ref=${encodeURIComponent(parsed.ref)}` : ""}`;
const file = await runGhJsonAsync<{ content?: string; encoding?: string; name?: string }>(["api", apiPath], {
signal,
timeoutMs,
});
const content = file.encoding === "base64" && file.content
? Buffer.from(file.content.replace(/\n/g, ""), "base64").toString("utf-8")
: (file.content ?? "");
return {
content,
metadata: {
url,
owner: parsed.owner,
repo: parsed.repo,
kind: "file",
path: parsed.filePath,
ref: parsed.ref,
name: file.name,
},
mimeType: "text/plain",
};
} catch (error) {
throw this.mapGhError(error);
}
}
isConfigured(): boolean {
return isGhAvailable() && isGhAuthenticated();
}
private mapGhError(error: unknown): ResearchProviderError {
if (error instanceof ResearchProviderError) return error;
if (isGhError(error)) {
const message = `${error.message}${error.stderr ? `: ${error.stderr}` : ""}`;
const lowered = message.toLowerCase();
if (error.code === "ABORT_ERR" || lowered.includes("aborted")) {
return new ResearchProviderError({ providerType: "github", code: "abort", message, cause: error });
}
if (lowered.includes("timed out")) {
return new ResearchProviderError({
providerType: "github",
code: "timeout",
message,
retryable: true,
cause: error,
});
}
if (error.code === 403 || lowered.includes("rate limit")) {
return new ResearchProviderError({
providerType: "github",
code: "rate-limited",
message,
retryable: true,
cause: error,
});
}
if (error.code === 401 || lowered.includes("not logged") || lowered.includes("authentication")) {
return new ResearchProviderError({
providerType: "github",
code: "auth-failed",
message,
cause: error,
});
}
if (error.code === 404 || lowered.includes("not found")) {
return new ResearchProviderError({
providerType: "github",
code: "provider-unavailable",
message,
cause: error,
});
}
return new ResearchProviderError({
providerType: "github",
code: "network-error",
message,
retryable: true,
cause: error,
});
}
log.warn("github provider error", { error });
return new ResearchProviderError({
providerType: "github",
code: "network-error",
message: error instanceof Error ? error.message : "GitHub provider failed",
retryable: true,
cause: error,
});
}
}
function isGhError(value: unknown): value is GhError {
return value instanceof Error && "stderr" in value && "stdout" in value && "code" in value;
}
function parseGitHubUrl(url: string): ParsedGitHubUrl | null {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return null;
}
if (!/(^|\.)github\.com$/i.test(parsedUrl.hostname)) {
return null;
}
const segments = parsedUrl.pathname.split("/").filter(Boolean);
if (segments.length < 2) return null;
const [owner, repo, section, ...rest] = segments;
if (!section) {
return { owner, repo, kind: "repo" };
}
if (section === "issues" && rest[0]) {
return { owner, repo, kind: "issue", number: rest[0] };
}
if (section === "pull" && rest[0]) {
return { owner, repo, kind: "pr", number: rest[0] };
}
if (section === "blob" && rest.length >= 2) {
return { owner, repo, kind: "file", ref: rest[0], filePath: rest.slice(1).join("/") };
}
return null;
}
export { parseGitHubUrl };

View File

@@ -0,0 +1,5 @@
export { WebSearchProvider, type WebSearchProviderOptions } from "./web-search-provider.js";
export { PageFetchProvider, type PageFetchProviderOptions } from "./page-fetch-provider.js";
export { GitHubProvider } from "./github-provider.js";
export { LocalDocsProvider, type LocalDocsProviderOptions } from "./local-docs-provider.js";
export { LLMSynthesisProvider, type LLMSynthesisProviderOptions } from "./llm-synthesis-provider.js";

View File

@@ -0,0 +1,216 @@
import type { ResearchProviderConfig, ResearchSource, ResearchSynthesisRequest, ResearchSynthesisResult, ResolvedModelSelection } 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:llm-synthesis");
const DEFAULT_TIMEOUT_MS = 120_000;
const LARGE_MODEL_CONTEXT_CHARS = 100_000;
const SMALL_MODEL_CONTEXT_CHARS = 30_000;
export interface LLMSynthesisProviderOptions {
projectRoot: string;
timeoutMs?: number;
}
export class LLMSynthesisProvider implements ResearchProvider {
readonly type = "llm-synthesis";
constructor(private readonly options: LLMSynthesisProviderOptions) {}
isConfigured(): boolean {
return true;
}
async search(_query: string, _options: ResearchProviderConfig = {}, _signal?: AbortSignal): Promise<ResearchSource[]> {
return [];
}
async fetchContent(
_url: string,
_options: ResearchProviderConfig = {},
_signal?: AbortSignal,
): Promise<{ content: string; metadata: Record<string, unknown> }> {
return { content: "", metadata: {} };
}
async synthesize(
request: ResearchSynthesisRequest,
modelSelection: ResolvedModelSelection,
signal?: AbortSignal,
): Promise<ResearchSynthesisResult> {
if (!modelSelection?.provider || !modelSelection?.modelId) {
throw new ResearchProviderError({ providerType: "llm-synthesis", code: "provider-unavailable", message: "Synthesis model is not configured" });
}
const timeoutSignal = AbortSignal.timeout(this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
try {
const cappedSources = this.applySourceBudget(request.sources, modelSelection);
const prompt = buildSynthesisPrompt(request, cappedSources);
const { session } = await createFnAgent({
cwd: this.options.projectRoot,
tools: "readonly",
systemPrompt: "You synthesize research findings into concise, cited outputs.",
defaultProvider: modelSelection.provider,
defaultModelId: modelSelection.modelId,
});
try {
await Promise.race([
promptWithFallback(session, prompt),
new Promise<never>((_, reject) => {
requestSignal.addEventListener(
"abort",
() => reject(new ResearchProviderError({
providerType: "llm-synthesis",
code: signal?.aborted ? "abort" : "timeout",
message: signal?.aborted ? "Synthesis aborted" : "Synthesis timed out",
retryable: !signal?.aborted,
})),
{ once: true },
);
}),
]);
if (requestSignal.aborted) {
throw new ResearchProviderError({ providerType: "llm-synthesis", code: signal?.aborted ? "abort" : "timeout", message: signal?.aborted ? "Synthesis aborted" : "Synthesis timed out", retryable: !signal?.aborted });
}
const responseText = extractAssistantText(session);
if (!responseText) {
throw new ResearchProviderError({ providerType: "llm-synthesis", code: "provider-unavailable", message: "No synthesis response received" });
}
return {
output: responseText,
citations: extractCitations(responseText, cappedSources),
confidence: extractConfidence(responseText),
metadata: {
sourceCount: cappedSources.length,
truncated: cappedSources.length < request.sources.length,
},
};
} finally {
session.dispose();
}
} catch (error) {
if (error instanceof ResearchProviderError) throw error;
if (error instanceof DOMException && error.name === "AbortError") {
throw new ResearchProviderError({ providerType: "llm-synthesis", code: "abort", message: "Synthesis aborted", cause: error });
}
log.warn("llm synthesis failed", { error });
throw new ResearchProviderError({
providerType: "llm-synthesis",
code: "provider-unavailable",
message: error instanceof Error ? error.message : "Synthesis failed",
retryable: true,
cause: error,
});
}
}
private applySourceBudget(sources: ResearchSource[], modelSelection: ResolvedModelSelection): ResearchSource[] {
// Approximation: 1 token ~= 4 English chars. We cap at 80% of an estimated
// context window to preserve room for prompt/instructions and response.
const contextChars = isLargeModel(modelSelection.modelId) ? LARGE_MODEL_CONTEXT_CHARS : SMALL_MODEL_CONTEXT_CHARS;
const budget = Math.floor(contextChars * 0.8);
const ordered = [...sources].sort((a, b) => scoreSource(b) - scoreSource(a));
let total = 0;
const kept: ResearchSource[] = [];
for (const source of ordered) {
const chunk = `${source.title ?? ""}\n${source.excerpt ?? ""}\n${source.content ?? ""}`;
if (total + chunk.length > budget) continue;
total += chunk.length;
kept.push(source);
}
return kept.length > 0 ? kept : ordered.slice(0, 1);
}
}
function isLargeModel(modelId?: string): boolean {
const id = modelId?.toLowerCase() ?? "";
return id.includes("gpt-5") || id.includes("claude-opus") || id.includes("gemini-2.5-pro") || id.includes("sonnet");
}
function scoreSource(source: ResearchSource): number {
const confidence = typeof source.metadata?.confidence === "number" ? source.metadata.confidence : 0;
const hasContent = source.content ? 1 : 0;
return confidence * 10 + hasContent;
}
function buildSynthesisPrompt(request: ResearchSynthesisRequest, sources: ResearchSource[]): string {
const format = request.desiredFormat ?? "markdown";
const renderedSources = sources
.map(
(source, index) => `Source [${index + 1}]\nTitle: ${source.title ?? source.reference}\nReference: ${source.reference}\nExcerpt: ${source.excerpt ?? ""}\nContent: ${source.content ?? ""}`,
)
.join("\n\n");
return [
"You are a research synthesis assistant.",
`Query: ${request.query}`,
`Round: ${request.round}`,
`Desired format: ${format}`,
"Analyze the provided sources and produce: summary, key findings, contradictions, confidence, and follow-up queries.",
"Cite source references inline using [n] notation where n is source number.",
request.instructions ? `Additional instructions: ${request.instructions}` : "",
"Sources:",
renderedSources,
'Return valid JSON: {"summary": string, "findings": [{"statement": string, "citations": string[]}], "confidence": number, "followUps": string[]}',
]
.filter(Boolean)
.join("\n\n");
}
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 extractCitations(text: string, sources: ResearchSource[]): string[] {
const matches = text.match(/\[(\d+)\]/g) ?? [];
const refs = new Set<string>();
for (const match of matches) {
const idx = Number.parseInt(match.replace(/\D/g, ""), 10) - 1;
if (Number.isFinite(idx) && idx >= 0 && idx < sources.length) {
refs.add(sources[idx].reference);
}
}
return [...refs];
}
function extractConfidence(text: string): number | undefined {
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? text;
try {
const parsed = JSON.parse(jsonBlock);
if (typeof parsed.confidence === "number") return parsed.confidence;
} catch {
const match = text.match(/"confidence"\s*:\s*([0-9]*\.?[0-9]+)/i);
if (match) {
const parsed = Number.parseFloat(match[1]);
if (Number.isFinite(parsed)) return parsed;
}
}
return undefined;
}
export { buildSynthesisPrompt, extractCitations };

View File

@@ -0,0 +1,238 @@
import { promises as fs } from "node:fs";
import { extname, join, relative, resolve } from "node:path";
import type { ResearchProviderConfig, ResearchSource } from "@fusion/core";
import type { ResearchProvider } from "../../research-step-runner.js";
import { createLogger } from "../../logger.js";
import { ResearchProviderError, type ResearchFetchResult } from "../types.js";
const log = createLogger("research:local-docs");
const DEFAULT_MAX_RESULTS = 10;
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_FILE_SIZE_BYTES = 1024 * 1024;
const BINARY_SNIFF_BYTES = 8 * 1024;
export interface LocalDocsProviderOptions {
projectRoot: string;
timeoutMs?: number;
maxResults?: number;
scanPaths?: string[];
}
export class LocalDocsProvider implements ResearchProvider {
readonly type = "local-docs";
private readonly projectRoot: string;
private readonly scanPaths: string[];
constructor(private readonly options: LocalDocsProviderOptions) {
this.projectRoot = resolve(options.projectRoot);
this.scanPaths = options.scanPaths ?? ["docs", "README.md", "AGENTS.md", ".fusion/memory"];
}
isConfigured(): boolean {
return true;
}
async search(query: string, config: ResearchProviderConfig = {}, signal?: AbortSignal): Promise<ResearchSource[]> {
const timeoutMs = Number(config.timeoutMs ?? this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const maxResults = Number(config.maxResults ?? this.options.maxResults ?? DEFAULT_MAX_RESULTS);
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
const files = await this.withTimeout(this.collectCandidateFiles(signal), timeoutMs, signal);
const results: Array<{ source: ResearchSource; score: number }> = [];
for (const file of files) {
this.throwIfAborted(signal);
const content = await this.safeReadText(file, signal);
if (!content) continue;
const lower = content.toLowerCase();
let score = 0;
for (const term of terms) {
const matches = lower.match(new RegExp(escapeRegex(term), "g"));
score += matches?.length ?? 0;
}
if (score <= 0) continue;
const relPath = relative(this.projectRoot, file);
results.push({
score,
source: {
id: `local-docs-${relPath}`,
type: "local",
reference: relPath,
title: relPath,
excerpt: buildExcerpt(content, terms),
status: "completed",
metadata: { score, path: relPath },
},
});
}
return results
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map((item) => item.source);
}
async fetchContent(filePath: string, config: ResearchProviderConfig = {}, signal?: AbortSignal): Promise<ResearchFetchResult> {
const timeoutMs = Number(config.timeoutMs ?? this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const resolvedPath = resolve(this.projectRoot, filePath);
if (!resolvedPath.startsWith(this.projectRoot)) {
throw new ResearchProviderError({
providerType: "local-docs",
code: "provider-unavailable",
message: "Path traversal is not allowed",
});
}
const stat = await this.withTimeout(fs.stat(resolvedPath), timeoutMs, signal);
if (!stat.isFile()) {
throw new ResearchProviderError({ providerType: "local-docs", code: "provider-unavailable", message: "Path is not a file" });
}
const content = await this.withTimeout(fs.readFile(resolvedPath), timeoutMs, signal);
const sniff = content.subarray(0, BINARY_SNIFF_BYTES);
if (sniff.includes(0)) {
throw new ResearchProviderError({ providerType: "local-docs", code: "provider-unavailable", message: "Binary file is not supported" });
}
const text = content.toString("utf-8");
return {
content: text.length > MAX_FILE_SIZE_BYTES ? text.slice(0, MAX_FILE_SIZE_BYTES) : text,
metadata: {
path: relative(this.projectRoot, resolvedPath),
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
extension: extname(resolvedPath),
},
mimeType: "text/plain",
};
}
private async collectCandidateFiles(signal?: AbortSignal): Promise<string[]> {
const ignorePatterns = await this.readGitignore();
const files: string[] = [];
for (const pathEntry of this.scanPaths) {
const target = resolve(this.projectRoot, pathEntry);
if (!target.startsWith(this.projectRoot)) continue;
try {
const stat = await fs.stat(target);
if (stat.isDirectory()) {
await this.walk(target, files, ignorePatterns, signal);
} else if (stat.isFile()) {
files.push(target);
}
} catch {
// ignore missing entries
}
}
const rootEntries = await fs.readdir(this.projectRoot, { withFileTypes: true });
for (const entry of rootEntries) {
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
files.push(join(this.projectRoot, entry.name));
}
}
return [...new Set(files)];
}
private async walk(dir: string, out: string[], ignorePatterns: string[], signal?: AbortSignal): Promise<void> {
this.throwIfAborted(signal);
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
this.throwIfAborted(signal);
const fullPath = join(dir, entry.name);
const relPath = relative(this.projectRoot, fullPath).replace(/\\/g, "/");
if (matchesGitignore(relPath, ignorePatterns)) continue;
if (entry.isDirectory()) {
await this.walk(fullPath, out, ignorePatterns, signal);
} else if (entry.isFile()) {
out.push(fullPath);
}
}
}
private async safeReadText(filePath: string, signal?: AbortSignal): Promise<string | undefined> {
try {
const stat = await fs.stat(filePath);
if (stat.size > MAX_FILE_SIZE_BYTES) return undefined;
const content = await fs.readFile(filePath);
if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return undefined;
this.throwIfAborted(signal);
return content.toString("utf-8");
} catch (error) {
log.warn("failed to read local docs file", { filePath, error });
return undefined;
}
}
private async readGitignore(): Promise<string[]> {
try {
const content = await fs.readFile(join(this.projectRoot, ".gitignore"), "utf-8");
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
} catch {
return [];
}
}
private throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new ResearchProviderError({ providerType: "local-docs", code: "abort", message: "Local docs scan aborted" });
}
}
private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
combined.addEventListener(
"abort",
() => {
reject(
new ResearchProviderError({
providerType: "local-docs",
code: signal?.aborted ? "abort" : "timeout",
message: signal?.aborted ? "Local docs operation aborted" : `Local docs operation timed out after ${timeoutMs}ms`,
retryable: !signal?.aborted,
}),
);
},
{ once: true },
);
}),
]);
}
}
function buildExcerpt(content: string, terms: string[]): string {
const lower = content.toLowerCase();
const first = terms.find((term) => lower.includes(term));
if (!first) return content.slice(0, 220).replace(/\s+/g, " ").trim();
const idx = lower.indexOf(first);
const start = Math.max(0, idx - 80);
const end = Math.min(content.length, idx + 140);
return content.slice(start, end).replace(/\s+/g, " ").trim();
}
function matchesGitignore(relPath: string, patterns: string[]): boolean {
return patterns.some((pattern) => {
if (pattern.endsWith("/")) return relPath.startsWith(pattern.slice(0, -1));
if (pattern.includes("*")) {
const regex = new RegExp(`^${pattern.split("*").map(escapeRegex).join(".*")}$`);
return regex.test(relPath);
}
return relPath === pattern || relPath.startsWith(`${pattern}/`);
});
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,123 @@
import type { ResearchProviderConfig, ResearchSource } from "@fusion/core";
import type { ResearchProvider } from "../../research-step-runner.js";
import { createLogger } from "../../logger.js";
import { ResearchProviderError, type ResearchFetchResult } from "../types.js";
const log = createLogger("research:page-fetch");
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_USER_AGENT = "FusionResearchBot/1.0";
export const MAX_CONTENT_CHARS = 500 * 1024;
export interface PageFetchProviderOptions {
timeoutMs?: number;
userAgent?: string;
}
export class PageFetchProvider implements ResearchProvider {
readonly type = "page-fetch";
constructor(private readonly options: PageFetchProviderOptions = {}) {}
isConfigured(): boolean {
return true;
}
async search(_query: string, _config: ResearchProviderConfig = {}, _signal?: AbortSignal): Promise<ResearchSource[]> {
return [];
}
async fetchContent(url: string, config: ResearchProviderConfig = {}, signal?: AbortSignal): Promise<ResearchFetchResult> {
const timeoutMs = Number(config.timeoutMs ?? this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
try {
const response = await fetch(url, {
method: "GET",
redirect: "follow",
headers: {
"User-Agent": (config.metadata?.userAgent as string) ?? this.options.userAgent ?? DEFAULT_USER_AGENT,
},
signal: requestSignal,
});
if (!response.ok) {
throw new ResearchProviderError({
providerType: "page-fetch",
code: response.status >= 500 ? "provider-unavailable" : "network-error",
message: `fetch failed with status ${response.status}`,
retryable: response.status >= 500,
});
}
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
const mimeType = contentType.split(";")[0].trim().toLowerCase();
const raw = await response.text();
const metadata: Record<string, unknown> = {
url,
contentType,
contentLength: raw.length,
};
if (mimeType.includes("text/html")) {
const extracted = extractHtml(raw);
metadata.title = extracted.title;
metadata.description = extracted.description;
metadata.contentLength = extracted.content.length;
return { content: truncate(extracted.content), metadata, mimeType };
}
if (mimeType.includes("application/json") || looksLikeJson(raw)) {
const pretty = JSON.stringify(JSON.parse(raw), null, 2);
return { content: truncate(pretty), metadata, mimeType };
}
if (mimeType.includes("text/") || mimeType.includes("markdown")) {
return { content: truncate(raw), metadata, mimeType };
}
throw new ResearchProviderError({
providerType: "page-fetch",
code: "provider-unavailable",
message: `unsupported mime type: ${mimeType}`,
});
} catch (error) {
if (error instanceof ResearchProviderError) throw error;
if (error instanceof DOMException && error.name === "AbortError") {
throw new ResearchProviderError({ providerType: "page-fetch", code: "abort", message: "Fetch aborted", cause: error });
}
if (error instanceof Error && error.name === "TimeoutError") {
throw new ResearchProviderError({ providerType: "page-fetch", code: "timeout", message: error.message, retryable: true, cause: error });
}
log.warn("page fetch failed", { error });
throw new ResearchProviderError({
providerType: "page-fetch",
code: "network-error",
message: error instanceof Error ? error.message : "fetch failed",
retryable: true,
cause: error,
});
}
}
}
function extractHtml(html: string): { title?: string; description?: string; content: string } {
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
const description = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["'][^>]*>/i)?.[1]?.trim();
const stripped = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, " ");
const main = stripped.match(/<(main|article)[^>]*>([\s\S]*?)<\/\1>/i)?.[2] ?? stripped.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? stripped;
const text = main.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
return { title, description, content: text };
}
function truncate(value: string): string {
return value.length > MAX_CONTENT_CHARS ? value.slice(0, MAX_CONTENT_CHARS) : value;
}
function looksLikeJson(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}

View File

@@ -0,0 +1,269 @@
import type { ResearchProviderConfig, ResearchSource, WebSearchBackend } from "@fusion/core";
import type { ResearchProvider } from "../../research-step-runner.js";
import { createLogger } from "../../logger.js";
import { ResearchProviderError } from "../types.js";
const log = createLogger("research:web-search");
export interface WebSearchProviderOptions {
backend?: WebSearchBackend;
searxngUrl?: string;
braveApiKey?: string;
googleApiKey?: string;
googleCx?: string;
tavilyApiKey?: string;
maxResults?: number;
timeoutMs?: number;
userAgent?: string;
}
const DEFAULT_MAX_RESULTS = 10;
const DEFAULT_TIMEOUT_MS = 30_000;
const RETRY_BASE_DELAY_MS = 1_000;
const RETRY_MAX_DELAY_MS = 10_000;
const RETRY_MAX_ATTEMPTS = 3;
export class WebSearchProvider implements ResearchProvider {
readonly type = "web-search";
constructor(private readonly options: WebSearchProviderOptions = {}) {}
isConfigured(): boolean {
const backend = this.options.backend ?? "none";
if (backend === "none") return false;
if (backend === "searxng") return Boolean(this.options.searxngUrl);
if (backend === "brave") return Boolean(this.options.braveApiKey);
if (backend === "google") return Boolean(this.options.googleApiKey && this.options.googleCx);
if (backend === "tavily") return Boolean(this.options.tavilyApiKey);
return false;
}
async search(query: string, config: ResearchProviderConfig = {}, signal?: AbortSignal): Promise<ResearchSource[]> {
const backend = this.options.backend ?? "none";
if (!this.isConfigured()) return [];
const maxResults = Number(config.maxResults ?? this.options.maxResults ?? DEFAULT_MAX_RESULTS);
const timeoutMs = Number(config.timeoutMs ?? this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const results = await this.withHttpRetry(async () => {
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);
if (backend === "tavily") return this.searchTavily(query, maxResults, requestSignal);
return [];
}, requestSignal, backend);
return results.slice(0, maxResults).map((result, index) => ({
id: `${backend}-${index}-${result.url}`,
type: "web",
reference: result.url,
title: result.title,
excerpt: result.snippet,
status: "completed",
metadata: {
backend,
rank: index + 1,
},
}));
}
async fetchContent(
_url: string,
_options: ResearchProviderConfig = {},
_signal?: AbortSignal,
): Promise<{ content: string; metadata: Record<string, unknown> }> {
return { content: "", metadata: {} };
}
private async searchSearxng(query: string, maxResults: number, signal: AbortSignal) {
const base = this.options.searxngUrl?.replace(/\/$/, "") ?? "";
const url = new URL(`${base}/search`);
url.searchParams.set("q", query);
url.searchParams.set("format", "json");
const response = await this.requestJson<{ results?: Array<{ url: string; title?: string; content?: string }> }>(
url.toString(),
{
method: "GET",
signal,
},
"searxng",
);
return (response.results ?? []).slice(0, maxResults).map((item) => ({
url: item.url,
title: item.title ?? item.url,
snippet: item.content ?? "",
}));
}
private async searchBrave(query: string, maxResults: number, signal: AbortSignal) {
const url = new URL("https://api.search.brave.com/res/v1/web/search");
url.searchParams.set("q", query);
url.searchParams.set("count", String(maxResults));
const response = await this.requestJson<{ web?: { results?: Array<{ url: string; title?: string; description?: string }> } }>(
url.toString(),
{
method: "GET",
headers: {
"X-Subscription-Token": this.options.braveApiKey ?? "",
"User-Agent": this.options.userAgent ?? "FusionResearchBot/1.0",
},
signal,
},
"brave",
);
return (response.web?.results ?? []).slice(0, maxResults).map((item) => ({
url: item.url,
title: item.title ?? item.url,
snippet: item.description ?? "",
}));
}
private async searchGoogle(query: string, maxResults: number, signal: AbortSignal) {
const url = new URL("https://www.googleapis.com/customsearch/v1");
url.searchParams.set("q", query);
url.searchParams.set("num", String(maxResults));
url.searchParams.set("key", this.options.googleApiKey ?? "");
url.searchParams.set("cx", this.options.googleCx ?? "");
const response = await this.requestJson<{ items?: Array<{ link: string; title?: string; snippet?: string }> }>(
url.toString(),
{
method: "GET",
signal,
},
"google",
);
return (response.items ?? []).slice(0, maxResults).map((item) => ({
url: item.link,
title: item.title ?? item.link,
snippet: item.snippet ?? "",
}));
}
private async searchTavily(query: string, maxResults: number, signal: AbortSignal) {
const response = await this.requestJson<{ results?: Array<{ url: string; title?: string; content?: string }> }>(
"https://api.tavily.com/search",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": this.options.userAgent ?? "FusionResearchBot/1.0",
},
body: JSON.stringify({
api_key: this.options.tavilyApiKey,
query,
max_results: maxResults,
}),
signal,
},
"tavily",
);
return (response.results ?? []).slice(0, maxResults).map((item) => ({
url: item.url,
title: item.title ?? item.url,
snippet: item.content ?? "",
}));
}
private async withHttpRetry<T>(
operation: () => Promise<T>,
signal: AbortSignal,
backend: WebSearchBackend,
): Promise<T> {
let attempt = 0;
let lastError: unknown;
while (attempt < RETRY_MAX_ATTEMPTS) {
if (signal.aborted) {
throw new ResearchProviderError({ providerType: "web-search", code: "abort", message: "Search aborted" });
}
try {
return await operation();
} catch (error) {
lastError = error;
const isRetryableHttp =
error instanceof ResearchProviderError &&
(error.code === "rate-limited" || error.code === "network-error" || error.code === "provider-unavailable") &&
error.retryable;
attempt += 1;
if (!isRetryableHttp || attempt >= RETRY_MAX_ATTEMPTS) {
break;
}
const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
const jitter = delay * (Math.random() * 0.2 - 0.1);
const totalDelay = Math.max(0, delay + jitter);
log.warn(`retrying ${backend} search`, { attempt, totalDelay });
await sleep(totalDelay, signal);
}
}
throw lastError;
}
private async requestJson<T>(url: string, init: RequestInit, backend: WebSearchBackend): Promise<T> {
try {
const response = await fetch(url, init);
if (!response.ok) {
const message = `${backend} request failed with status ${response.status}`;
if (response.status === 401 || response.status === 403) {
throw new ResearchProviderError({ providerType: "web-search", code: "auth-failed", message });
}
if (response.status === 429) {
throw new ResearchProviderError({
providerType: "web-search",
code: "rate-limited",
message,
retryable: true,
});
}
if (response.status >= 500) {
throw new ResearchProviderError({
providerType: "web-search",
code: "provider-unavailable",
message,
retryable: true,
});
}
throw new ResearchProviderError({ providerType: "web-search", code: "network-error", message });
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof ResearchProviderError) throw error;
if (error instanceof DOMException && error.name === "AbortError") {
throw new ResearchProviderError({ providerType: "web-search", code: "abort", message: "Search aborted", cause: error });
}
if (error instanceof Error && error.name === "TimeoutError") {
throw new ResearchProviderError({ providerType: "web-search", code: "timeout", message: error.message, retryable: true, cause: error });
}
throw new ResearchProviderError({
providerType: "web-search",
code: "network-error",
message: error instanceof Error ? error.message : "Unexpected network error",
retryable: true,
cause: error,
});
}
}
}
async function sleep(ms: number, signal: AbortSignal): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new ResearchProviderError({ providerType: "web-search", code: "abort", message: "Search aborted" }));
};
signal.addEventListener("abort", onAbort, { once: true });
});
}