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:
@@ -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 = {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
38
packages/engine/src/tool-availability.ts
Normal file
38
packages/engine/src/tool-availability.ts
Normal 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();
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user