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:
@@ -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
|
||||
|
||||
|
||||
@@ -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[]) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
95
packages/cli/src/__tests__/research-extension-tools.test.ts
Normal file
95
packages/cli/src/__tests__/research-extension-tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user