FN-6635: expose task document tools to chat agents

Expose task document read/write tools to dashboard chat agents with explicit task targeting.

- Add chat-specific fn_task_document_write/read factories that require task_id and reuse task document read behavior.
- Wire dashboard chat custom tools to include task document tools when a scoped task store is available.
- Cover chat document tool exposure and explicit-task document operations with engine and dashboard tests.
- Document chat availability and add a published package changeset.

Files changed:
 .changeset/fn-6635-chat-task-documents.md          |   5 +
 docs/agents.md                                     |   1 +
 .../cli/skill/fusion/references/engine-tools.md    |   4 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   |  78 +++++++++++
 packages/dashboard/src/chat.ts                     |  15 ++-
 .../src/__tests__/agent-document-tools.test.ts     | 108 +++++++++++++++
 packages/engine/src/agent-tools.ts                 | 145 ++++++++++++++++-----
 packages/engine/src/index.ts                       |   3 +
 8 files changed, 318 insertions(+), 41 deletions(-)

Fusion-Task-Id: FN-6635

Fusion-Task-Lineage: 25e701d4-7670-470b-9dea-df49884f7b21
This commit is contained in:
gsxdsm
2026-06-18 07:06:00 -07:00
parent d6415bfc41
commit b1a2aeebf2
8 changed files with 321 additions and 44 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Expose task document read/write tools to dashboard chat agents with explicit `task_id` targeting.

View File

@@ -26,6 +26,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- 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. - 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. - 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. - 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 ### Flags

View File

@@ -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_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_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_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 | Read one task document or list all | `key?` (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_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_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 | | `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |

View File

@@ -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<any> }> = [];
__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 () => { it("persists and clears durable in-flight generation snapshots during streaming", async () => {
let onTextCb: ((delta: string) => void) | undefined; let onTextCb: ((delta: string) => void) | undefined;

View File

@@ -45,6 +45,7 @@ import {
createSendMessageTool, createSendMessageTool,
createReadMessagesTool, createReadMessagesTool,
createAskQuestionTool, createAskQuestionTool,
createChatTaskDocumentTools,
createWorkflowAuthoringTools, createWorkflowAuthoringTools,
} from "@fusion/engine"; } from "@fusion/engine";
import * as engineModule from "@fusion/engine"; import * as engineModule from "@fusion/engine";
@@ -827,8 +828,8 @@ export class ChatManager {
> | undefined, > | undefined,
private messageStore?: MessageStore, private messageStore?: MessageStore,
// Scoped task store for the chat's project — enables workflow-authoring // Scoped task store for the chat's project — enables workflow-authoring
// tools (fn_workflow_*). Optional so existing test/construction sites that // tools (fn_workflow_*) and explicit-task document tools. Optional so
// don't author workflows keep working. // existing test/construction sites that don't author workflows keep working.
private taskStore?: TaskStore, private taskStore?: TaskStore,
) {} ) {}
@@ -1812,7 +1813,15 @@ export class ChatManager {
? createWorkflowAuthoringTools(this.taskStore, "", { stripApprovalFlags: true }) ? 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 = { const sessionOptions = {
cwd: this.rootDir, cwd: this.rootDir,

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskDocument, TaskStore } from "@fusion/core"; import type { TaskDocument, TaskStore } from "@fusion/core";
import { import {
createChatTaskDocumentTools,
createTaskDocumentReadTool, createTaskDocumentReadTool,
createTaskDocumentWriteTool, createTaskDocumentWriteTool,
} from "../agent-tools.js"; } 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", () => { describe("document tool factory integration", () => {
it("uses the provided store instance across write and read tools", async () => { it("uses the provided store instance across write and read tools", async () => {
const { store, upsertTaskDocument, getTaskDocument, getTaskDocuments } = createMockStore(); const { store, upsertTaskDocument, getTaskDocument, getTaskDocuments } = createMockStore();

View File

@@ -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 workflowListParams = Type.Object({});
export const workflowGetParams = Type.Object({ export const workflowGetParams = Type.Object({
@@ -1046,58 +1062,115 @@ export function createTaskDocumentReadTool(store: TaskStore, taskId: string): To
description: description:
"Read a named document for this task, or list all documents when no key is provided.", "Read a named document for this task, or list all documents when no key is provided.",
parameters: taskDocumentReadParams, parameters: taskDocumentReadParams,
execute: async (_id: string, params: Static<typeof taskDocumentReadParams>) => { execute: async (_id: string, params: Static<typeof taskDocumentReadParams>) => readTaskDocuments(store, taskId, params.key),
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: {},
};
}
/**
* 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<typeof chatTaskDocumentWriteParams>) => {
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 { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
text: text: `Saved document "${document.key}" (revision ${document.revision}).`,
`Document: ${document.key}\n` + }],
`Revision: ${document.revision}\n` + details: {},
`Updated: ${document.updatedAt}\n\n` + };
document.content, // 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: {}, 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<typeof chatTaskDocumentReadParams>) => (
readTaskDocuments(store, params.task_id, params.key)
),
},
];
}
const documents: TaskDocument[] = await store.getTaskDocuments(taskId); async function readTaskDocuments(store: TaskStore, taskId: string, key?: string) {
if (documents.length === 0) { try {
return { if (key) {
content: [{ type: "text" as const, text: "No documents found for this task." }], const document: TaskDocument | null = await store.getTaskDocument(taskId, key);
details: {}, if (!document) {
};
}
const lines = documents.map((doc) => `- ${doc.key} (revision ${doc.revision}, updated ${doc.updatedAt})`);
return { return {
content: [{ content: [{ type: "text" as const, text: `Document "${key}" not found.` }],
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}`,
}],
details: {}, 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: {},
};
}
} }
/** /**

View File

@@ -3,6 +3,7 @@ export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent
export { createFusionAuthStorage } from "./auth-storage.js"; export { createFusionAuthStorage } from "./auth-storage.js";
export { export {
createTaskCreateTool, createTaskCreateTool,
createChatTaskDocumentTools,
createTaskDocumentReadTool, createTaskDocumentReadTool,
createTaskDocumentWriteTool, createTaskDocumentWriteTool,
createTaskLogTool, createTaskLogTool,
@@ -18,6 +19,8 @@ export {
createTraitListTool, createTraitListTool,
createWorkflowAuthoringTools, createWorkflowAuthoringTools,
taskCreateParams, taskCreateParams,
chatTaskDocumentReadParams,
chatTaskDocumentWriteParams,
taskDocumentReadParams, taskDocumentReadParams,
taskDocumentWriteParams, taskDocumentWriteParams,
taskLogParams, taskLogParams,