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:
5
.changeset/fn-6640-planning-document-tools.md
Normal file
5
.changeset/fn-6640-planning-document-tools.md
Normal 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.
|
||||||
@@ -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.
|
- 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`.
|
- 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
|
### Flags
|
||||||
|
|
||||||
|
|||||||
@@ -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; 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_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 (explicit `task_id`) | Read one task document or list all | `key?` (string); chat also requires `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_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 |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "./ai-session-diagnostics.js";
|
} from "./ai-session-diagnostics.js";
|
||||||
import {
|
import {
|
||||||
buildSessionSkillContextSync,
|
buildSessionSkillContextSync,
|
||||||
|
createChatTaskDocumentTools,
|
||||||
createFnAgent as engineCreateFnAgent,
|
createFnAgent as engineCreateFnAgent,
|
||||||
createWorkflowAuthoringTools,
|
createWorkflowAuthoringTools,
|
||||||
} from "@fusion/engine";
|
} from "@fusion/engine";
|
||||||
@@ -863,6 +864,11 @@ export async function createSession(
|
|||||||
customTools: [
|
customTools: [
|
||||||
...createPlanningBoardTools(store),
|
...createPlanningBoardTools(store),
|
||||||
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
|
...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: () => {
|
onThinking: () => {
|
||||||
// Non-streaming path ignores thinking output
|
// Non-streaming path ignores thinking output
|
||||||
@@ -1455,6 +1461,7 @@ async function createPlanningAgent(
|
|||||||
customTools: [
|
customTools: [
|
||||||
...createPlanningBoardTools(store),
|
...createPlanningBoardTools(store),
|
||||||
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
|
...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }),
|
||||||
|
...createChatTaskDocumentTools(store),
|
||||||
],
|
],
|
||||||
...(modelProvider && modelId
|
...(modelProvider && modelId
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user