FN-6640: expose planning task document tools

Expose planning-lane task document tools with chat-style explicit task targeting.

- Add task document read/write tools to both planning agent creation paths.
- Cover non-streaming and streaming planning tool assembly plus explicit task_id document behavior.
- Update agent docs and generated skill tool references for chat/planning parity.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6640-planning-document-tools.md      |   5 +
 docs/agents.md                                     |   2 +-
 .../cli/skill/fusion/references/engine-tools.md    |   4 +-
 .../planning-document-tools-exposure.test.ts       | 139 +++++++++++++++++++++
 packages/dashboard/src/planning.ts                 |   7 ++
 5 files changed, 154 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6640

Fusion-Task-Lineage: 6a9225cf-fbe7-4a87-8927-e01355a38e92
This commit is contained in:
gsxdsm
2026-06-18 07:23:02 -07:00
parent b1a2aeebf2
commit 0ed46d9007
5 changed files with 154 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Expose task document read/write tools to planning agents with explicit task IDs, matching chat session behavior.

View File

@@ -26,7 +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.
- 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`.
- 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`.
### 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_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (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_task_document_write` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string); chat/planning also require `task_id` (string) |
| `fn_task_document_read` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Read one task document or list all | `key?` (string); chat/planning also require `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 |

View File

@@ -0,0 +1,139 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import { __resetPlanningState, __setCreateFnAgent, createSession, createSessionWithAgent, planningStreamManager } from "../planning.js";
function createQuestionJson(): string {
return JSON.stringify({
type: "question",
data: { id: "q-1", type: "text", question: "What should this plan cover?" },
});
}
function createMockAgent(response = createQuestionJson()) {
const messages: Array<{ role: string; content: string }> = [];
return {
session: {
state: { messages },
prompt: vi.fn(async () => {
messages.push({ role: "assistant", content: response });
}),
dispose: vi.fn(),
},
};
}
async function waitFor(condition: () => boolean): Promise<void> {
for (let i = 0; i < 50; i += 1) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error("Timed out waiting for condition");
}
describe("planning task-document tools", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "planning-doc-tools-root-"));
globalDir = mkdtempSync(join(tmpdir(), "planning-doc-tools-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
__resetPlanningState();
});
afterEach(() => {
__resetPlanningState();
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
it("exposes task-document tools from both planning customTools assembly sites", async () => {
const capturedNonStreaming: any[] = [];
__setCreateFnAgent(async (options: any) => {
capturedNonStreaming.push(options);
return createMockAgent();
});
await createSession("127.0.0.210", "Plan document tool coverage", store, rootDir);
const nonStreamingToolNames = capturedNonStreaming[0]?.customTools?.map((tool: any) => tool.name) ?? [];
expect(nonStreamingToolNames).toContain("fn_task_document_write");
expect(nonStreamingToolNames).toContain("fn_task_document_read");
const capturedStreaming: any[] = [];
__resetPlanningState();
__setCreateFnAgent(async (options: any) => {
capturedStreaming.push(options);
return createMockAgent();
});
const sessionId = await createSessionWithAgent(
"127.0.0.211",
"Plan streaming document tool coverage",
rootDir,
store,
);
const unsubscribe = planningStreamManager.subscribe(sessionId, () => undefined);
try {
planningStreamManager.consumeInitialTurn(sessionId)?.();
await waitFor(() => capturedStreaming.length > 0);
} finally {
unsubscribe();
}
const streamingToolNames = capturedStreaming[0]?.customTools?.map((tool: any) => tool.name) ?? [];
expect(streamingToolNames).toContain("fn_task_document_write");
expect(streamingToolNames).toContain("fn_task_document_read");
});
it("uses explicit task_id when planning document tools write and read task documents", async () => {
const task = await store.createTask({ description: "Document target" });
const upsertSpy = vi.spyOn(store, "upsertTaskDocument");
const getSpy = vi.spyOn(store, "getTaskDocument");
const listSpy = vi.spyOn(store, "getTaskDocuments");
let capturedOptions: any;
__setCreateFnAgent(async (options: any) => {
capturedOptions = options;
return createMockAgent();
});
await createSession("127.0.0.212", "Plan document behavior", store, rootDir);
const writeTool = capturedOptions.customTools.find((tool: any) => tool.name === "fn_task_document_write");
const readTool = capturedOptions.customTools.find((tool: any) => tool.name === "fn_task_document_read");
expect(writeTool).toBeDefined();
expect(readTool).toBeDefined();
const writeResult = await writeTool.execute("write-plan-doc", {
task_id: task.id,
key: "plan",
content: "Planning notes",
author: "planner",
});
expect(writeResult.content[0]?.text).toContain("Saved document \"plan\"");
expect(upsertSpy).toHaveBeenCalledWith(task.id, {
key: "plan",
content: "Planning notes",
author: "planner",
});
const readResult = await readTool.execute("read-plan-doc", { task_id: task.id, key: "plan" });
expect(readResult.content[0]?.text).toContain("Document: plan");
expect(readResult.content[0]?.text).toContain("Planning notes");
expect(getSpy).toHaveBeenCalledWith(task.id, "plan");
const listResult = await readTool.execute("list-plan-docs", { task_id: task.id });
expect(listResult.content[0]?.text).toContain("Task documents:");
expect(listResult.content[0]?.text).toContain("- plan");
expect(listSpy).toHaveBeenCalledWith(task.id);
});
});

View File

@@ -34,6 +34,7 @@ import {
} from "./ai-session-diagnostics.js";
import {
buildSessionSkillContextSync,
createChatTaskDocumentTools,
createFnAgent as engineCreateFnAgent,
createWorkflowAuthoringTools,
} from "@fusion/engine";
@@ -863,6 +864,11 @@ export async function createSession(
customTools: [
...createPlanningBoardTools(store),
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
/*
FNXC:PlanningTools 2026-06-18-07:11:
FN-6640 gives planning agents parity with chat for `fn_task_document_write` and `fn_task_document_read` after FN-6635. The planning lane has no ambient task (`PLANNING_NO_AMBIENT_TASK_ID`), so these document tools must require an explicit `task_id`, mirroring no-ambient workflow authoring tools.
*/
...createChatTaskDocumentTools(store),
],
onThinking: () => {
// Non-streaming path ignores thinking output
@@ -1455,6 +1461,7 @@ async function createPlanningAgent(
customTools: [
...createPlanningBoardTools(store),
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
...createChatTaskDocumentTools(store),
],
...(modelProvider && modelId
? {