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.
This commit is contained in:
gsxdsm
2026-07-15 11:25:55 -07:00
parent 93baf482f9
commit c6050785bf
8 changed files with 34 additions and 872 deletions

View File

@@ -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`
<!-- END: tool-categories -->
- **Dashboard** — Use `/fn` command to start/stop the dashboard

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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);

View File

@@ -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" }] });
});
});

View File

@@ -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<TaskStore> {
const store = h.store();
await store.updateGlobalSettings({
researchGlobalEnabled: true,
researchGlobalDefaults: { searchProvider: "searxng" },
researchGlobalSearxngUrl: "http://localhost:8888",
experimentalFeatures: { researchView: true } as Record<string, boolean>,
});
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<string, boolean>,
});
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 () => {

View File

@@ -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<string, boolean> });
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<string, boolean> });
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<string, boolean>,
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<string, boolean>,
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<string, boolean>,
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<string, boolean>,
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<string, boolean>,
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<string, boolean>,
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<string, boolean>,
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" });
});
});

View File

@@ -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<string, unknown>) : {};
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<ResearchRunStatus>([
"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<void> {
if (ms <= 0) {
return;
}
await new Promise<void>((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",