feat(FN-2996): add research agent tools with extension wiring, remote setti

This merge brings multiple substantial features: new research extension tools wired through the AI engine (with full test coverage and documentation), legacy routines agentId backward compatibility with migration paths, migration of experimental remote settings to the global scope, Nerd Font glyph a

Fusion-Task-Id: FN-2996
This commit is contained in:
Fusion
2026-04-30 20:37:18 -07:00
committed by gsxdsm
parent 5d463851f3
commit 6670837b7e
15 changed files with 730 additions and 30 deletions

View File

@@ -33,6 +33,7 @@ Mission → Milestone → Slice → Feature → Task
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
- **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_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`
<!-- END: tool-categories -->
- **Dashboard** — Use `/fn` command to start/stop the dashboard

View File

@@ -17,6 +17,10 @@ These tools are **not** part of the pi extension's user-invokable `extension.ts`
| `fn_memory_search` | triage, executor, heartbeat | Search project/agent memory snippets | `query` (string), `limit?` (number) |
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window | `path` (string), `startLine?` (number), `lineCount?` (number) |
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append long-term/daily memory notes | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
| `fn_research_run` | triage, executor | Start a bounded research run (optionally wait for completion) and return structured findings metadata | `query` (string), `wait_for_completion?` (boolean), `max_wait_ms?` (number) |
| `fn_research_list` | triage, executor | List recent research runs with status/summary metadata | `status?` (`pending` \| `running` \| `completed` \| `failed` \| `cancelled`), `limit?` (number) |
| `fn_research_get` | triage, executor | Read one research run's structured findings/citations payload | `id` (string) |
| `fn_research_cancel` | triage, executor | Cancel an active research run via orchestrator cancellation path | `id` (string) |
| `fn_reflect_on_performance` | executor | Generate reflection insights from prior runs | `focus_area?` (string) |
| `fn_list_agents` | triage, executor, heartbeat | List agents (optionally filtered) | `role?` (string), `state?` (string), `includeEphemeral?` (boolean) |
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]) |

View File

@@ -324,6 +324,41 @@ Show a single insight-generation run by ID.
|-----------|------|----------|-------------|
| `id` | string | ✓ | Insight run ID (e.g. INSR-XXXXX) |
## Other Tools
### fn_research_run
Start a bounded research run and optionally wait for findings.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | ✓ | Research query or question |
### fn_research_list
List recent research runs.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string(enum) | — | |
| `limit` | number | — | Max runs to return (default: 10) |
### fn_research_get
Get one research run and structured findings.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Research run ID |
### fn_research_cancel
Cancel a research run.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Research run ID |
<!-- END: extension-tools -->
## Dashboard Command

View File

@@ -29,6 +29,10 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. |
| `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. |
| `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. |
| `fn_research_run` | Start a bounded research run and optionally wait for findings. |
| `fn_research_list` | List recent research runs. |
| `fn_research_get` | Get one research run and structured findings. |
| `fn_research_cancel` | Cancel a research run. |
| `fn_insight_list` | List persisted project insights with optional category/status filters. |
| `fn_insight_show` | Show a single persisted insight by ID. |
| `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. |

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import kbExtension from "../extension.js";
import { TaskStore } from "@fusion/core";
interface RegisteredTool {
name: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
return {
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
function makeCtx(cwd: string) {
return { cwd } as any;
}
describe("research extension tools", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-research-test-"));
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("registers research extension tools", () => {
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);
});
it("returns actionable disabled response when research is off", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({ researchSettings: { enabled: false } });
const runTool = api.tools.get("fn_research_run")!;
const result = await runTool.execute("call-1", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
expect(result.details.setup.code).toBe("feature-disabled");
expect(result.content[0].text).toContain("disabled");
});
it("creates, reads, lists, and cancels runs", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({
researchWebSearchProvider: "tavily",
researchTavilyApiKey: "test-key",
researchSettings: { enabled: true, searchProvider: "tavily" },
});
const runTool = api.tools.get("fn_research_run")!;
const runResult = await runTool.execute("call-1", { query: "fusion architecture" }, undefined, undefined, makeCtx(tmpDir));
expect(runResult.details.runId).toBeTruthy();
const listTool = api.tools.get("fn_research_list")!;
const listResult = await listTool.execute("call-2", {}, undefined, undefined, makeCtx(tmpDir));
expect(listResult.details.runs.length).toBeGreaterThan(0);
const getTool = api.tools.get("fn_research_get")!;
const getResult = await getTool.execute("call-3", { id: runResult.details.runId }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.runId).toBe(runResult.details.runId);
const cancelTool = api.tools.get("fn_research_cancel")!;
const cancelResult = await cancelTool.execute("call-4", { id: runResult.details.runId }, undefined, undefined, makeCtx(tmpDir));
expect(cancelResult.details.status).toBe("cancelled");
});
});

View File

@@ -11,6 +11,9 @@ import {
type InsightStatus,
type InsightRunStatus,
type InsightRunTrigger,
type ResearchRun,
type ResearchRunStatus,
resolveResearchSettings,
} from "@fusion/core";
import {
getGhErrorMessage,
@@ -128,6 +131,45 @@ function formatTaskLine(t: Task): string {
return `${t.id} ${label}${deps}${paused}`;
}
async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean; code?: string; message?: string }> {
const settings = await store.getSettings();
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.researchWebSearchProvider;
const configured = backend === "searxng"
? Boolean(settings.researchSearxngUrl)
: backend === "brave"
? Boolean(settings.researchBraveApiKey)
: backend === "google"
? Boolean(settings.researchGoogleSearchApiKey && settings.researchGoogleSearchCx)
: backend === "tavily"
? Boolean(settings.researchTavilyApiKey)
: false;
if (!configured && !resolved.searchProvider) {
return { ok: false, code: "provider-unavailable", message: "Research provider is not configured. Set research provider credentials in Settings." };
}
return { ok: true };
}
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,
};
}
interface GitHubIssueApiResult {
number: number;
title: string;
@@ -1182,6 +1224,97 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── Research Tools ──────────────────────────────────────────────
pi.registerTool({
name: "fn_research_run",
label: "fn: Run Research",
description: "Start a bounded research run and optionally wait for findings.",
parameters: Type.Object({
query: Type.String({ description: "Research query or question" }),
}),
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 } },
};
}
const run = store.getResearchStore().createRun({
query: params.query,
topic: params.query,
providerConfig: {},
});
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),
};
},
});
pi.registerTool({
name: "fn_research_list",
label: "fn: List Research Runs",
description: "List recent research runs.",
parameters: Type.Object({
status: Type.Optional(StringEnum(["pending", "running", "completed", "failed", "cancelled"]) 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 runs = 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: "Get one research run and structured findings.",
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const run = 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: null },
};
}
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: "Cancel a research run.",
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const researchStore = store.getResearchStore();
const run = researchStore.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: null },
};
}
researchStore.updateStatus(params.id, "cancelled", { cancelledAt: new Date().toISOString(), error: "Cancelled via extension" });
const updated = researchStore.getRun(params.id)!;
return {
content: [{ type: "text", text: `Marked research run ${params.id} as cancelled.` }],
details: toResearchRunDetails(updated),
};
},
});
// ── Insights Tools ──────────────────────────────────────────────
pi.registerTool({