From c6050785bf2311a42422e68f48fbdf36f8dccf68 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 11:25:55 -0700 Subject: [PATCH] fix: remove fn_research_* from the host pi extension Host-extension research tools dual-booted a second TaskStore and could wedge agent turns via wait_for_completion polling (same hang class as FN-7956). Leave research available only when the engine injects createResearchTools under experimentalFeatures.researchView. Operators still use fn research CLI and the dashboard Research view. Regen fusion skill docs from extension.ts. --- packages/cli/skill/fusion/SKILL.md | 2 +- .../fusion/references/extension-tools.md | 43 -- .../fusion/references/fusion-capabilities.md | 5 - .../__tests__/extension-integration.test.ts | 1 - .../__tests__/extension-tool-timeout.test.ts | 50 +-- packages/cli/src/__tests__/extension.test.ts | 141 +------ .../research-extension-tools.test.ts | 294 +------------- packages/cli/src/extension.ts | 370 +----------------- 8 files changed, 34 insertions(+), 872 deletions(-) diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 0797823205..8905119854 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -35,7 +35,7 @@ Mission → Milestone → Slice → Feature → Task - **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_update`, `fn_agent_set_instructions`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart` - **Skills tools** — `fn_skills_search`, `fn_skills_install` - **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show` -- **Other tools** — `fn_web_fetch`, `fn_secret_get`, `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`, `fn_experiment_finalize` +- **Other tools** — `fn_web_fetch`, `fn_secret_get`, `fn_experiment_finalize` - **Dashboard** — Use `/fn` command to start/stop the dashboard diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 0b76549f1f..291e513cbf 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -739,49 +739,6 @@ Read a secret by key using per-secret access policy. | `key` | string | ✓ | Secret key | | `scope` | union | — | Optional scope | -### fn_research_run - -Cited-research pipeline: create a bounded search/fetch/synthesis run (not an autonomous experiment loop) and optionally wait for completion. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `query` | string | ✓ | Research query or question | -| `wait_for_completion` | boolean | — | Wait for the run to complete before returning (default: false) | -| `max_wait_ms` | number | — | Max wait time when wait_for_completion=true (default: 90000, capped by settings) | - -### fn_research_list - -Cited-research pipeline: list recent search/fetch/synthesis runs (not experiment-loop sessions). - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `status` | string(enum) | — | Filter by run status | -| `limit` | number | — | Max runs to return (default: 10) | - -### fn_research_get - -Cited-research pipeline: get one run with structured findings and citations (not experiment-loop state). - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `id` | string | ✓ | Research run ID | - -### fn_research_cancel - -Cited-research pipeline: cancel an in-flight run; terminal runs return INVALID_TRANSITION (does not control experiment loops). - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `id` | string | ✓ | Research run ID | - -### fn_research_retry - -Cited-research pipeline: retry a failed run when lifecycle marks it retryable (not an autonomous experiment loop retry). - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `id` | string | ✓ | Research run ID | - ### fn_experiment_finalize Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 349a96c634..704d665630 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -47,11 +47,6 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. | | `fn_web_fetch` | Lightweight URL fetch (no JS rendering). Use agent-browser skill for JS-heavy pages. URL to fetch (http/https) Optional extraction hint for downstream summarization Timeout in milliseconds (default: 30000) Max bytes to return (default: 512000) | | `fn_secret_get` | Read a secret by key using per-secret access policy. | -| `fn_research_run` | Cited-research pipeline: create a bounded search/fetch/synthesis run (not an autonomous experiment loop) and optionally wait for completion. | -| `fn_research_list` | Cited-research pipeline: list recent search/fetch/synthesis runs (not experiment-loop sessions). | -| `fn_research_get` | Cited-research pipeline: get one run with structured findings and citations (not experiment-loop state). | -| `fn_research_cancel` | Cited-research pipeline: cancel an in-flight run; terminal runs return INVALID_TRANSITION (does not control experiment loops). | -| `fn_research_retry` | Cited-research pipeline: retry a failed run when lifecycle marks it retryable (not an autonomous experiment loop retry). | | `fn_experiment_finalize` | Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git. | | `fn_insight_list` | List persisted project insights with optional category/status filters. | | `fn_insight_show` | Show a single persisted insight by ID. | diff --git a/packages/cli/src/__tests__/extension-integration.test.ts b/packages/cli/src/__tests__/extension-integration.test.ts index 730d9ff565..fd7aa34360 100644 --- a/packages/cli/src/__tests__/extension-integration.test.ts +++ b/packages/cli/src/__tests__/extension-integration.test.ts @@ -147,7 +147,6 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr "fn_list_agents", "fn_delegate_task", "fn_agent_show", - "fn_research_run", "fn_skills_install", ]) { expect(api.tools.has(toolName), `${toolName} should be registered`).toBe(true); diff --git a/packages/cli/src/__tests__/extension-tool-timeout.test.ts b/packages/cli/src/__tests__/extension-tool-timeout.test.ts index d4bf29ceaf..4c2a378fa4 100644 --- a/packages/cli/src/__tests__/extension-tool-timeout.test.ts +++ b/packages/cli/src/__tests__/extension-tool-timeout.test.ts @@ -10,8 +10,8 @@ import { FNXC:MergeQueue 2026-07-15-11:15: FN-7956 hung AI merge review on unbounded extension fn_task_show. These unit tests lock the fail-closed timeout/abort budgets that unblock agent turns when store work wedges. -FNXC:MergeQueue 2026-07-15-11:20: -Code review follow-up: per-tool budgets must not clip fn_research_run(wait_for_completion) (default max_wait_ms 90s) under a flat 60s outer wrap. +FNXC:MergeQueue 2026-07-15-11:28: +Host extension research tools are off; budgets cover remaining long host tools only. */ afterEach(() => { @@ -25,27 +25,6 @@ describe("resolveExtensionToolTimeoutMs", () => { expect(resolveExtensionToolTimeoutMs("fn_task_list")).toBe(60_000); }); - it("raises the budget for fn_research_run wait_for_completion above default max_wait_ms", () => { - // Default max_wait_ms is 90s; outer wrap must be strictly larger. - expect( - resolveExtensionToolTimeoutMs("fn_research_run", { wait_for_completion: true }), - ).toBe(90_000 + 15_000); - }); - - it("honors explicit max_wait_ms for research wait", () => { - expect( - resolveExtensionToolTimeoutMs("fn_research_run", { - wait_for_completion: true, - max_wait_ms: 120_000, - }), - ).toBe(120_000 + 15_000); - }); - - it("keeps 60s for research when not waiting for completion", () => { - expect(resolveExtensionToolTimeoutMs("fn_research_run", { wait_for_completion: false })).toBe(60_000); - expect(resolveExtensionToolTimeoutMs("fn_research_run", {})).toBe(60_000); - }); - it("gives multi-minute budgets to skills install and import/browse tools", () => { expect(resolveExtensionToolTimeoutMs("fn_skills_install")).toBe(300_000); expect(resolveExtensionToolTimeoutMs("fn_task_import_github")).toBe(180_000); @@ -143,29 +122,4 @@ describe("wrapExtensionToolExecute", () => { }); expect(warn).toHaveBeenCalledWith(expect.stringContaining("fn_abort aborted")); }); - - it("uses per-tool research wait budget when timeoutMs is omitted", async () => { - const execute = vi.fn(async () => ({ content: [{ type: "text" as const, text: "done" }] })); - const wrapped = wrapExtensionToolExecute("fn_research_run", execute); - // Should not use the flat 60s path for wait_for_completion — budget is 105s; this call is instant. - await expect( - wrapped("id", { wait_for_completion: true, max_wait_ms: 90_000 }, undefined), - ).resolves.toEqual({ content: [{ type: "text", text: "done" }] }); - expect(execute).toHaveBeenCalledOnce(); - }); - - it("does not clip a research wait that finishes under max_wait_ms but over 60s", async () => { - // Simulate a 70ms wait with a 100ms research budget (not the flat 60ms default for ordinary tools). - const execute = vi.fn( - async () => { - await new Promise((r) => setTimeout(r, 70)); - return { content: [{ type: "text" as const, text: "research-ok" }] }; - }, - ); - // Explicit small budget that still exceeds the simulated wait (params would resolve to 90s+ in prod). - const wrapped = wrapExtensionToolExecute("fn_research_run", execute, 150); - await expect( - wrapped("id", { wait_for_completion: true, max_wait_ms: 90_000 }, undefined), - ).resolves.toEqual({ content: [{ type: "text", text: "research-ok" }] }); - }); }); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 5472150571..e2ed3559d2 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -22,7 +22,7 @@ vi.mock("../commands/task.js", () => ({ })); import { __setCachedStoreForTesting, closeCachedStores, resolveTaskListFormatter } from "../extension.js"; -import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core"; +import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core"; import type { WorkflowIr } from "@fusion/core"; import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli"; import { runTaskPlan } from "../commands/task.js"; @@ -165,23 +165,6 @@ async function readTaskWorkflowState(_cwd: string, taskId: string) { return { task, selection }; } -async function enableResearch(_cwd: string): Promise { - const store = h.store(); - await store.updateGlobalSettings({ - researchGlobalEnabled: true, - researchGlobalDefaults: { searchProvider: "searxng" }, - researchGlobalSearxngUrl: "http://localhost:8888", - experimentalFeatures: { researchView: true } as Record, - }); - await store.updateSettings({ - researchEnabled: true, - researchSettings: { enabled: true, searchProvider: "searxng" }, - researchGlobalWebSearchProvider: "searxng", - researchGlobalSearxngUrl: "http://localhost:8888", - }); - return store; -} - // ── Tests ────────────────────────────────────────────────────────── pgTest("fn pi extension tool copy guardrails", () => { @@ -288,10 +271,6 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => { "fn_task_unarchive", "fn_task_delete", "fn_task_plan", - "fn_research_run", - "fn_research_list", - "fn_research_get", - "fn_research_cancel", "fn_insight_list", "fn_insight_show", "fn_insight_run_list", @@ -4125,120 +4104,10 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { }); }); - describe("research tools", () => { - it.each([ - "fn_research_run", - "fn_research_list", - "fn_research_get", - "fn_research_cancel", - "fn_research_retry", - ])("%s uses disambiguated cited-research wording", (toolName) => { - const tool = api.tools.get(toolName)!; - expect(tool.description).toMatch(/cited-research pipeline/i); - if (/experiment loop/i.test(tool.description)) { - expect(tool.description).toMatch(/not\s+.*experiment loop/i); - } - }); - - it("fn_research_run treats builtin as configured when no provider is explicitly set", async () => { - const store = createStore(); - await store.updateGlobalSettings({ - researchGlobalEnabled: true, - experimentalFeatures: { researchView: true } as Record, - }); - await store.updateSettings({ - researchEnabled: true, - researchSettings: { enabled: true }, - }); - - const tool = api.tools.get("fn_research_run")!; - const result = await tool.execute( - "research-run-builtin", - { query: "builtin default" }, - undefined, - undefined, - makeCtx(tmpDir), - ); - - expect(result.details.setup).toBeNull(); - expect(result.details.status).toBe("queued"); - }); - - it("fn_research_list status parameter matches RESEARCH_RUN_STATUSES", () => { - const tool = requireTool(api, "fn_research_list") as unknown as ToolWithParameters; - const statusSchema = tool.parameters?.properties?.status; - const enumValues = statusSchema?.enum ?? statusSchema?.anyOf?.[0]?.enum; - expect(enumValues).toEqual([...RESEARCH_RUN_STATUSES]); - }); - - it("fn_research_run preserves fire-and-forget behavior when wait_for_completion is false", async () => { - const store = await enableResearch(tmpDir); - try { - const tool = api.tools.get("fn_research_run")!; - - const result = await tool.execute( - "research-run-ff", - { query: "test query", wait_for_completion: false }, - undefined, - undefined, - makeCtx(tmpDir), - ); - - expect(result.content[0].text).toContain("Start the project engine to process pending runs"); - expect(result.details.status).toBe("queued"); - } finally { - } - }); - - it("fn_research_run waits and returns terminal run details when wait_for_completion is true", async () => { - const store = await enableResearch(tmpDir); - try { - const tool = api.tools.get("fn_research_run")!; - const researchStore = store.getResearchStore(); - - const settleRunToCompleted = async () => { - const queuedRun = (await researchStore.listRuns({ limit: 1 }))[0]; - if (!queuedRun) { - return false; - } - if (queuedRun.status === "completed") { - return true; - } - if (queuedRun.status === "queued") { - await researchStore.updateRun(queuedRun.id, { status: "running" }); - } - await researchStore.updateRun(queuedRun.id, { - status: "completed", - results: { summary: "done", findings: [{ heading: "h1", content: "f1", sources: [] }], citations: [] }, - }); - return true; - }; - - /* - FNXC:CliTests 2026-06-19-11:06: - The wait-for-completion regression must settle its synthetic run after the tool creates it; starting the completer before creation can miss the run and consume the whole 5s Vitest budget. - */ - const resultPromise = tool.execute( - "research-run-wait", - { query: "terminal query", wait_for_completion: true, max_wait_ms: 3000 }, - undefined, - undefined, - makeCtx(tmpDir), - ); - - for (let attempt = 0; attempt < 50 && !(await settleRunToCompleted()); attempt += 1) { - await delay(10); - } - - const result = await resultPromise; - - expect(result.details.status).toBe("completed"); - expect(result.details.summary).toBe("done"); - expect(result.content[0].text).toContain("is completed"); - } finally { - } - }); - }); + /* + FNXC:MergeQueue 2026-07-15-11:28: + Host extension no longer registers fn_research_*. See research-extension-tools.test.ts for the off-surface lock. + */ describe("fn_delegate_task", () => { it("delegates task to agent", async () => { diff --git a/packages/cli/src/__tests__/research-extension-tools.test.ts b/packages/cli/src/__tests__/research-extension-tools.test.ts index 73b200fb23..abb8b39b0c 100644 --- a/packages/cli/src/__tests__/research-extension-tools.test.ts +++ b/packages/cli/src/__tests__/research-extension-tools.test.ts @@ -1,283 +1,25 @@ /** - * FNXC:PostgresCutover 2026-07-04-00:00: - * Migrated from the legacy SQLite `new TaskStore(tmpDir)` harness to the - * PostgreSQL extension harness. Research runs are seeded via the PG-backed - * AsyncResearchStore (`h.store().getResearchStore()`), and the research tools - * resolve the same store through the harness-injected `getStore(cwd)` cache. + * FNXC:MergeQueue 2026-07-15-11:28: + * Host extension no longer registers fn_research_*. This file locks that surface off so dual-store + * research tools cannot reappear and wedge agent sessions (FN-7956 hang class). Engine createResearchTools + * remains the gated research agent surface when experimentalFeatures.researchView is enabled. */ -import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; -import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js"; -import { - createPgExtensionHarness, - createMockApi, - registerExtension, - requireTool, - type ToolExecuteContext, -} from "./pg-extension-harness.js"; -import { type AsyncResearchStore, type ResearchResult } from "@fusion/core"; +import { expect, it } from "vitest"; +import { createMockApi, registerExtension } from "./pg-extension-harness.js"; -const pgTest = pgDescribe; +const RESEARCH_EXTENSION_TOOLS = [ + "fn_research_run", + "fn_research_list", + "fn_research_get", + "fn_research_cancel", + "fn_research_retry", +] as const; -/** Narrow a details payload value to a string (throws loudly if it isn't one). */ -function asString(value: unknown): string { - if (typeof value !== "string") { - throw new Error(`expected string, got ${typeof value}`); +it("does not register fn_research_* tools on the host pi extension", () => { + const api = createMockApi(); + registerExtension(api); + for (const name of RESEARCH_EXTENSION_TOOLS) { + expect(api.tools.has(name)).toBe(false); } - return value; -} - -function makeCtx(cwd: string): ToolExecuteContext { - return { cwd }; -} - -pgTest("research extension tools", () => { - const h = createPgExtensionHarness("kb-cli-research"); - - beforeAll(h.beforeAll); - beforeEach(h.beforeEach); - afterEach(h.afterEach); - afterAll(h.afterAll); - - // In backend mode getResearchStore() returns the AsyncResearchStore (async methods). - const research = (): AsyncResearchStore => h.store().getResearchStore() as AsyncResearchStore; - - it("registers research extension tools", () => { - const api = createMockApi(); - registerExtension(api); - expect(api.tools.has("fn_research_run")).toBe(true); - expect(api.tools.has("fn_research_list")).toBe(true); - expect(api.tools.has("fn_research_get")).toBe(true); - expect(api.tools.has("fn_research_cancel")).toBe(true); - expect(api.tools.has("fn_research_retry")).toBe(true); - }); - - it("returns feature-disabled response when experimental research flag is off", async () => { - const store = h.store(); - await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record }); - - const api = createMockApi(); - registerExtension(api); - const runTool = requireTool(api, "fn_research_run"); - const result = await runTool.execute("call-1", { query: "fusion" }, undefined, undefined, makeCtx(h.rootDir())); - - expect(result.details?.setup).toMatchObject({ code: "feature-disabled" }); - expect(result.content[0]?.text).toContain("disabled"); - }); - - it("returns feature-disabled contract for list/get/cancel/retry when flag is off", async () => { - const store = h.store(); - await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record }); - - const api = createMockApi(); - registerExtension(api); - const listResult = await requireTool(api, "fn_research_list").execute("call-list", {}, undefined, undefined, makeCtx(h.rootDir())); - expect(listResult.details?.setup).toMatchObject({ code: "feature-disabled" }); - - const getResult = await requireTool(api, "fn_research_get").execute("call-get", { id: "RR-1" }, undefined, undefined, makeCtx(h.rootDir())); - expect(getResult.details?.setup).toMatchObject({ code: "feature-disabled" }); - - const cancelResult = await requireTool(api, "fn_research_cancel").execute("call-cancel", { id: "RR-1" }, undefined, undefined, makeCtx(h.rootDir())); - expect(cancelResult.isError).toBe(true); - expect(cancelResult.details?.setup).toMatchObject({ code: "feature-disabled" }); - - const retryResult = await requireTool(api, "fn_research_retry").execute("call-retry", { id: "RR-1" }, undefined, undefined, makeCtx(h.rootDir())); - expect(retryResult.isError).toBe(true); - expect(retryResult.details?.setup).toMatchObject({ code: "feature-disabled" }); - }); - - it("treats builtin as configured when no provider is explicitly set", async () => { - const store = h.store(); - await store.updateGlobalSettings({ - experimentalFeatures: { researchView: true } as Record, - researchGlobalEnabled: true, - }); - await store.updateSettings({ - researchSettings: { enabled: true }, - }); - - const api = createMockApi(); - registerExtension(api); - const runTool = requireTool(api, "fn_research_run"); - const result = await runTool.execute("call-builtin", { query: "fusion" }, undefined, undefined, makeCtx(h.rootDir())); - - expect(result.details?.setup).toBeNull(); - expect(result.details?.status).toBe("queued"); - }); - - it("returns actionable missing-credentials response", async () => { - const store = h.store(); - await store.updateGlobalSettings({ - experimentalFeatures: { researchView: true } as Record, - researchGlobalEnabled: true, - researchGlobalWebSearchProvider: "tavily", - researchGlobalDefaults: { searchProvider: "tavily" }, - }); - await store.updateSettings({ - researchSettings: { enabled: true }, - }); - - const api = createMockApi(); - registerExtension(api); - const runTool = requireTool(api, "fn_research_run"); - const result = await runTool.execute("call-0", { query: "fusion" }, undefined, undefined, makeCtx(h.rootDir())); - - expect(result.details?.setup).toMatchObject({ code: "missing-credentials" }); - expect(result.content[0]?.text).toContain("Missing credentials"); - }); - - it("creates, reads, lists, and cancels runs", async () => { - const store = h.store(); - await store.updateGlobalSettings({ - experimentalFeatures: { researchView: true } as Record, - researchGlobalEnabled: true, - researchGlobalWebSearchProvider: "searxng", - researchGlobalSearxngUrl: "http://localhost:8888", - researchGlobalDefaults: { searchProvider: "searxng" }, - }); - await store.updateSettings({ - researchSettings: { enabled: true, searchProvider: "searxng" }, - }); - - const created = await research().createRun({ query: "fusion architecture", topic: "fusion architecture" }); - - const api = createMockApi(); - registerExtension(api); - const listResult = await requireTool(api, "fn_research_list").execute("call-2", {}, undefined, undefined, makeCtx(h.rootDir())); - const runs = listResult.details?.runs; - if (!Array.isArray(runs)) throw new Error("expected runs array"); - expect(runs.length).toBeGreaterThan(0); - - const getResult = await requireTool(api, "fn_research_get").execute("call-3", { id: created.id }, undefined, undefined, makeCtx(h.rootDir())); - expect(getResult.details?.runId).toBe(created.id); - - const cancelResult = await requireTool(api, "fn_research_cancel").execute("call-4", { id: created.id }, undefined, undefined, makeCtx(h.rootDir())); - const cancelStatus = cancelResult.details?.status; - expect(cancelStatus === "cancelling" || cancelStatus === "cancelled").toBe(true); - - const retryResult = await requireTool(api, "fn_research_retry").execute("call-5", { id: created.id }, undefined, undefined, makeCtx(h.rootDir())); - expect(retryResult.isError).toBe(true); - }); - - it("returns structured missing-run details for get and cancel", async () => { - const store = h.store(); - await store.updateGlobalSettings({ - experimentalFeatures: { researchView: true } as Record, - researchGlobalEnabled: true, - researchGlobalWebSearchProvider: "searxng", - researchGlobalSearxngUrl: "http://localhost:8888", - researchGlobalDefaults: { searchProvider: "searxng" }, - }); - await store.updateSettings({ - researchSettings: { enabled: true, searchProvider: "searxng" }, - }); - - const api = createMockApi(); - registerExtension(api); - const getResult = await requireTool(api, "fn_research_get").execute("call-missing-get", { id: "RR-404" }, undefined, undefined, makeCtx(h.rootDir())); - expect(getResult.details?.runId).toBe("RR-404"); - expect(getResult.details?.status).toBe("missing"); - expect(getResult.details?.setup).toMatchObject({ code: "NOT_FOUND" }); - - const cancelResult = await requireTool(api, "fn_research_cancel").execute("call-missing-cancel", { id: "RR-404" }, undefined, undefined, makeCtx(h.rootDir())); - expect(cancelResult.isError).toBe(true); - expect(cancelResult.details?.runId).toBe("RR-404"); - expect(cancelResult.details?.setup).toMatchObject({ code: "NOT_FOUND" }); - }); - - it("returns completed-run structured findings and citations", async () => { - const store = h.store(); - await store.updateGlobalSettings({ - experimentalFeatures: { researchView: true } as Record, - researchGlobalEnabled: true, - researchGlobalWebSearchProvider: "searxng", - researchGlobalSearxngUrl: "http://localhost:8888", - researchGlobalDefaults: { searchProvider: "searxng" }, - }); - await store.updateSettings({ - researchSettings: { enabled: true, searchProvider: "searxng" }, - }); - - const run = await research().createRun({ query: "fusion", topic: "fusion" }); - // The persisted result carries structured citations; the ResearchResult type - // declares citations as string[], so narrow once at this test boundary. - const results = { - summary: "Summary text", - findings: [{ heading: "Finding A", content: "Detail A", sources: ["https://example.com/a"] }], - citations: [{ title: "Source A", url: "https://example.com/a" }], - } as unknown as ResearchResult; - await research().setResults(run.id, results); - await research().updateStatus(run.id, "running"); - await research().updateStatus(run.id, "completed"); - - const api = createMockApi(); - registerExtension(api); - const result = await requireTool(api, "fn_research_get").execute("call-complete", { id: run.id }, undefined, undefined, makeCtx(h.rootDir())); - expect(result.details?.runId).toBe(run.id); - expect(result.details?.status).toBe("completed"); - expect(result.details?.summary).toBe("Summary text"); - expect(result.details?.findings).toHaveLength(1); - expect(result.details?.findings).toMatchObject([{ heading: "Finding A", content: "Detail A" }]); - expect(result.details?.citations).toHaveLength(1); - expect(result.details?.citations).toMatchObject([{ title: "Source A", url: "https://example.com/a" }]); - }); - - it("retries failed run and returns retry linkage metadata", async () => { - const store = h.store(); - await store.updateGlobalSettings({ - experimentalFeatures: { researchView: true } as Record, - researchGlobalEnabled: true, - researchGlobalWebSearchProvider: "searxng", - researchGlobalSearxngUrl: "http://localhost:8888", - researchGlobalDefaults: { searchProvider: "searxng" }, - }); - await store.updateSettings({ - researchSettings: { enabled: true, searchProvider: "searxng" }, - }); - - const lifecycle = { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" }; - const run = await research().createRun({ query: "fusion", topic: "fusion", lifecycle }); - await research().updateStatus(run.id, "running", { lifecycle }); - await research().updateStatus(run.id, "failed", { lifecycle }); - - const api = createMockApi(); - registerExtension(api); - const retryResult = await requireTool(api, "fn_research_retry").execute("call-retry", { id: run.id }, undefined, undefined, makeCtx(h.rootDir())); - - expect(retryResult.isError).not.toBe(true); - const retryStatus = retryResult.details?.status; - expect(retryStatus === "queued" || retryStatus === "retry_waiting").toBe(true); - const newRunId = asString(retryResult.details?.runId); - expect(newRunId).not.toBe(run.id); - - const retried = await research().getRun(newRunId); - expect(retried?.status).toBe("retry_waiting"); - expect(retried?.lifecycle?.retryOfRunId).toBe(run.id); - expect(retried?.lifecycle?.rootRunId).toBe(run.id); - expect(retried?.lifecycle?.attempt).toBe(2); - }); - - it("returns INVALID_TRANSITION for cancel on terminal run", async () => { - const store = h.store(); - await store.updateGlobalSettings({ - experimentalFeatures: { researchView: true } as Record, - researchGlobalEnabled: true, - researchGlobalWebSearchProvider: "searxng", - researchGlobalSearxngUrl: "http://localhost:8888", - researchGlobalDefaults: { searchProvider: "searxng" }, - }); - await store.updateSettings({ - researchSettings: { enabled: true, searchProvider: "searxng" }, - }); - - const run = await research().createRun({ query: "fusion", topic: "fusion" }); - await research().updateStatus(run.id, "running"); - await research().updateStatus(run.id, "completed"); - - const api = createMockApi(); - registerExtension(api); - const result = await requireTool(api, "fn_research_cancel").execute("call-6", { id: run.id }, undefined, undefined, makeCtx(h.rootDir())); - expect(result.isError).toBe(true); - expect(result.details?.setup).toMatchObject({ code: "INVALID_TRANSITION" }); - }); }); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 6e1f808cb0..6847e7bd0c 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -23,13 +23,8 @@ import { type InsightStatus, type InsightRunStatus, type InsightRunTrigger, - type ResearchRun, - type ResearchRunStatus, type AgentCapability, type AgentUpdateInput, - RESEARCH_RUN_STATUSES, - isResearchExperimentalEnabled, - resolveResearchSettings, getTaskDuplicateLineage, resolveAgentProvisioningPolicy, TASK_PRIORITIES, @@ -259,9 +254,6 @@ FNXC:MergeQueue 2026-07-15-11:15: Default wall-clock budget for store/CRUD host-extension tools. Long-running tools use resolveExtensionToolTimeoutMs instead of this flat default (research wait, skills install, imports). */ const EXTENSION_TOOL_TIMEOUT_MS = 60_000; -/** Slack added above fn_research_run max_wait_ms so the outer wrap cannot clip an intentional wait. */ -const RESEARCH_WAIT_SLACK_MS = 15_000; -const RESEARCH_DEFAULT_MAX_WAIT_MS = 90_000; const SKILLS_INSTALL_TIMEOUT_MS = 300_000; const IMPORT_BROWSE_TIMEOUT_MS = 180_000; const WEB_FETCH_TIMEOUT_MS = 90_000; @@ -275,23 +267,14 @@ function isAbortError(error: unknown): boolean { /** * FNXC:MergeQueue 2026-07-15-11:20: - * Per-tool outer budgets. A flat 60s wrap false-failed fn_research_run(wait_for_completion) whose default max_wait_ms is 90s. - * Store tools stay at 60s; intentional multi-minute tools get higher ceilings. + * Per-tool outer budgets. Store tools stay at 60s; intentional multi-minute tools get higher ceilings. + * FNXC:MergeQueue 2026-07-15-11:28: + * Host extension no longer registers fn_research_* (engine injects createResearchTools when experimental research is on), so research wait budgets are not needed here. * * @internal Exported for unit tests. */ -export function resolveExtensionToolTimeoutMs(toolName: string, params?: unknown): number { +export function resolveExtensionToolTimeoutMs(toolName: string, _params?: unknown): number { const name = toolName.trim(); - if (name === "fn_research_run") { - const record = params && typeof params === "object" ? (params as Record) : {}; - if (record.wait_for_completion === true) { - const rawMax = record.max_wait_ms; - const maxWait = - typeof rawMax === "number" && Number.isFinite(rawMax) ? Math.max(0, rawMax) : RESEARCH_DEFAULT_MAX_WAIT_MS; - return maxWait + RESEARCH_WAIT_SLACK_MS; - } - return EXTENSION_TOOL_TIMEOUT_MS; - } if (name === "fn_skills_install") return SKILLS_INSTALL_TIMEOUT_MS; if (name.startsWith("fn_task_import_") || name.startsWith("fn_task_browse_")) return IMPORT_BROWSE_TIMEOUT_MS; if (name === "fn_web_fetch") return WEB_FETCH_TIMEOUT_MS; @@ -753,88 +736,6 @@ export function formatTaskLine(t: Task): string { return `${t.id} ${label}${sourceSuffix}${deps}${paused}`; } -async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean; code?: string; message?: string }> { - const settings = await store.getSettings(); - if (!isResearchExperimentalEnabled(settings)) { - return { ok: false, code: "feature-disabled", message: "Research tools are disabled. Enable experimentalFeatures.researchView first." }; - } - - const resolved = resolveResearchSettings(settings); - if (!resolved.enabled) { - return { ok: false, code: "feature-disabled", message: "Research is disabled in settings." }; - } - - const backend = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin"; - const configured = backend === "builtin" - ? true - : backend === "searxng" - ? Boolean(settings.researchGlobalSearxngUrl) - : backend === "brave" - ? Boolean(settings.researchGlobalBraveApiKey) - : backend === "google" - ? Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx) - : backend === "tavily" - ? Boolean(settings.researchGlobalTavilyApiKey) - : false; - - if (!configured) { - return { ok: false, code: "missing-credentials", message: `Missing credentials for ${backend}. Add provider keys in Authentication and verify Research defaults.` }; - } - - return { ok: true }; -} - -const RESEARCH_RUN_TERMINAL_STATUSES = new Set([ - "completed", - "failed", - "cancelled", - "timed_out", - "retry_exhausted", -]); - -function toResearchRunDetails(run: ResearchRun) { - return { - runId: run.id, - status: run.status, - query: run.query, - summary: run.results?.summary ?? null, - findings: run.results?.findings ?? [], - citations: run.results?.citations ?? [], - sourceCount: Array.isArray(run.sources) ? run.sources.length : 0, - error: run.error ?? null, - setup: null, - }; -} - -function isResearchRunTerminal(status: ResearchRunStatus): boolean { - return RESEARCH_RUN_TERMINAL_STATUSES.has(status); -} - -async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { - if (ms <= 0) { - return; - } - - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - - const onAbort = () => { - clearTimeout(timer); - reject(new Error("Operation aborted")); - }; - - if (signal?.aborted) { - onAbort(); - return; - } - - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - interface GitHubIssueApiResult { number: number; title: string; @@ -2701,265 +2602,10 @@ export default function kbExtension(pi: ExtensionAPI) { }, }); - // ── Research Tools ────────────────────────────────────────────── - - pi.registerTool({ - name: "fn_research_run", - label: "fn: Run Research", - description: "Cited-research pipeline: create a bounded search/fetch/synthesis run (not an autonomous experiment loop) and optionally wait for completion.", - parameters: Type.Object({ - query: Type.String({ description: "Research query or question" }), - wait_for_completion: Type.Optional(Type.Boolean({ description: "Wait for the run to complete before returning (default: false)" })), - max_wait_ms: Type.Optional(Type.Number({ description: "Max wait time when wait_for_completion=true (default: 90000, capped by settings)" })), - }), - async execute(_toolCallId, params, signal, _onUpdate, ctx) { - const store = await getStore(ctx.cwd); - const availability = await getResearchAvailability(store); - if (!availability.ok) { - return { - content: [{ type: "text", text: availability.message! }], - details: { runId: null, status: "unavailable", summary: null, findings: [], citations: [], error: availability.message, setup: { code: availability.code, message: availability.message } }, - }; - } - - // FNXC:ResearchStore 2026-06-27-12:40: - // getResearchStore() returns ResearchStore (SQLite) or AsyncResearchStore (PG backend); - // await every call so research-run CRUD works in both backends (await is harmless on - // the sync store). AI research EXECUTION still requires starting the engine. - const researchStore = store.getResearchStore(); - const run = await researchStore.createRun({ - query: params.query, - topic: params.query, - providerConfig: {}, - }); - - if (!params.wait_for_completion) { - return { - content: [{ type: "text", text: `Created research run ${run.id}. Start the project engine to process pending runs, then use fn_research_get.` }], - details: toResearchRunDetails(run), - }; - } - - const maxWaitMs = Number.isFinite(params.max_wait_ms) - ? Math.max(0, params.max_wait_ms ?? 90_000) - : 90_000; - const pollIntervalMs = 2_000; - const deadline = Date.now() + maxWaitMs; - let latestRun = run; - - while (Date.now() <= deadline) { - const current = await researchStore.getRun(run.id); - if (!current) { - break; - } - - latestRun = current; - if (isResearchRunTerminal(current.status)) { - return { - content: [{ type: "text", text: `Research run ${current.id} is ${current.status}.` }], - details: toResearchRunDetails(current), - }; - } - - await sleepWithSignal(pollIntervalMs, signal); - } - - return { - content: [{ type: "text", text: `Research run ${latestRun.id} is ${latestRun.status}.` }], - details: toResearchRunDetails(latestRun), - }; - }, - }); - - pi.registerTool({ - name: "fn_research_list", - label: "fn: List Research Runs", - description: "Cited-research pipeline: list recent search/fetch/synthesis runs (not experiment-loop sessions).", - parameters: Type.Object({ - status: Type.Optional(StringEnum([...RESEARCH_RUN_STATUSES], { description: "Filter by run status" }) as unknown as TSchema), - limit: Type.Optional(Type.Number({ description: "Max runs to return (default: 10)" })), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const store = await getStore(ctx.cwd); - const availability = await getResearchAvailability(store); - if (!availability.ok) { - return { - content: [{ type: "text", text: availability.message! }], - details: { runs: [], setup: { code: availability.code, message: availability.message } }, - }; - } - - const runs = await store.getResearchStore().listRuns({ status: params.status as ResearchRunStatus | undefined, limit: params.limit ?? 10 }); - const text = runs.length ? runs.map((run) => `- ${run.id} [${run.status}] ${run.query}`).join("\n") : "No research runs found."; - return { content: [{ type: "text", text }], details: { runs: runs.map(toResearchRunDetails) } }; - }, - }); - - pi.registerTool({ - name: "fn_research_get", - label: "fn: Get Research Run", - description: "Cited-research pipeline: get one run with structured findings and citations (not experiment-loop state).", - parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const store = await getStore(ctx.cwd); - const availability = await getResearchAvailability(store); - if (!availability.ok) { - return { - content: [{ type: "text", text: availability.message! }], - details: { - runId: params.id, - status: "unavailable", - summary: null, - findings: [], - citations: [], - error: availability.message, - setup: { code: availability.code, message: availability.message }, - }, - }; - } - - const run = await store.getResearchStore().getRun(params.id); - if (!run) { - return { - content: [{ type: "text", text: `Research run ${params.id} not found.` }], - details: { - runId: params.id, - status: "missing", - summary: null, - findings: [], - citations: [], - error: "not found", - setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` }, - }, - }; - } - return { content: [{ type: "text", text: `Research run ${run.id} is ${run.status}.` }], details: toResearchRunDetails(run) }; - }, - }); - - pi.registerTool({ - name: "fn_research_cancel", - label: "fn: Cancel Research Run", - description: "Cited-research pipeline: cancel an in-flight run; terminal runs return INVALID_TRANSITION (does not control experiment loops).", - parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const store = await getStore(ctx.cwd); - const availability = await getResearchAvailability(store); - if (!availability.ok) { - return { - content: [{ type: "text", text: availability.message! }], - isError: true, - details: { - runId: params.id, - status: "unavailable", - summary: null, - findings: [], - citations: [], - error: availability.message, - setup: { code: availability.code, message: availability.message }, - }, - }; - } - - const researchStore = store.getResearchStore(); - const run = await researchStore.getRun(params.id); - if (!run) { - return { - content: [{ type: "text", text: `Research run ${params.id} not found.` }], - isError: true, - details: { - runId: params.id, - status: "missing", - summary: null, - findings: [], - citations: [], - error: "not found", - setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` }, - }, - }; - } - - if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) { - return { - content: [{ type: "text", text: `Research run ${params.id} cannot be cancelled from status ${run.status}.` }], - isError: true, - details: { - ...toResearchRunDetails(run), - error: "invalid transition", - setup: { code: "INVALID_TRANSITION", message: "Cancel is only available for queued/running/cancelling/retry_waiting runs." }, - }, - }; - } - - const updated = await researchStore.requestCancellation(params.id); - return { - content: [{ type: "text", text: `Requested cancellation for research run ${params.id} (status: ${updated.status}).` }], - details: toResearchRunDetails(updated), - }; - }, - }); - - pi.registerTool({ - name: "fn_research_retry", - label: "fn: Retry Research Run", - description: "Cited-research pipeline: retry a failed run when lifecycle marks it retryable (not an autonomous experiment loop retry).", - parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const store = await getStore(ctx.cwd); - const availability = await getResearchAvailability(store); - if (!availability.ok) { - return { - content: [{ type: "text", text: availability.message! }], - isError: true, - details: { - runId: params.id, - status: "unavailable", - summary: null, - findings: [], - citations: [], - error: availability.message, - setup: { code: availability.code, message: availability.message }, - }, - }; - } - - const researchStore = store.getResearchStore(); - const run = await researchStore.getRun(params.id); - if (!run) { - return { - content: [{ type: "text", text: `Research run ${params.id} not found.` }], - isError: true, - details: { - runId: params.id, - status: "missing", - summary: null, - findings: [], - citations: [], - error: "not found", - setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` }, - }, - }; - } - const isRetryExhausted = run.status === "retry_exhausted" || run.lifecycle?.errorCode === "RETRY_EXHAUSTED"; - if ((run.status !== "failed" && run.status !== "timed_out") || run.lifecycle?.retryable === false || isRetryExhausted) { - return { - content: [{ type: "text", text: `Research run ${params.id} is not retryable from status ${run.status}.` }], - isError: true, - details: { - ...toResearchRunDetails(run), - error: "not retryable", - setup: { code: isRetryExhausted ? "RETRY_EXHAUSTED" : "INVALID_TRANSITION", message: "Retry is only available for failed/timed_out retryable runs." }, - }, - }; - } - - const retryRun = await researchStore.createRetryRun(params.id); - return { - content: [{ type: "text", text: `Created retry run ${retryRun.id} from ${params.id}.` }], - details: toResearchRunDetails(retryRun), - }; - }, - }); + /* + FNXC:MergeQueue 2026-07-15-11:28: + Do not register fn_research_* on the host pi extension. These tools dual-boot a second TaskStore via getStore and can wedge agent turns with wait_for_completion polling (same hang class as FN-7956 fn_task_show). Bounded research remains available only when the engine injects createResearchTools into triage/executor/heartbeat sessions with experimentalFeatures.researchView enabled. Operators use `fn research` CLI / dashboard Research view. + */ pi.registerTool({ name: "fn_experiment_finalize",