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:
@@ -121,6 +121,24 @@ Agent deletion is available from both the detail header lifecycle controls and t
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Research Tools in Planning/Execution Sessions
|
||||||
|
|
||||||
|
Triage and executor runtime sessions now include a bounded research tool surface:
|
||||||
|
|
||||||
|
- `fn_research_run` — create/start a bounded research run for a focused query
|
||||||
|
- `fn_research_list` — list recent runs and statuses
|
||||||
|
- `fn_research_get` — fetch one run's structured findings payload
|
||||||
|
- `fn_research_cancel` — cancel an active run
|
||||||
|
|
||||||
|
These tools return structured metadata (`runId`, `status`, `summary`, `findings`, `citations`, `error`, `setup`) in addition to concise text so downstream model steps can consume results deterministically.
|
||||||
|
|
||||||
|
Expected behavior and boundaries:
|
||||||
|
|
||||||
|
- Agents should use research only when repository/local context is insufficient
|
||||||
|
- Queries should stay narrow and task-scoped; avoid open-ended exploration
|
||||||
|
- If research is disabled or provider setup is incomplete, tools return actionable `setup` responses instead of crashing
|
||||||
|
- Durable conclusions should be persisted with `fn_task_document_write` (for example, `key="research"`)
|
||||||
|
|
||||||
## Built-In Agent Prompt Templates
|
## Built-In Agent Prompt Templates
|
||||||
|
|
||||||
Fusion includes built-in templates for role prompts:
|
Fusion includes built-in templates for role prompts:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Mission → Milestone → Slice → Feature → Task
|
|||||||
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
|
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
|
||||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||||
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`
|
- **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 -->
|
<!-- END: tool-categories -->
|
||||||
- **Dashboard** — Use `/fn` command to start/stop the dashboard
|
- **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_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_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_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_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_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[]) |
|
| `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) |
|
| `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 -->
|
<!-- END: extension-tools -->
|
||||||
## Dashboard Command
|
## 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_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_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_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_list` | List persisted project insights with optional category/status filters. |
|
||||||
| `fn_insight_show` | Show a single persisted insight by ID. |
|
| `fn_insight_show` | Show a single persisted insight by ID. |
|
||||||
| `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. |
|
| `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 InsightStatus,
|
||||||
type InsightRunStatus,
|
type InsightRunStatus,
|
||||||
type InsightRunTrigger,
|
type InsightRunTrigger,
|
||||||
|
type ResearchRun,
|
||||||
|
type ResearchRunStatus,
|
||||||
|
resolveResearchSettings,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
getGhErrorMessage,
|
getGhErrorMessage,
|
||||||
@@ -128,6 +131,45 @@ function formatTaskLine(t: Task): string {
|
|||||||
return `${t.id} ${label}${deps}${paused}`;
|
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 {
|
interface GitHubIssueApiResult {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
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 ──────────────────────────────────────────────
|
// ── Insights Tools ──────────────────────────────────────────────
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
|
|||||||
@@ -637,18 +637,21 @@
|
|||||||
/* === Quick Chat Mobile (FN: full-screen) =================================== */
|
/* === Quick Chat Mobile (FN: full-screen) =================================== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.quick-chat-panel {
|
.quick-chat-panel {
|
||||||
/* Full-screen sheet that ignores drag position on mobile. The
|
/* Full-screen sheet that ignores drag position on mobile. `top`
|
||||||
JSX-level style only emits right/bottom on desktop now, but we
|
follows visualViewport.offsetTop because on the *second* input
|
||||||
still pin every edge with !important here so any stray inline
|
focus iOS Safari shifts the visual viewport (offsetTop > 0)
|
||||||
value (or a future regression) cannot pull the panel off-screen.
|
instead of repositioning the keyboard, so a fixed panel pinned
|
||||||
`top: 0` is intentional — when the iOS keyboard opens we just
|
at layout top:0 ends up rendering at visual -offsetTop and slides
|
||||||
shrink height (via --vv-height), the panel does not translate
|
off-screen. Translating the panel by offsetTop puts the header
|
||||||
so the header stays in place. */
|
back at visual y=0 — i.e. the user still sees the top of the
|
||||||
|
chat box. The CSS variable is written directly to the DOM via
|
||||||
|
a layout effect, so it tracks the keyboard 1:1 with no React
|
||||||
|
state lag. */
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
inset: 0 !important;
|
inset: 0 !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
top: 0 !important;
|
top: var(--vv-offset-top, 0px) !important;
|
||||||
bottom: 0 !important;
|
bottom: 0 !important;
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
|
|||||||
@@ -901,43 +901,50 @@ export function QuickChatFAB({
|
|||||||
// iOS keeps the keyboard up across that transfer.
|
// iOS keeps the keyboard up across that transfer.
|
||||||
const stealthInputRef = useRef<HTMLInputElement | null>(null);
|
const stealthInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Lock body scroll while the panel is open on mobile. Otherwise iOS
|
// Pin the document at the top while the panel is open on mobile.
|
||||||
// can leave the document scrolled (e.g. after the keyboard was opened
|
// Otherwise iOS can leave window.scrollY > 0 (e.g. after the keyboard
|
||||||
// and dismissed once), and on the next open the position:fixed panel
|
// was opened and dismissed once), and on the next open the
|
||||||
// anchors to layout top:0 which is *above* the visible viewport — only
|
// position:fixed panel anchors to layout top:0 which is *above* the
|
||||||
// the bottom of the panel (the input bar) pokes into view at the top of
|
// visible viewport — only the bottom of the panel (the input bar)
|
||||||
// the screen. Locking the body keeps layout-top and visual-top aligned.
|
// pokes into view at the top of the screen.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT use `body { position: fixed }` to lock scroll:
|
||||||
|
// that would make the body the containing block for the panel's
|
||||||
|
// position:fixed and reintroduce the same translation bug. Instead we
|
||||||
|
// scroll to 0 and lock overflow on <html> and <body>; the panel's
|
||||||
|
// viewport anchor stays correct.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||||
if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||||
|
|
||||||
const scrollY = window.scrollY;
|
const scrollY = window.scrollY;
|
||||||
|
const html = document.documentElement;
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const prev = {
|
const prev = {
|
||||||
position: body.style.position,
|
htmlOverflow: html.style.overflow,
|
||||||
top: body.style.top,
|
bodyOverflow: body.style.overflow,
|
||||||
width: body.style.width,
|
|
||||||
overflow: body.style.overflow,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
body.style.position = "fixed";
|
window.scrollTo(0, 0);
|
||||||
body.style.top = `-${scrollY}px`;
|
html.style.overflow = "hidden";
|
||||||
body.style.width = "100%";
|
|
||||||
body.style.overflow = "hidden";
|
body.style.overflow = "hidden";
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
body.style.position = prev.position;
|
html.style.overflow = prev.htmlOverflow;
|
||||||
body.style.top = prev.top;
|
body.style.overflow = prev.bodyOverflow;
|
||||||
body.style.width = prev.width;
|
|
||||||
body.style.overflow = prev.overflow;
|
|
||||||
window.scrollTo(0, scrollY);
|
window.scrollTo(0, scrollY);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Mirror visualViewport.height onto the panel as --vv-height directly,
|
// Mirror visualViewport metrics onto the panel as CSS variables
|
||||||
// bypassing React state. The panel just shrinks when the iOS keyboard
|
// directly, bypassing React state. --vv-height shrinks the panel to
|
||||||
// opens — top stays at 0 so the header remains visible.
|
// the visible area; --vv-offset-top translates it back into view when
|
||||||
|
// iOS shifts the visual viewport on input focus (which would otherwise
|
||||||
|
// slide the position:fixed panel off-screen, especially on the second
|
||||||
|
// focus after the keyboard has been dismissed once). Going through
|
||||||
|
// setState here introduced per-event React reconciliation lag that
|
||||||
|
// showed up as visible jank during the keyboard animation.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
if (typeof window === "undefined" || !window.visualViewport) return;
|
if (typeof window === "undefined" || !window.visualViewport) return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createTaskLogToolWithContext,
|
createTaskLogToolWithContext,
|
||||||
createSendMessageTool,
|
createSendMessageTool,
|
||||||
createReadMessagesTool,
|
createReadMessagesTool,
|
||||||
|
createResearchTools,
|
||||||
qmdAgentMemoryCollectionName,
|
qmdAgentMemoryCollectionName,
|
||||||
sendMessageParams,
|
sendMessageParams,
|
||||||
readMessagesParams,
|
readMessagesParams,
|
||||||
@@ -610,6 +611,114 @@ describe("createSendMessageTool", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createResearchTools", () => {
|
||||||
|
const baseSettings = {
|
||||||
|
researchGlobalEnabled: true,
|
||||||
|
researchGlobalMaxConcurrentRuns: 2,
|
||||||
|
researchGlobalDefaultTimeout: 30_000,
|
||||||
|
researchGlobalMaxSynthesisRounds: 2,
|
||||||
|
researchWebSearchProvider: "none",
|
||||||
|
researchSettings: { enabled: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function createStoreMock(overrides: Record<string, unknown> = {}) {
|
||||||
|
const runs = new Map<string, any>();
|
||||||
|
const researchStore = {
|
||||||
|
createRun: vi.fn().mockImplementation((input: any) => {
|
||||||
|
const run = {
|
||||||
|
id: "RES-001",
|
||||||
|
query: input.query,
|
||||||
|
status: "pending",
|
||||||
|
providerConfig: input.providerConfig,
|
||||||
|
sources: [],
|
||||||
|
events: [],
|
||||||
|
tags: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
runs.set(run.id, run);
|
||||||
|
return run;
|
||||||
|
}),
|
||||||
|
getRun: vi.fn((id: string) => runs.get(id) ?? null),
|
||||||
|
listRuns: vi.fn(() => [...runs.values()]),
|
||||||
|
updateRun: vi.fn((id: string, updates: any) => {
|
||||||
|
const existing = runs.get(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
const next = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||||
|
runs.set(id, next);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
updateStatus: vi.fn((id: string, status: string) => {
|
||||||
|
const existing = runs.get(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
const next = { ...existing, status, updatedAt: new Date().toISOString() };
|
||||||
|
runs.set(id, next);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
addEvent: vi.fn(),
|
||||||
|
addSource: vi.fn(),
|
||||||
|
updateSource: vi.fn(),
|
||||||
|
setResults: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
runs,
|
||||||
|
store: {
|
||||||
|
getResearchStore: vi.fn(() => researchStore),
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
researchStore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns actionable disabled response when research is off", async () => {
|
||||||
|
const { store } = createStoreMock();
|
||||||
|
const tools = createResearchTools({
|
||||||
|
store: store as any,
|
||||||
|
rootDir: process.cwd(),
|
||||||
|
getSettings: async () => ({ ...baseSettings, researchSettings: { enabled: false } } as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runTool = tools.find((tool) => tool.name === "fn_research_run")!;
|
||||||
|
const result = await (runTool as any).execute("call-1", { query: "fusion" }, undefined, undefined, undefined);
|
||||||
|
|
||||||
|
expect(result.details.setup.code).toBe("feature-disabled");
|
||||||
|
expect(result.content[0].text).toContain("Research is disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts research and returns structured run details", async () => {
|
||||||
|
const { store, researchStore, runs } = createStoreMock();
|
||||||
|
const tools = createResearchTools({
|
||||||
|
store: store as any,
|
||||||
|
rootDir: process.cwd(),
|
||||||
|
getSettings: async () => ({ ...baseSettings, researchTavilyApiKey: "key", researchWebSearchProvider: "tavily" } as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runTool = tools.find((tool) => tool.name === "fn_research_run")!;
|
||||||
|
const result = await (runTool as any).execute("call-1", { query: "fusion roadmap" }, undefined, undefined, undefined);
|
||||||
|
|
||||||
|
expect(researchStore.createRun).toHaveBeenCalled();
|
||||||
|
expect(result.details).toMatchObject({ runId: "RES-001", status: "pending", findings: [], citations: [] });
|
||||||
|
runs.set("RES-001", { ...runs.get("RES-001"), status: "completed", results: { summary: "Done", findings: [], citations: [] } });
|
||||||
|
|
||||||
|
const getTool = tools.find((tool) => tool.name === "fn_research_get")!;
|
||||||
|
const getResult = await (getTool as any).execute("call-2", { id: "RES-001" }, undefined, undefined, undefined);
|
||||||
|
expect(getResult.details.summary).toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not-found metadata for missing runs", async () => {
|
||||||
|
const { store } = createStoreMock();
|
||||||
|
const tools = createResearchTools({
|
||||||
|
store: store as any,
|
||||||
|
rootDir: process.cwd(),
|
||||||
|
getSettings: async () => ({ ...baseSettings } as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTool = tools.find((tool) => tool.name === "fn_research_get")!;
|
||||||
|
const result = await (getTool as any).execute("call-1", { id: "RES-404" }, undefined, undefined, undefined);
|
||||||
|
expect(result.details.status).toBe("missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("createReadMessagesTool", () => {
|
describe("createReadMessagesTool", () => {
|
||||||
let messageStore: ReturnType<typeof createMockMessageStore>;
|
let messageStore: ReturnType<typeof createMockMessageStore>;
|
||||||
let tool: ReturnType<typeof createReadMessagesTool>;
|
let tool: ReturnType<typeof createReadMessagesTool>;
|
||||||
|
|||||||
@@ -4677,6 +4677,14 @@ describe("Code review verdict enforcement - fn_task_update blocking", () => {
|
|||||||
expect(allowed.content[0].text).toContain("→ done");
|
expect(allowed.content[0].text).toContain("→ done");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers research runtime tools in customTools", async () => {
|
||||||
|
const tools = await captureTools();
|
||||||
|
expect(tools.fn_research_run).toBeTypeOf("function");
|
||||||
|
expect(tools.fn_research_list).toBeTypeOf("function");
|
||||||
|
expect(tools.fn_research_get).toBeTypeOf("function");
|
||||||
|
expect(tools.fn_research_cancel).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
it("REVISE tool response text includes re-review instructions", async () => {
|
it("REVISE tool response text includes re-review instructions", async () => {
|
||||||
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Bug found", summary: "Issues" });
|
mockedReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Bug found", summary: "Issues" });
|
||||||
|
|
||||||
|
|||||||
@@ -523,6 +523,11 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TRIAGE_SYSTEM_PROMPT", () => {
|
describe("TRIAGE_SYSTEM_PROMPT", () => {
|
||||||
|
it("includes bounded research guidance", () => {
|
||||||
|
expect(TRIAGE_SYSTEM_PROMPT).toContain("fn_research_run");
|
||||||
|
expect(TRIAGE_SYSTEM_PROMPT).toContain("Keep research bounded");
|
||||||
|
});
|
||||||
|
|
||||||
it("requires specs to keep lint, tests, build, and typecheck green even outside initial file scope", () => {
|
it("requires specs to keep lint, tests, build, and typecheck green even outside initial file scope", () => {
|
||||||
expect(TRIAGE_SYSTEM_PROMPT).toContain("If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope");
|
expect(TRIAGE_SYSTEM_PROMPT).toContain("If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope");
|
||||||
expect(TRIAGE_SYSTEM_PROMPT).toContain("Run lint check");
|
expect(TRIAGE_SYSTEM_PROMPT).toContain("Run lint check");
|
||||||
@@ -694,6 +699,10 @@ describe("fast-mode triage", () => {
|
|||||||
|
|
||||||
const { promptWithFallback } = await import("../pi.js");
|
const { promptWithFallback } = await import("../pi.js");
|
||||||
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
|
||||||
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_run")).toBe(true);
|
||||||
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(true);
|
||||||
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(true);
|
||||||
|
expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(true);
|
||||||
await writeFile(promptPath, "# Task: FN-FAST-004 - Fast\n\n## Mission\n\nShip it.");
|
await writeFile(promptPath, "# Task: FN-FAST-004 - Fast\n\n## Mission\n\nShip it.");
|
||||||
const reviewTool = capturedTools.find((tool) => tool.name === "fn_review_spec");
|
const reviewTool = capturedTools.find((tool) => tool.name === "fn_review_spec");
|
||||||
expect(reviewTool).toBeDefined();
|
expect(reviewTool).toBeDefined();
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType } from "@fusion/core";
|
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus } from "@fusion/core";
|
||||||
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, resolveResearchSettings, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
||||||
|
import { ResearchOrchestrator } from "./research-orchestrator.js";
|
||||||
|
import { ResearchProviderRegistry } from "./research/provider-registry.js";
|
||||||
|
import { ResearchStepRunner } from "./research-step-runner.js";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||||
@@ -100,6 +103,31 @@ export const memoryGetParams = Type.Object({
|
|||||||
lineCount: Type.Optional(Type.Number({ description: "Number of lines to read (default: 120, max: 400)" })),
|
lineCount: Type.Optional(Type.Number({ description: "Number of lines to read (default: 120, max: 400)" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const researchRunParams = Type.Object({
|
||||||
|
query: Type.String({ description: "Research question or topic to investigate" }),
|
||||||
|
wait_for_completion: Type.Optional(Type.Boolean({ description: "Wait for completion in this call (default: false)" })),
|
||||||
|
max_wait_ms: Type.Optional(Type.Number({ description: "Max wait time when wait_for_completion=true (default: 90000, capped by settings)" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const researchListParams = Type.Object({
|
||||||
|
status: Type.Optional(Type.Union([
|
||||||
|
Type.Literal("pending"),
|
||||||
|
Type.Literal("running"),
|
||||||
|
Type.Literal("completed"),
|
||||||
|
Type.Literal("failed"),
|
||||||
|
Type.Literal("cancelled"),
|
||||||
|
], { description: "Optional status filter" })),
|
||||||
|
limit: Type.Optional(Type.Number({ description: "Max runs to return (default: 10)" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const researchGetParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Research run ID" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const researchCancelParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Research run ID to cancel" }),
|
||||||
|
});
|
||||||
|
|
||||||
export const memoryAppendParams = Type.Object({
|
export const memoryAppendParams = Type.Object({
|
||||||
scope: Type.Optional(Type.Union([
|
scope: Type.Optional(Type.Union([
|
||||||
Type.Literal("project"),
|
Type.Literal("project"),
|
||||||
@@ -1027,6 +1055,230 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
|||||||
* @param agentId - The agent ID whose inbox to read
|
* @param agentId - The agent ID whose inbox to read
|
||||||
* @returns ToolDefinition for the `fn_read_messages` tool
|
* @returns ToolDefinition for the `fn_read_messages` tool
|
||||||
*/
|
*/
|
||||||
|
type ResearchToolsOptions = {
|
||||||
|
store: TaskStore;
|
||||||
|
rootDir: string;
|
||||||
|
getSettings: () => Promise<Settings>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatResearchRunDetails(run: ResearchRun) {
|
||||||
|
const findings = run.results?.findings ?? [];
|
||||||
|
const citations = run.results?.citations ?? [];
|
||||||
|
return {
|
||||||
|
runId: run.id,
|
||||||
|
status: run.status,
|
||||||
|
query: run.query,
|
||||||
|
summary: run.results?.summary ?? null,
|
||||||
|
findings,
|
||||||
|
citations,
|
||||||
|
sourceCount: run.sources.length,
|
||||||
|
error: run.error ?? null,
|
||||||
|
setup: null as null | { code: string; message: string },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function researchUnavailable(code: string, message: string) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: message }],
|
||||||
|
details: {
|
||||||
|
runId: null,
|
||||||
|
status: "unavailable",
|
||||||
|
summary: null,
|
||||||
|
findings: [],
|
||||||
|
citations: [],
|
||||||
|
error: message,
|
||||||
|
setup: { code, message },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResearchTools(options: ResearchToolsOptions): ToolDefinition[] {
|
||||||
|
const orchestratorState: {
|
||||||
|
orchestrator: ResearchOrchestrator | null;
|
||||||
|
providerRegistry: ResearchProviderRegistry | null;
|
||||||
|
inFlight: Map<string, Promise<void>>;
|
||||||
|
} = {
|
||||||
|
orchestrator: null,
|
||||||
|
providerRegistry: null,
|
||||||
|
inFlight: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureOrchestrator = async (): Promise<ResearchOrchestrator | null> => {
|
||||||
|
const settings = await options.getSettings();
|
||||||
|
const resolved = resolveResearchSettings(settings);
|
||||||
|
if (!resolved.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orchestratorState.providerRegistry) {
|
||||||
|
orchestratorState.providerRegistry = new ResearchProviderRegistry(settings, options.rootDir);
|
||||||
|
} else {
|
||||||
|
orchestratorState.providerRegistry.refreshSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = orchestratorState.providerRegistry;
|
||||||
|
const availableProviders = registry.getAvailableProviders();
|
||||||
|
if (availableProviders.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orchestratorState.orchestrator) {
|
||||||
|
const stepRunner = new ResearchStepRunner({
|
||||||
|
providers: availableProviders
|
||||||
|
.map((type) => registry.getProvider(type))
|
||||||
|
.filter((provider): provider is NonNullable<typeof provider> => Boolean(provider)),
|
||||||
|
});
|
||||||
|
orchestratorState.orchestrator = new ResearchOrchestrator({
|
||||||
|
store: options.store.getResearchStore(),
|
||||||
|
stepRunner,
|
||||||
|
maxConcurrentRuns: resolved.limits.maxConcurrentRuns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return orchestratorState.orchestrator;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTool: ToolDefinition = {
|
||||||
|
name: "fn_research_run",
|
||||||
|
label: "Run Research",
|
||||||
|
description: "Start a bounded research run and optionally wait for completion to get findings.",
|
||||||
|
parameters: researchRunParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof researchRunParams>) => {
|
||||||
|
const settings = await options.getSettings();
|
||||||
|
const resolved = resolveResearchSettings(settings);
|
||||||
|
if (!resolved.enabled) {
|
||||||
|
return researchUnavailable("feature-disabled", "Research is disabled in settings. Enable researchSettings.enabled or global research defaults first.");
|
||||||
|
}
|
||||||
|
const orchestrator = await ensureOrchestrator();
|
||||||
|
if (!orchestrator) {
|
||||||
|
return researchUnavailable("provider-unavailable", "Research providers are not configured. Add provider credentials in Settings → Authentication and select a research provider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = orchestratorState.providerRegistry;
|
||||||
|
const availableProviderTypes = registry?.getAvailableProviders() ?? [];
|
||||||
|
const runId = orchestrator.createRun({
|
||||||
|
providers: availableProviderTypes
|
||||||
|
.filter((type) => type !== "llm-synthesis")
|
||||||
|
.map((type) => ({ type, config: { maxResults: resolved.limits.maxSourcesPerRun, timeoutMs: resolved.limits.requestTimeoutMs } })),
|
||||||
|
maxSources: resolved.limits.maxSourcesPerRun,
|
||||||
|
maxSynthesisRounds: Math.max(1, settings.researchMaxSynthesisRounds ?? settings.researchGlobalMaxSynthesisRounds ?? 2),
|
||||||
|
phaseTimeoutMs: resolved.limits.maxDurationMs,
|
||||||
|
stepTimeoutMs: resolved.limits.requestTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runPromise = orchestrator.startRun(runId, params.query);
|
||||||
|
orchestratorState.inFlight.set(runId, runPromise.then(() => undefined).catch(() => undefined));
|
||||||
|
void runPromise.finally(() => orchestratorState.inFlight.delete(runId));
|
||||||
|
|
||||||
|
if (!params.wait_for_completion) {
|
||||||
|
const started = options.store.getResearchStore().getRun(runId);
|
||||||
|
if (!started) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Started research run ${runId} for: ${params.query}` }],
|
||||||
|
details: { runId, status: "pending", summary: null, findings: [], citations: [], sourceCount: 0, error: null, setup: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Started research run ${runId} for: ${params.query}` }],
|
||||||
|
details: formatResearchRunDetails(started),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWaitMs = Math.max(1_000, Math.min(params.max_wait_ms ?? 90_000, resolved.limits.maxDurationMs));
|
||||||
|
const completed = await Promise.race([
|
||||||
|
runPromise,
|
||||||
|
new Promise<ResearchRun>((resolve) => setTimeout(() => {
|
||||||
|
const latest = options.store.getResearchStore().getRun(runId);
|
||||||
|
resolve(latest ?? ({
|
||||||
|
id: runId,
|
||||||
|
query: params.query,
|
||||||
|
status: "running",
|
||||||
|
sources: [],
|
||||||
|
events: [],
|
||||||
|
tags: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as ResearchRun));
|
||||||
|
}, maxWaitMs)),
|
||||||
|
]);
|
||||||
|
const details = formatResearchRunDetails(completed);
|
||||||
|
const text = details.status === "completed"
|
||||||
|
? `Research run ${runId} completed. ${details.summary ?? "No summary generated."}`
|
||||||
|
: `Research run ${runId} is ${details.status}. Use fn_research_get for updates.`;
|
||||||
|
return { content: [{ type: "text" as const, text }], details };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const listTool: ToolDefinition = {
|
||||||
|
name: "fn_research_list",
|
||||||
|
label: "List Research Runs",
|
||||||
|
description: "List recent research runs with status and summary snippets.",
|
||||||
|
parameters: researchListParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof researchListParams>) => {
|
||||||
|
const limit = Math.max(1, Math.min(params.limit ?? 10, 50));
|
||||||
|
const runs = options.store.getResearchStore().listRuns({
|
||||||
|
status: params.status as ResearchRunStatus | undefined,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
const text = runs.length
|
||||||
|
? runs.map((run) => `- ${run.id} [${run.status}] ${run.query}`).join("\n")
|
||||||
|
: "No research runs found.";
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text }],
|
||||||
|
details: { runs: runs.map((run) => formatResearchRunDetails(run)) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTool: ToolDefinition = {
|
||||||
|
name: "fn_research_get",
|
||||||
|
label: "Get Research Run",
|
||||||
|
description: "Get one research run with structured findings and citations.",
|
||||||
|
parameters: researchGetParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof researchGetParams>) => {
|
||||||
|
const run = options.store.getResearchStore().getRun(params.id);
|
||||||
|
if (!run) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Research run ${params.id} not found.` }],
|
||||||
|
details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const details = formatResearchRunDetails(run);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Research run ${run.id} is ${run.status}.` }],
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelTool: ToolDefinition = {
|
||||||
|
name: "fn_research_cancel",
|
||||||
|
label: "Cancel Research Run",
|
||||||
|
description: "Cancel an active research run.",
|
||||||
|
parameters: researchCancelParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof researchCancelParams>) => {
|
||||||
|
const orchestrator = await ensureOrchestrator();
|
||||||
|
if (!orchestrator) {
|
||||||
|
return researchUnavailable("provider-unavailable", "Research orchestrator is unavailable because research providers are not configured.");
|
||||||
|
}
|
||||||
|
const cancelled = orchestrator.cancelRun(params.id);
|
||||||
|
const run = options.store.getResearchStore().getRun(params.id);
|
||||||
|
if (!run) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, 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" as const, text: cancelled ? `Cancellation requested for ${params.id}.` : `Run ${params.id} is not active.` }],
|
||||||
|
details: formatResearchRunDetails(run),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [runTool, listTool, getTool, cancelTool];
|
||||||
|
}
|
||||||
|
|
||||||
export function createReadMessagesTool(messageStore: MessageStore, agentId: string): ToolDefinition {
|
export function createReadMessagesTool(messageStore: MessageStore, agentId: string): ToolDefinition {
|
||||||
return {
|
return {
|
||||||
name: "fn_read_messages",
|
name: "fn_read_messages",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
createReadMessagesTool,
|
createReadMessagesTool,
|
||||||
createReflectOnPerformanceTool,
|
createReflectOnPerformanceTool,
|
||||||
|
createResearchTools,
|
||||||
createSendMessageTool,
|
createSendMessageTool,
|
||||||
createTaskCreateTool as sharedCreateTaskCreateTool,
|
createTaskCreateTool as sharedCreateTaskCreateTool,
|
||||||
createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool,
|
createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool,
|
||||||
@@ -357,6 +358,12 @@ You can save and retrieve named documents for this task. Use these to store plan
|
|||||||
|
|
||||||
Documents are versioned — each write creates a new revision. Use meaningful keys like "plan", "notes", "research", "architecture".
|
Documents are versioned — each write creates a new revision. Use meaningful keys like "plan", "notes", "research", "architecture".
|
||||||
|
|
||||||
|
## Research tools
|
||||||
|
When implementation needs external context, you may use research tools (
|
||||||
|
\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`) to run bounded research.
|
||||||
|
Keep runs focused and short, and persist durable conclusions into task documents (for example key="research").
|
||||||
|
If research is disabled or providers are not configured, use the actionable tool response and continue with available local context.
|
||||||
|
|
||||||
**IMPORTANT — Save your deliverables as documents:** When your task produces written output (documentation, specifications, reports, API references, README updates, guides, or any other content), you MUST save that content as a task document using \`fn_task_document_write\`. Use a key that describes the deliverable (e.g., key="readme", key="api-docs", key="changelog"). Do this in addition to writing the file to disk — the document persists in the task for review even after the worktree is cleaned up.
|
**IMPORTANT — Save your deliverables as documents:** When your task produces written output (documentation, specifications, reports, API references, README updates, guides, or any other content), you MUST save that content as a task document using \`fn_task_document_write\`. Use a key that describes the deliverable (e.g., key="readme", key="api-docs", key="changelog"). Do this in addition to writing the file to disk — the document persists in the task for review even after the worktree is cleaned up.
|
||||||
|
|
||||||
If the task's PROMPT.md includes a "Documentation Requirements" section listing files to update, save each updated file's final content as a task document with a matching key.
|
If the task's PROMPT.md includes a "Documentation Requirements" section listing files to update, save each updated file's final content as a task document with a matching key.
|
||||||
@@ -2333,6 +2340,11 @@ export class TaskExecutor {
|
|||||||
this.createSpawnAgentTool(task.id, worktreePath, settings),
|
this.createSpawnAgentTool(task.id, worktreePath, settings),
|
||||||
this.createTaskDocumentWriteTool(task.id),
|
this.createTaskDocumentWriteTool(task.id),
|
||||||
this.createTaskDocumentReadTool(task.id),
|
this.createTaskDocumentReadTool(task.id),
|
||||||
|
...createResearchTools({
|
||||||
|
store: this.store,
|
||||||
|
rootDir: this.rootDir,
|
||||||
|
getSettings: async () => this.store.getSettings(),
|
||||||
|
}),
|
||||||
...createMemoryTools(this.rootDir, settings, assignedAgent ? {
|
...createMemoryTools(this.rootDir, settings, assignedAgent ? {
|
||||||
agentMemory: {
|
agentMemory: {
|
||||||
agentId: assignedAgent.id,
|
agentId: assignedAgent.id,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
createDelegateTaskTool,
|
createDelegateTaskTool,
|
||||||
createListAgentsTool,
|
createListAgentsTool,
|
||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
|
createResearchTools,
|
||||||
createTaskDocumentReadTool,
|
createTaskDocumentReadTool,
|
||||||
createTaskDocumentWriteTool,
|
createTaskDocumentWriteTool,
|
||||||
} from "./agent-tools.js";
|
} from "./agent-tools.js";
|
||||||
@@ -231,6 +232,10 @@ When the planning conversation produces a structured plan, save it as a document
|
|||||||
- Testing & Verification must run before Documentation & Delivery
|
- Testing & Verification must run before Documentation & Delivery
|
||||||
- Avoid giant catch-all steps; split outcomes so execution can be verified incrementally
|
- Avoid giant catch-all steps; split outcomes so execution can be verified incrementally
|
||||||
|
|
||||||
|
## Research tools
|
||||||
|
When spec work needs missing domain context, you may use research tools (\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`). Keep research bounded to the task at hand, prefer concise queries, and write durable findings into task documents when useful.
|
||||||
|
If research is unavailable or unconfigured, continue planning with repository context and clearly note assumptions.
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
- Read the project structure and relevant source files to understand context BEFORE writing
|
- Read the project structure and relevant source files to understand context BEFORE writing
|
||||||
- Check package.json/scripts and explicit project commands to align real lint/test/build/typecheck commands
|
- Check package.json/scripts and explicit project commands to align real lint/test/build/typecheck commands
|
||||||
@@ -867,6 +872,11 @@ export class TriageProcessor {
|
|||||||
}),
|
}),
|
||||||
createTaskDocumentWriteTool(this.store, task.id),
|
createTaskDocumentWriteTool(this.store, task.id),
|
||||||
createTaskDocumentReadTool(this.store, task.id),
|
createTaskDocumentReadTool(this.store, task.id),
|
||||||
|
...createResearchTools({
|
||||||
|
store: this.store,
|
||||||
|
rootDir: this.rootDir,
|
||||||
|
getSettings: async () => this.store.getSettings(),
|
||||||
|
}),
|
||||||
...createMemoryTools(this.rootDir, settings),
|
...createMemoryTools(this.rootDir, settings),
|
||||||
// Agent delegation tools — discover and delegate work to other agents.
|
// Agent delegation tools — discover and delegate work to other agents.
|
||||||
...(this.options.agentStore ? [
|
...(this.options.agentStore ? [
|
||||||
|
|||||||
Reference in New Issue
Block a user