diff --git a/.changeset/fn-6635-chat-task-documents.md b/.changeset/fn-6635-chat-task-documents.md new file mode 100644 index 0000000000..6830361748 --- /dev/null +++ b/.changeset/fn-6635-chat-task-documents.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Expose task document read/write tools to dashboard chat agents with explicit `task_id` targeting. diff --git a/docs/agents.md b/docs/agents.md index 765805cc58..58c9c4e871 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -26,6 +26,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. - 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. 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 sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because chat has no ambient task, both tools require an explicit `task_id`. ### Flags diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 1b89a91c97..201101954d 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -13,8 +13,8 @@ These tools are **not** part of the user-invokable extension surface. They are i |---|---|---|---| | `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`), `workflow_id?` (string) | | `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) | -| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) | -| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) | +| `fn_task_document_write` | triage, executor, heartbeat; chat (explicit `task_id`) | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string); chat also requires `task_id` (string) | +| `fn_task_document_read` | triage, executor, heartbeat; chat (explicit `task_id`) | Read one task document or list all | `key?` (string); chat also requires `task_id` (string) | | `fn_goal_list` | triage, executor, heartbeat | List goals with concise citation-ready snippets and active-goal warning details | `status?` (`active` \| `archived` \| `all`) | | `fn_goal_show` | triage, executor, heartbeat | Show one goal's full detail on demand, including the full description body | `id` (string) | | `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none | diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index e4d170e4b4..f845ada078 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -420,6 +420,84 @@ describe("ChatManager.sendMessage", () => { } }); + it("exposes fn_task_document_* tools to the chat agent when a task store is present", async () => { + let capturedTools: Array<{ name: string; execute?: (...args: any[]) => Promise }> = []; + __setCreateFnAgent(async (options: any) => { + capturedTools = options.customTools ?? []; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "ok" }] }, + }, + }; + }); + + const taskStore = { + upsertTaskDocument: vi.fn().mockResolvedValue({ + id: "doc-1", + taskId: "FN-6635", + key: "docs", + content: "Saved from chat", + revision: 1, + author: "chat-agent", + createdAt: "2026-06-18T06:51:00.000Z", + updatedAt: "2026-06-18T06:51:00.000Z", + }), + } as any; + const chatManager = new ChatManager( + mockChatStore as any, + "/tmp/test", + mockAgentStore as any, + undefined, + undefined, + undefined, + taskStore, + ); + + await chatManager.sendMessage("chat-001", "Save this as task docs"); + + const names = capturedTools.map((tool) => tool.name); + expect(names).toContain("fn_task_document_write"); + expect(names).toContain("fn_task_document_read"); + + const writeTool = capturedTools.find((tool) => tool.name === "fn_task_document_write"); + const writeResult = await writeTool?.execute?.("call-doc-write", { + task_id: "FN-6635", + key: "docs", + content: "Saved from chat", + author: "chat-agent", + }); + + expect(taskStore.upsertTaskDocument).toHaveBeenCalledWith("FN-6635", { + key: "docs", + content: "Saved from chat", + author: "chat-agent", + }); + expect(writeResult?.content?.[0]?.text).toContain("Saved document \"docs\""); + }); + + it("does not expose fn_task_document_* tools when no task store is present", async () => { + let capturedTools: Array<{ name: string }> = []; + __setCreateFnAgent(async (options: any) => { + capturedTools = options.customTools ?? []; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "ok" }] }, + }, + }; + }); + + const chatManager = createChatManager(); + await chatManager.sendMessage("chat-001", "Try saving docs"); + + const names = capturedTools.map((tool) => tool.name); + expect(names).not.toContain("fn_task_document_write"); + expect(names).not.toContain("fn_task_document_read"); + }); + it("persists and clears durable in-flight generation snapshots during streaming", async () => { let onTextCb: ((delta: string) => void) | undefined; diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 5b4338e601..69a8425f99 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -45,6 +45,7 @@ import { createSendMessageTool, createReadMessagesTool, createAskQuestionTool, + createChatTaskDocumentTools, createWorkflowAuthoringTools, } from "@fusion/engine"; import * as engineModule from "@fusion/engine"; @@ -827,8 +828,8 @@ export class ChatManager { > | undefined, private messageStore?: MessageStore, // Scoped task store for the chat's project — enables workflow-authoring - // tools (fn_workflow_*). Optional so existing test/construction sites that - // don't author workflows keep working. + // tools (fn_workflow_*) and explicit-task document tools. Optional so + // existing test/construction sites that don't author workflows keep working. private taskStore?: TaskStore, ) {} @@ -1812,7 +1813,15 @@ export class ChatManager { ? createWorkflowAuthoringTools(this.taskStore, "", { stripApprovalFlags: true }) : []; - const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools]; + /* + FNXC:ChatAgentTools 2026-06-18-06:51: + The dashboard chat lane has no ambient task, so task-document tools must require explicit `task_id` while keeping the canonical `fn_task_document_write` and `fn_task_document_read` names available to chat agents. + */ + const documentTools = this.taskStore + ? createChatTaskDocumentTools(this.taskStore) + : []; + + const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools, ...documentTools]; const sessionOptions = { cwd: this.rootDir, diff --git a/packages/engine/src/__tests__/agent-document-tools.test.ts b/packages/engine/src/__tests__/agent-document-tools.test.ts index c7ea1f63e6..54e5b33c00 100644 --- a/packages/engine/src/__tests__/agent-document-tools.test.ts +++ b/packages/engine/src/__tests__/agent-document-tools.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TaskDocument, TaskStore } from "@fusion/core"; import { + createChatTaskDocumentTools, createTaskDocumentReadTool, createTaskDocumentWriteTool, } from "../agent-tools.js"; @@ -223,6 +224,113 @@ describe("task_document_read tool", () => { }); }); +describe("chat task document tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function findChatTool(name: "fn_task_document_write" | "fn_task_document_read", store: TaskStore) { + const tool = createChatTaskDocumentTools(store).find((candidate) => candidate.name === name); + expect(tool).toBeDefined(); + return tool!; + } + + it("exposes canonical document tool names for chat agents", () => { + const { store } = createMockStore(); + + expect(createChatTaskDocumentTools(store).map((tool) => tool.name)).toEqual([ + "fn_task_document_write", + "fn_task_document_read", + ]); + }); + + it("writes a document to the explicit task_id", async () => { + const { store, upsertTaskDocument } = createMockStore(); + upsertTaskDocument.mockResolvedValue(createMockDocument({ taskId: "FN-2020", key: "plan", revision: 5 })); + + const tool = findChatTool("fn_task_document_write", store); + const result = await runTool(tool, "call-chat-write", { + task_id: "FN-2020", + key: "plan", + content: "Chat-authored plan", + author: "chat-agent", + }); + + expect(upsertTaskDocument).toHaveBeenCalledWith("FN-2020", { + key: "plan", + content: "Chat-authored plan", + author: "chat-agent", + }); + expect(getText(result)).toContain("Saved document \"plan\""); + expect(getText(result)).toContain("revision 5"); + }); + + it("reads a document from the explicit task_id", async () => { + const { store, getTaskDocument } = createMockStore(); + getTaskDocument.mockResolvedValue(createMockDocument({ taskId: "FN-2021", key: "notes", content: "Chat notes" })); + + const tool = findChatTool("fn_task_document_read", store); + const result = await runTool(tool, "call-chat-read", { task_id: "FN-2021", key: "notes" }); + + expect(getTaskDocument).toHaveBeenCalledWith("FN-2021", "notes"); + expect(getText(result)).toContain("Document: notes"); + expect(getText(result)).toContain("Chat notes"); + }); + + it("returns not found for a missing explicit-task document key", async () => { + const { store, getTaskDocument } = createMockStore(); + getTaskDocument.mockResolvedValue(null); + + const tool = findChatTool("fn_task_document_read", store); + const result = await runTool(tool, "call-chat-missing", { task_id: "FN-2022", key: "missing" }); + + expect(getTaskDocument).toHaveBeenCalledWith("FN-2022", "missing"); + expect(getText(result)).toContain("Document \"missing\" not found."); + }); + + it("lists documents for the explicit task_id when key is omitted", async () => { + const { store, getTaskDocuments } = createMockStore(); + getTaskDocuments.mockResolvedValue([ + createMockDocument({ taskId: "FN-2023", key: "plan", revision: 1 }), + createMockDocument({ taskId: "FN-2023", key: "docs", revision: 2 }), + ]); + + const tool = findChatTool("fn_task_document_read", store); + const result = await runTool(tool, "call-chat-list", { task_id: "FN-2023" }); + + expect(getTaskDocuments).toHaveBeenCalledWith("FN-2023"); + expect(getText(result)).toContain("Task documents:"); + expect(getText(result)).toContain("- plan (revision 1"); + expect(getText(result)).toContain("- docs (revision 2"); + }); + + it("returns clean errors for non-existent explicit task writes", async () => { + const { store, upsertTaskDocument } = createMockStore(); + upsertTaskDocument.mockRejectedValue(new Error("Task FN-404 not found")); + + const tool = findChatTool("fn_task_document_write", store); + const result = await runTool(tool, "call-chat-write-error", { + task_id: "FN-404", + key: "plan", + content: "No target", + }); + + expect(getText(result)).toContain("ERROR: Failed to save document \"plan\" for task FN-404"); + expect(getText(result)).toContain("Task FN-404 not found"); + }); + + it("returns clean errors for non-existent explicit task reads", async () => { + const { store, getTaskDocuments } = createMockStore(); + getTaskDocuments.mockRejectedValue(new Error("Task FN-405 not found")); + + const tool = findChatTool("fn_task_document_read", store); + const result = await runTool(tool, "call-chat-read-error", { task_id: "FN-405" }); + + expect(getText(result)).toContain("ERROR: Failed to read task documents for task FN-405"); + expect(getText(result)).toContain("Task FN-405 not found"); + }); +}); + describe("document tool factory integration", () => { it("uses the provided store instance across write and read tools", async () => { const { store, upsertTaskDocument, getTaskDocument, getTaskDocuments } = createMockStore(); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 6a6b2cfcb3..8bde4bf2b4 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -71,6 +71,22 @@ export const taskDocumentReadParams = Type.Object({ ), }); +export const chatTaskDocumentWriteParams = Type.Object({ + task_id: Type.String({ description: "Task ID to write the document to (e.g. 'FN-001')." }), + key: Type.String({ + description: "Document key (e.g., 'plan', 'notes', 'research'). Alphanumeric, hyphens, underscores, 1-64 chars.", + }), + content: Type.String({ description: "Document content to store" }), + author: Type.Optional(Type.String({ description: "Who is writing (default: 'agent')" })), +}); + +export const chatTaskDocumentReadParams = Type.Object({ + task_id: Type.String({ description: "Task ID to read documents from (e.g. 'FN-001')." }), + key: Type.Optional( + Type.String({ description: "Document key to read. Omit to list all documents for this task." }), + ), +}); + export const workflowListParams = Type.Object({}); export const workflowGetParams = Type.Object({ @@ -1046,58 +1062,115 @@ export function createTaskDocumentReadTool(store: TaskStore, taskId: string): To description: "Read a named document for this task, or list all documents when no key is provided.", parameters: taskDocumentReadParams, - execute: async (_id: string, params: Static) => { - try { - if (params.key) { - const document: TaskDocument | null = await store.getTaskDocument(taskId, params.key); - if (!document) { - return { - content: [{ type: "text" as const, text: `Document "${params.key}" not found.` }], - details: {}, - }; - } + execute: async (_id: string, params: Static) => readTaskDocuments(store, taskId, params.key), + }; +} +/** + * FNXC:ChatAgentTools 2026-06-18-06:51: + * Chat sessions do not have an ambient task, but users expect the same `fn_task_document_write` and `fn_task_document_read` names that task-bound lanes expose. + * Require an explicit `task_id` here, mirroring no-ambient workflow authoring tools, so FN-6635 chat agents can persist task documents without guessing a target task. + */ +export function createChatTaskDocumentTools(store: TaskStore): ToolDefinition[] { + return [ + { + name: "fn_task_document_write", + label: "Write Document", + description: + "Save a named document for a task (for example plan, notes, or research). " + + "Each write creates a new revision so you can update documents over time. Requires task_id.", + parameters: chatTaskDocumentWriteParams, + execute: async (_id: string, params: Static) => { + const input: TaskDocumentCreateInput = { + key: params.key, + content: params.content, + author: params.author || "agent", + }; + + try { + const document: TaskDocument = await store.upsertTaskDocument(params.task_id, input); return { content: [{ type: "text" as const, - text: - `Document: ${document.key}\n` + - `Revision: ${document.revision}\n` + - `Updated: ${document.updatedAt}\n\n` + - document.content, + text: `Saved document "${document.key}" (revision ${document.revision}).`, + }], + details: {}, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ + type: "text" as const, + text: `ERROR: Failed to save document "${params.key}" for task ${params.task_id}: ${err.message}`, }], details: {}, }; } + }, + }, + { + name: "fn_task_document_read", + label: "Read Document", + description: + "Read a named document for a task, or list all documents when no key is provided. Requires task_id.", + parameters: chatTaskDocumentReadParams, + execute: async (_id: string, params: Static) => ( + readTaskDocuments(store, params.task_id, params.key) + ), + }, + ]; +} - const documents: TaskDocument[] = await store.getTaskDocuments(taskId); - if (documents.length === 0) { - return { - content: [{ type: "text" as const, text: "No documents found for this task." }], - details: {}, - }; - } - - const lines = documents.map((doc) => `- ${doc.key} (revision ${doc.revision}, updated ${doc.updatedAt})`); +async function readTaskDocuments(store: TaskStore, taskId: string, key?: string) { + try { + if (key) { + const document: TaskDocument | null = await store.getTaskDocument(taskId, key); + if (!document) { return { - content: [{ - type: "text" as const, - text: `Task documents:\n${lines.join("\n")}`, - }], - details: {}, - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - return { - content: [{ - type: "text" as const, - text: `ERROR: Failed to read task documents: ${err.message}`, - }], + content: [{ type: "text" as const, text: `Document "${key}" not found.` }], details: {}, }; } - }, - }; + + return { + content: [{ + type: "text" as const, + text: + `Document: ${document.key}\n` + + `Revision: ${document.revision}\n` + + `Updated: ${document.updatedAt}\n\n` + + document.content, + }], + details: {}, + }; + } + + const documents: TaskDocument[] = await store.getTaskDocuments(taskId); + if (documents.length === 0) { + return { + content: [{ type: "text" as const, text: "No documents found for this task." }], + details: {}, + }; + } + + const lines = documents.map((doc) => `- ${doc.key} (revision ${doc.revision}, updated ${doc.updatedAt})`); + return { + content: [{ + type: "text" as const, + text: `Task documents:\n${lines.join("\n")}`, + }], + details: {}, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ + type: "text" as const, + text: `ERROR: Failed to read task documents for task ${taskId}: ${err.message}`, + }], + details: {}, + }; + } } /** diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7055ea0ff3..cb580413ab 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -3,6 +3,7 @@ export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent export { createFusionAuthStorage } from "./auth-storage.js"; export { createTaskCreateTool, + createChatTaskDocumentTools, createTaskDocumentReadTool, createTaskDocumentWriteTool, createTaskLogTool, @@ -18,6 +19,8 @@ export { createTraitListTool, createWorkflowAuthoringTools, taskCreateParams, + chatTaskDocumentReadParams, + chatTaskDocumentWriteParams, taskDocumentReadParams, taskDocumentWriteParams, taskLogParams,