diff --git a/.changeset/fn-7987-chat-fusion-toolset.md b/.changeset/fn-7987-chat-fusion-toolset.md new file mode 100644 index 0000000000..2dd348fb8b --- /dev/null +++ b/.changeset/fn-7987-chat-fusion-toolset.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 22f3873e69..3349b81c69 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -38,6 +38,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - 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. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index e5e082a5a1..ab9ee76695 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -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 diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 94a82ce788..feeba655a7 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -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 () => { diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 6c25b6da55..9f946f2f5f 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -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 { + 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(); + 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) { 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, diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 37591f52e9..b269728e96 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -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 = { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 64781fcd3b..ce4cecfdc6 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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, diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts index e37c2bf9e9..8c95d3323e 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts @@ -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([ {