FN-7987: expose shared fusion toolset to chat agents and Grok CLI

Give dashboard chat and room responders the same safe coordination/productivity tools as other agent lanes, including via the Grok MCP bridge.

- Export chat coordination tool factories from @fusion/engine for public use
- Assemble createChatFusionToolset with board, delegation, web, goal, memory, and research tools
- Wire the shared toolset into model-loop chat and room-responder sessions
- Exclude destructive agent-lifecycle tools and fn_memory_append from chat
- Cover chat fusion parity and Grok bridge tool preservation with tests
- Document chat Grok tool parity and add a minor changeset

Files changed:
 .changeset/fn-7987-chat-fusion-toolset.md          |  7 ++
 docs/agents.md                                     |  1 +
 docs/grok-cli-contract.md                          |  2 +-
 packages/dashboard/src/__tests__/chat-manager.test.ts | 52 +++++++++++-
 packages/dashboard/src/chat.ts                     | 95 +++++++++++++++++++++-
 packages/engine/src/__tests__/agent-session-helpers.test.ts | 15 ++++
 packages/engine/src/index.ts                       | 26 ++++++
 plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts | 36 ++++++++
 8 files changed, 230 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7987

Fusion-Task-Lineage: 4d8d3dbc-10b8-4b56-9b63-79fe85a13755

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 15:25:39 -07:00
parent d9843f75bc
commit 667f4c8a55
8 changed files with 230 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Chat agents and Grok CLI sessions now have board, delegation, web, and knowledge retrieval tools.
category: feature
dev: Dashboard chat and room responders share a safe coordination toolset; destructive agent lifecycle tools remain excluded.

View File

