feat(FN-3706): gate research tools behind experimental feature

The merge introduces a research tool surface gating mechanism: shared availability helpers in core and engine, applied to the executor and triage agent so research prompts and tool exposure are gated behind experimental-feature flags, with tests covering the new logic.

Fusion-Task-Id: FN-3706
This commit is contained in:
Fusion
2026-05-07 15:50:20 -07:00
committed by gsxdsm
parent e3daa7b3b9
commit aaaebdcb0d
11 changed files with 231 additions and 24 deletions

View File

@@ -19,6 +19,7 @@ import {
} from "../agent-tools.js";
import * as core from "@fusion/core";
import type { MessageStore, Message } from "@fusion/core";
import { getEnabledPluginTools, getResearchToolSurfaceStatus } from "../tool-availability.js";
const loggerSpies = vi.hoisted(() => ({
log: vi.fn(),
@@ -60,6 +61,30 @@ vi.mock("node:child_process", async () => {
};
});
describe("tool availability helpers", () => {
it("treats research surface as disabled when researchView experimental flag is off", () => {
expect(getResearchToolSurfaceStatus({ experimentalFeatures: { researchView: false } } as any)).toEqual({
enabled: false,
reason: "experimental-disabled",
});
});
it("treats research surface as enabled when researchView experimental flag is on", () => {
expect(getResearchToolSurfaceStatus({ experimentalFeatures: { researchView: true } } as any)).toEqual({
enabled: true,
reason: "enabled",
});
});
it("resolves legacy experimental feature aliases through core helper", () => {
expect(core.isExperimentalFeatureEnabled({ experimentalFeatures: { devServer: true } } as any, "devServerView")).toBe(true);
});
it("returns no plugin tools when plugin runner is absent", () => {
expect(getEnabledPluginTools(undefined)).toEqual([]);
});
});
describe("createTaskCreateTool", () => {
it("returns details.taskId and keeps Created <id> response text", async () => {
const store = {

View File

@@ -4938,6 +4938,72 @@ describe("Code review verdict enforcement - fn_task_update blocking", () => {
expect(result.content[0].text).toContain('type="code"');
});
it("omits research prompt guidance when researchView experimental flag is disabled", async () => {
let capturedSystemPrompt = "";
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedSystemPrompt = opts.systemPrompt || "";
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
sessionManager: { getLeafId: vi.fn(), branchWithSummary: vi.fn() },
navigateTree: vi.fn(),
},
} as any;
});
const store = createMockStore();
store.getSettings.mockResolvedValue({ ...(await store.getSettings()), experimentalFeatures: { researchView: false } });
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-SYS-NO-RESEARCH",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(capturedSystemPrompt).not.toContain("fn_research_run");
});
it("includes research prompt guidance when researchView experimental flag is enabled", async () => {
let capturedSystemPrompt = "";
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedSystemPrompt = opts.systemPrompt || "";
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
sessionManager: { getLeafId: vi.fn(), branchWithSummary: vi.fn() },
navigateTree: vi.fn(),
},
} as any;
});
const store = createMockStore();
store.getSettings.mockResolvedValue({ ...(await store.getSettings()), experimentalFeatures: { researchView: true } });
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-SYS-RESEARCH",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(capturedSystemPrompt).toContain("fn_research_run");
});
it("EXECUTOR_SYSTEM_PROMPT contains code review enforcement language", async () => {
// Capture the system prompt passed to createFnAgent
let capturedSystemPrompt = "";

View File

@@ -587,9 +587,9 @@ describe("buildSpecificationPrompt", () => {
});
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("does not include unconditional research guidance", () => {
expect(TRIAGE_SYSTEM_PROMPT).not.toContain("fn_research_run");
expect(TRIAGE_SYSTEM_PROMPT).not.toContain("Keep research bounded");
});
it("requires specs to keep lint, tests, build, and typecheck green even outside initial file scope", () => {
@@ -889,7 +889,7 @@ describe("fast-mode triage", () => {
}
});
it("omits research tools when researchView experimental flag is disabled", async () => {
it("omits research tools and prompt guidance when researchView experimental flag is disabled", async () => {
const task = createTriageTask({ id: "FN-FAST-005", executionMode: "fast" });
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
@@ -904,8 +904,10 @@ describe("fast-mode triage", () => {
});
let capturedTools: any[] = [];
let capturedSystemPrompt = "";
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedTools = opts.customTools;
capturedSystemPrompt = opts.systemPrompt;
return {
session: {
state: {},
@@ -924,6 +926,42 @@ describe("fast-mode triage", () => {
expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(false);
expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(false);
expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(false);
expect(capturedSystemPrompt).not.toContain("fn_research_run");
});
it("includes research prompt guidance when researchView experimental flag is enabled", async () => {
const task = createTriageTask({ id: "FN-FAST-006", executionMode: "fast" });
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
experimentalFeatures: { researchView: true },
} as Settings),
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
});
let capturedSystemPrompt = "";
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedSystemPrompt = opts.systemPrompt;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, "/tmp/root");
await processor.specifyTask(task);
expect(capturedSystemPrompt).toContain("fn_research_run");
expect(capturedSystemPrompt).toContain("Keep research bounded");
});
});

View File

@@ -11,7 +11,6 @@ import {
buildExecutionMemoryInstructions,
getTaskMergeBlocker,
isEphemeralAgent,
isResearchExperimentalEnabled,
resolveAgentPrompt,
resolveEffectiveAgentPermissionPolicy,
resolveProjectDefaultModel,
@@ -73,6 +72,11 @@ import {
createTaskLogTool as sharedCreateTaskLogTool,
} from "./agent-tools.js";
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
import {
getEnabledPluginTools,
getResearchGuidanceForSurface,
isResearchToolSurfaceEnabled,
} from "./tool-availability.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
import { createRunVerificationTool } from "./run-verification-tool.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
@@ -381,12 +385,6 @@ 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".
## 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.
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.
@@ -503,7 +501,12 @@ The tool prevents your session from being killed by the inactivity watchdog duri
/** Resolve the executor system prompt from settings, falling back to the hardcoded constant. */
function getExecutorSystemPrompt(settings: Settings): string {
const customPrompt = resolveAgentPrompt("executor", settings.agentPrompts);
return customPrompt || EXECUTOR_SYSTEM_PROMPT;
const basePrompt = customPrompt || EXECUTOR_SYSTEM_PROMPT;
const sections = [
basePrompt,
isResearchToolSurfaceEnabled(settings) ? getResearchGuidanceForSurface("executor") : "",
].filter((section) => section.trim());
return sections.join("\n\n");
}
@@ -2880,7 +2883,7 @@ export class TaskExecutor {
this.createSpawnAgentTool(task.id, worktreePath, settings),
this.createTaskDocumentWriteTool(task.id),
this.createTaskDocumentReadTool(task.id),
...(isResearchExperimentalEnabled(settings)
...(isResearchToolSurfaceEnabled(settings)
? createResearchTools({
store: this.store,
rootDir: this.rootDir,
@@ -2911,7 +2914,7 @@ export class TaskExecutor {
createReadMessagesTool(this.options.messageStore, assignedAgentId),
] : []),
// Add plugin tools from PluginRunner
...(this.options.pluginRunner?.getPluginTools() ?? []),
...getEnabledPluginTools(this.options.pluginRunner),
];
// Accumulates the full assistant text output for the most recent session.

View File

@@ -0,0 +1,38 @@
import type { Settings } from "@fusion/core";
import { isResearchExperimentalEnabled } from "@fusion/core";
import type { PluginRunner } from "./plugin-runner.js";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
export function isResearchToolSurfaceEnabled(settings: Partial<Settings> | undefined): boolean {
return isResearchExperimentalEnabled(settings);
}
export function getResearchToolSurfaceStatus(settings: Partial<Settings> | undefined): {
enabled: boolean;
reason: "experimental-disabled" | "enabled";
} {
const enabled = isResearchToolSurfaceEnabled(settings);
return {
enabled,
reason: enabled ? "enabled" : "experimental-disabled",
};
}
const TRIAGE_RESEARCH_GUIDANCE = `## 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.`;
const EXECUTOR_RESEARCH_GUIDANCE = `## 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.`;
export function getResearchGuidanceForSurface(surface: "triage" | "executor"): string {
return surface === "triage" ? TRIAGE_RESEARCH_GUIDANCE : EXECUTOR_RESEARCH_GUIDANCE;
}
export function getEnabledPluginTools(pluginRunner: PluginRunner | undefined): ToolDefinition[] {
if (!pluginRunner) return [];
return pluginRunner.getPluginTools();
}

View File

@@ -8,7 +8,6 @@ import type {
} from "@fusion/core";
import {
buildTriageMemoryInstructions,
isResearchExperimentalEnabled,
resolveAgentPrompt,
sortTasksByPriorityThenAgeAndId,
} from "@fusion/core";
@@ -56,6 +55,10 @@ import {
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
} from "./agent-tools.js";
import {
getResearchGuidanceForSurface,
isResearchToolSurfaceEnabled,
} from "./tool-availability.js";
export const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "fn", an AI-orchestrated task board.
@@ -244,10 +247,6 @@ When the planning conversation produces a structured plan, save it as a document
- Testing & Verification must run before Documentation & Delivery
- 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
- 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
@@ -951,7 +950,7 @@ export class TriageProcessor {
}),
createTaskDocumentWriteTool(this.store, task.id),
createTaskDocumentReadTool(this.store, task.id),
...(isResearchExperimentalEnabled(settings)
...(isResearchToolSurfaceEnabled(settings)
? createResearchTools({
store: this.store,
rootDir: this.rootDir,
@@ -1016,7 +1015,13 @@ export class TriageProcessor {
const triageSystemPrompt = buildSystemPromptWithInstructions(
resolveAgentPrompt("triage", settings.agentPrompts)
|| (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : TRIAGE_SYSTEM_PROMPT),
[triageIdentitySection, triageInstructions].filter((section) => section.trim()).join("\n\n"),
[
triageIdentitySection,
triageInstructions,
isResearchToolSurfaceEnabled(settings)
? getResearchGuidanceForSurface("triage")
: "",
].filter((section) => section.trim()).join("\n\n"),
);
const triageContributions = this.options.pluginRunner
?.getPromptContributionsForSurface("triage")