feat(FN-3140): add readonly custom tools and plugin SDK

This merge adds readonly custom tool preservation (FN-3140) with new plugin SDK types and documentation, fixes PluginManager responsive overflow (FN-3093), and integrates the fn-3065 branch with enhanced plugin authoring capabilities. The core plugin-types module was significantly expanded with 230+

Fusion-Task-Id: FN-3140
This commit is contained in:
Fusion
2026-05-01 16:52:14 -07:00
committed by gsxdsm
parent c02138ddf4
commit cafe986f1b
3 changed files with 57 additions and 12 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix readonly `createFnAgent` sessions to preserve caller-supplied engine custom tools while still excluding host extensions. This restores delegation and memory tools for no-task heartbeat/reviewer readonly sessions without reopening host extension tool injection in summarizer flows.

View File

@@ -594,6 +594,50 @@ describe("createFnAgent", () => {
}); });
}); });
it("keeps caller customTools in readonly sessions", async () => {
createReadOnlyToolsMock.mockReturnValueOnce([{ name: "read" }] as any);
const delegationTool = {
name: "fn_list_agents",
label: "List Agents",
description: "List available agents",
parameters: {},
execute: vi.fn(),
};
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
customTools: [delegationTool as any],
});
const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }> };
expect(createSessionArgs.customTools.map((tool) => tool.name)).toContain("fn_list_agents");
});
it("keeps caller customTools in coding sessions", async () => {
createCodingToolsMock.mockReturnValueOnce([{ name: "read" }, { name: "write" }] as any);
const customTool = {
name: "fn_heartbeat_done",
label: "Heartbeat Done",
description: "Complete heartbeat",
parameters: {},
execute: vi.fn(),
};
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "coding",
customTools: [customTool as any],
});
const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }> };
expect(createSessionArgs.customTools.map((tool) => tool.name)).toContain("fn_heartbeat_done");
});
it("logs createFnAgent startup diagnostics without leaking cwd", async () => { it("logs createFnAgent startup diagnostics without leaking cwd", async () => {
const { piLog } = await import("../logger.js"); const { piLog } = await import("../logger.js");
const logSpy = vi.spyOn(piLog, "log").mockImplementation(() => {}); const logSpy = vi.spyOn(piLog, "log").mockImplementation(() => {});

View File

@@ -1146,12 +1146,13 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
}); });
} }
// `tools: "readonly"` MUST mean a hermetically sealed read-only session — no // `tools: "readonly"` MUST mean a hermetically sealed read-only session with
// way for the model to mutate state. Host extensions (`@runfusion/fusion`) // respect to host extension injection. Host extensions (`@runfusion/fusion`)
// register write tools like `fn_task_create`, so they are deliberately // can register write tools like `fn_task_create`, so they are deliberately
// EXCLUDED in readonly mode. Caller-supplied `customTools` are also dropped // EXCLUDED in readonly mode. Caller-supplied `customTools` are preserved,
// for the same reason. Without this, summarizer/compaction sessions could // since heartbeat/reviewer flows explicitly provide engine-owned tools.
// call write tools and mutate the task board (see FN-3057/FN-3058 incident). // This keeps summarizer/compaction sessions safe while retaining intended
// delegation/memory tools for readonly engine sessions.
const isReadonly = options.tools === "readonly"; const isReadonly = options.tools === "readonly";
const effectiveExtensionPaths = isReadonly ? [] : hostExtensionPaths; const effectiveExtensionPaths = isReadonly ? [] : hostExtensionPaths;
if (isReadonly && hostExtensionPaths.length > 0) { if (isReadonly && hostExtensionPaths.length > 0) {
@@ -1178,15 +1179,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
// suppress the defaults with `noTools: "builtin"` and register our wrapped // suppress the defaults with `noTools: "builtin"` and register our wrapped
// tools through `customTools` instead. The wrapped tools preserve the same // tools through `customTools` instead. The wrapped tools preserve the same
// names (`read`, `bash`, ...) as the built-ins they replace. // names (`read`, `bash`, ...) as the built-ins they replace.
// Readonly sessions drop caller-supplied customTools — see comment above
// about hermetic isolation. Only the wrapped read-only built-ins survive.
const customToolList: ToolDefinition[] = [ const customToolList: ToolDefinition[] = [
...(wrappedTools as ToolDefinition[]), ...(wrappedTools as ToolDefinition[]),
...(isReadonly ? [] : (options.customTools ?? [])), ...(options.customTools ?? []),
]; ];
if (isReadonly && (options.customTools?.length ?? 0) > 0) {
piLog.log(`readonly session — customTools (${options.customTools!.length}) skipped`);
}
// Last-chance abort hook. Fires *here* — after every awaited setup step // Last-chance abort hook. Fires *here* — after every awaited setup step
// in createFnAgent (provider registration, worktree validation, resource // in createFnAgent (provider registration, worktree validation, resource
// loader reload) and immediately before the actual LLM session spawn. // loader reload) and immediately before the actual LLM session spawn.