@@ -38,6 +38,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them.
- In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command.
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`.
- Dashboard chat and room responders share a safe coordination/productivity toolset across pi and Grok CLI runtimes: board reads, task creation, delegation, agent listing/configuration, web fetch, and goal/memory/research retrieval. Destructive agent-lifecycle tools and memory append remain excluded because chat has no action-gate context.
- Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. Lanes without an ambient task, including dashboard chat/planning and published/pi extension calls outside a task, must pass an explicit `task_id`; task-bound executor paths may default to the current task.
- Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency.
- Permanent/custom heartbeat agents and the published/pi extension receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery and authoring (`fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`), governed research (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Task-scoped heartbeat sessions also expose current-task workflow selection and promotion (`fn_workflow_select`, `fn_task_promote`); no-task heartbeats omit those because they have no ambient task, while no-task extension/chat/planning lanes expose `fn_workflow_select` but require explicit `task_id`. Workflow creation, updates, settings writes, deletion, and selection remain permission-gated task/agent mutations even when the tools are exposed in the lane. Prompt-injectable lanes strip workflow approval-bypass flags during `fn_workflow_create`/`fn_workflow_update`; executor-owner paths are the only authoring path that may preserve those flags. Executor-only worktree/workspace tools such as `fn_run_verification` and `fn_acquire_repo_worktree` remain out of the ambient heartbeat lane until that lane owns the required worktree/workspace context. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding governed tools from the session.

View File

@@ -68,7 +68,7 @@ mcpServers through createResolvedAgentSession. Grok ACP must not drop them.
-->
1. **Operator MCP** — `options.mcpServers` is reshaped to ACP wire format and forwarded on `session/new`.
2. **Fusion custom tools (`fn_*`)** — engine `customTools` are hosted by a loopback HTTP bridge + stdio MCP server (`mcp-schema-server.cjs`) named `fusion-custom-tools`. Grok invokes tools via real MCP `tools/call`; the bridge runs `ToolDefinition.execute` in-process.
2. **Fusion custom tools (`fn_*`)** — engine `customTools` are hosted by a loopback HTTP bridge + stdio MCP server (`mcp-schema-server.cjs`) named `fusion-custom-tools`. Grok invokes tools via real MCP `tools/call`; the bridge runs `ToolDefinition.execute` in-process. Dashboard chat and room responders include the same safe coordination/productivity tools as their pi-shaped sessions: board discovery, task creation and delegation, agent discovery/configuration, web fetch, and goal/memory/research retrieval; destructive agent-lifecycle tools remain excluded from chat.
3. **Skills** — the bundled Fusion skill (`packages/cli/skill/fusion`) plus any `additionalSkillPaths` skill roots are staged into a temp plugin directory and loaded via `grok agent --plugin-dir` and `_meta.pluginDirs`. Requested skill names and tool counts are also written into `_meta.rules` / system prompt context.
### Session lifecycle

View File

@@ -847,6 +847,7 @@ describe("ChatManager.sendMessage", () => {
});
const taskStore = {
getSettings: vi.fn().mockResolvedValue({}),
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-6635",
@@ -873,6 +874,30 @@ describe("ChatManager.sendMessage", () => {
const names = capturedTools.map((tool) => tool.name);
expect(names).toContain("fn_task_document_write");
expect(names).toContain("fn_task_document_read");
for (const required of [
"fn_task_list",
"fn_task_show",
"fn_task_search",
"fn_task_create",
"fn_delegate_task",
"fn_list_agents",
"fn_get_agent_config",
"fn_web_fetch",
"fn_goal_list",
"fn_goal_show",
"fn_memory_search",
"fn_memory_get",
"fn_research_run",
"fn_research_list",
"fn_research_get",
]) {
expect(names).toContain(required);
}
expect(names).not.toContain("fn_agent_create");
expect(names).not.toContain("fn_agent_delete");
expect(names).not.toContain("fn_agent_update");
expect(names).not.toContain("fn_memory_append");
expect(new Set(names).size).toBe(names.length);
const writeTool = capturedTools.find((tool) => tool.name === "fn_task_document_write");
const writeResult = await writeTool?.execute?.("call-doc-write", {
@@ -3609,7 +3634,32 @@ describe("ChatManager generation isolation", () => {
const chatManager = new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, undefined, undefined, undefined, taskStore as any);
await chatManager.sendRoomMessage("room-1", "How many tokens did FN-7310 use?");
expect(capturedTools.map((tool) => tool.name)).not.toContain("fn_task_planner_get_task_metrics");
const names = capturedTools.map((tool) => tool.name);
expect(names).not.toContain("fn_task_planner_get_task_metrics");
for (const required of [
"fn_task_list",
"fn_task_show",
"fn_task_search",
"fn_task_create",
"fn_delegate_task",
"fn_list_agents",
"fn_get_agent_config",
"fn_web_fetch",
"fn_goal_list",
"fn_goal_show",
"fn_memory_search",
"fn_memory_get",
"fn_research_run",
"fn_research_list",
"fn_research_get",
]) {
expect(names).toContain(required);
}
expect(names).not.toContain("fn_agent_create");
expect(names).not.toContain("fn_agent_delete");
expect(names).not.toContain("fn_agent_update");
expect(names).not.toContain("fn_memory_append");
expect(new Set(names).size).toBe(names.length);
});
it("sendRoomMessage persists assistant room replies", async () => {

View File

@@ -57,6 +57,17 @@ import {
createChatArtifactTools,
createChatTaskDocumentTools,
createWorkflowAuthoringTools,
createTaskCreateTool,
createTaskListTool,
createTaskShowTool,
createTaskSearchTool,
createListAgentsTool,
createDelegateTaskTool,
createGetAgentConfigTool,
createWebFetchTool,
createGoalRetrievalTools,
createMemoryTools,
createResearchTools,
resolveMcpServersForStore,
resolveExecutorThinkingLevel,
} from "@fusion/engine";
@@ -329,6 +340,62 @@ function createChatWorkflowAuthoringTools(taskStore: TaskStore | undefined, proj
.map((tool) => wrapWorkflowMutationTool(tool, projectId));
}
export interface ChatFusionToolsetOptions {
taskStore?: TaskStore;
agentStore?: AgentStore;
rootDir: string;
agentId?: string;
}
/*
FNXC:ChatAgentTools 2026-07-15-00:00:
Chat agents, including Grok CLI sessions reached through the plugin MCP bridge,
must receive one safe coordination/productivity toolset in both model-loop and
room-responder lanes. Workflow, document, artifact, messaging, and task-planner
tools stay additive at their call sites; agent lifecycle mutation and memory
append remain excluded because chat has no action-gate context.
*/
export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise<ChatCustomTool[]> {
const { taskStore, agentStore, rootDir, agentId } = options;
const tools: ChatCustomTool[] = [];
if (taskStore) {
const settings = await taskStore.getSettings?.();
tools.push(
createTaskListTool(taskStore),
createTaskShowTool(taskStore),
createTaskSearchTool(taskStore),
createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }),
...createGoalRetrievalTools(taskStore),
/* FNXC:ChatAgentTools 2026-07-15-00:00: Chat exposes memory retrieval only and respects the workspace memory-enabled setting; prompt-triggered persistent writes stay excluded without an action-gate context. */
...createMemoryTools(rootDir, settings).filter((tool) => tool.name !== "fn_memory_append"),
...createResearchTools({ store: taskStore, rootDir, getSettings: () => taskStore.getSettings() }),
);
}
if (agentStore) {
tools.push(createListAgentsTool(agentStore));
if (taskStore) {
tools.push(createDelegateTaskTool(agentStore, taskStore, { rootDir }));
}
if (agentId) {
tools.push(createGetAgentConfigTool(agentStore, agentId));
}
}
tools.push(createWebFetchTool());
return dedupeChatTools(tools);
}
export function dedupeChatTools(tools: ChatCustomTool[]): ChatCustomTool[] {
const names = new Set<string>();
return tools.filter((tool) => {
if (names.has(tool.name)) return false;
names.add(tool.name);
return true;
});
}
function createTaskPlannerMetricsTool(taskStore: TaskStore, taskId: string, getPricingOverrides: () => Promise<Settings["modelPricingOverrides"] | undefined>) {
return {
name: "fn_task_planner_get_task_metrics",
@@ -1790,6 +1857,12 @@ export class ChatManager {
);
const workflowTools = createChatWorkflowAuthoringTools(this.taskStore, input.roomProjectId);
const chatFusionTools = await createChatFusionToolset({
taskStore: this.taskStore,
agentStore: this.agentStore,
rootDir: this.rootDir,
agentId: input.responder.id,
});
const resolvedSession = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
@@ -1806,7 +1879,9 @@ export class ChatManager {
cwd: this.rootDir,
systemPrompt,
tools: "coding",
...(workflowTools.length > 0 ? { customTools: workflowTools } : {}),
...(workflowTools.length + chatFusionTools.length > 0
? { customTools: dedupeChatTools([...workflowTools, ...chatFusionTools]) }
: {}),
...(effectiveModelProvider && effectiveModelId
? {
defaultProvider: effectiveModelProvider,
@@ -2334,7 +2409,23 @@ export class ChatManager {
? [createTaskPlannerRefinementTool(this.taskStore, taskPlannerChatTaskId)]
: [];
const customTools = [createAskQuestionTool(), ...taskPlannerSteeringTools, ...taskPlannerMetricsTools, ...taskPlannerRefinementTools, ...messagingTools, ...workflowTools, ...documentTools, ...artifactTools];
const chatFusionTools = await createChatFusionToolset({
taskStore: this.taskStore,
agentStore: this.agentStore,
rootDir: this.rootDir,
agentId: agent?.id,
});
const customTools = dedupeChatTools([
createAskQuestionTool(),
...taskPlannerSteeringTools,
...taskPlannerMetricsTools,
...taskPlannerRefinementTools,
...messagingTools,
...workflowTools,
...documentTools,
...artifactTools,
...chatFusionTools,
]);
const sessionOptions = {
cwd: this.rootDir,

View File

@@ -18,6 +18,7 @@ import {
resolveValidatorSessionModel,
resolveValidatorThinkingLevel,
resolveValidatorFallbackThinkingLevel,
wrapCustomToolsForPluginRuntime,
} from "../agent-session-helpers.js";
const { resolveRuntimeMock } = vi.hoisted(() => ({
@@ -33,6 +34,20 @@ vi.mock("../runtime-resolution.js", async () => {
});
describe("non-pi custom tool wrapping", () => {
it("preserves chat fusion tool names without an action-gate context", () => {
const tools = [
{ name: "fn_task_list", description: "List tasks", parameters: {}, execute: async () => ({}) },
{ name: "fn_delegate_task", description: "Delegate a task", parameters: {}, execute: async () => ({}) },
{ name: "fn_list_agents", description: "List agents", parameters: {}, execute: async () => ({}) },
{ name: "fn_web_fetch", description: "Fetch a URL", parameters: {}, execute: async () => ({}) },
] as any;
expect(wrapCustomToolsForPluginRuntime(tools, {}, { runtimeId: "grok", sessionPurpose: "heartbeat" })?.map((tool) => tool.name))
.toEqual(tools.map((tool: { name: string }) => tool.name));
});
});
describe("resolve model-lane thinking levels", () => {
it("applies node/task > workflow execution lane > global lane > project default lane > global default precedence", () => {
const settings = {

View File

@@ -25,6 +25,13 @@ export {
createTaskShowTool,
createTaskSearchTool,
createTaskReadTools,
createListAgentsTool,
createDelegateTaskTool,
createGetAgentConfigTool,
createWebFetchTool,
createGoalRetrievalTools,
createMemoryTools,
createResearchTools,
createArtifactListTool,
createArtifactRegisterTool,
createArtifactViewTool,
@@ -69,6 +76,25 @@ export {
workflowDeleteParams,
workflowSettingsParams,
traitListParams,
listAgentsParams,
delegateTaskParams,
getAgentConfigParams,
webFetchParams,
memorySearchParams,
memoryGetParams,
goalListParams,
goalShowParams,
researchRunParams,
researchListParams,
researchGetParams,
researchCancelParams,
researchRetryParams,
/*
FNXC:ChatAgentTools 2026-07-15-00:00:
Dashboard chat needs these coordination factories re-exported so its shared
toolset can match the safe heartbeat productivity surface for pi and Grok
plugin runtimes without importing the engine's internal module path.
*/
executeApprovedAgentProvisioning,
createWorkflowValidateTool,
validateWorkflowIrDryRun,

View File

@@ -17,6 +17,42 @@ describe("tool-bridge", () => {
]);
});
it("exposes every expanded chat fusion tool to the Grok MCP schema", () => {
/*
FNXC:ChatAgentTools 2026-07-15-00:00:
The Grok loopback bridge must publish every safe coordination/productivity
tool assembled by dashboard chat. Each fixture includes execute because the
MCP bridge can only invoke in-process Fusion tool closures.
*/
const chatToolNames = [
"fn_task_list",
"fn_task_show",
"fn_task_search",
"fn_task_create",
"fn_delegate_task",
"fn_list_agents",
"fn_get_agent_config",
"fn_web_fetch",
"fn_goal_list",
"fn_goal_show",
"fn_memory_search",
"fn_memory_get",
"fn_research_run",
"fn_research_list",
"fn_research_get",
"fn_research_cancel",
"fn_research_retry",
];
const tools = chatToolNames.map((name) => ({
name,
description: name,
parameters: { type: "object", properties: {} },
execute: async () => ({ content: [] }),
}));
expect(toolsToMcpToolDefs(tools).map((tool) => tool.name)).toEqual(chatToolNames);
});
it("starts a bridge that executes Fusion custom tools over HTTP", async () => {
const bridge = await startFusionToolBridge([
{