feat(FN-1272): add task document tools for triage and executor

- Add task_document_write and task_document_read factories with schemas, revision-aware responses, and error handling in agent-tools
- Wire document tools into executor and triage sessions and update prompts to persist and reuse planning artifacts across runs
- Export document tool factories and parameter schemas from engine public entrypoints while keeping executor re-export compatibility
- Add comprehensive tests covering write/read success paths, empty/not-found cases, and store failure handling
This commit is contained in:
gsxdsm
2026-04-08 12:27:11 -07:00
parent dc4db25786
commit abbb4eab11
5 changed files with 409 additions and 2 deletions

View File

@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskDocument, TaskStore } from "@fusion/core";
import {
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
} from "../agent-tools.js";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual("@fusion/core");
return {
...actual,
};
});
const TASK_ID = "FN-1272";
type DocStore = Pick<TaskStore, "upsertTaskDocument" | "getTaskDocument" | "getTaskDocuments">;
function createMockDocument(overrides: Partial<TaskDocument> = {}): TaskDocument {
return {
id: "doc-1",
taskId: TASK_ID,
key: "plan",
content: "Initial plan content",
revision: 1,
author: "agent",
createdAt: "2026-04-08T12:00:00.000Z",
updatedAt: "2026-04-08T12:00:00.000Z",
...overrides,
};
}
function createMockStore(overrides: Partial<DocStore> = {}) {
const upsertTaskDocument = vi.fn<DocStore["upsertTaskDocument"]>();
const getTaskDocument = vi.fn<DocStore["getTaskDocument"]>();
const getTaskDocuments = vi.fn<DocStore["getTaskDocuments"]>();
const store: TaskStore = {
upsertTaskDocument,
getTaskDocument,
getTaskDocuments,
...overrides,
} as unknown as TaskStore;
return {
store,
upsertTaskDocument,
getTaskDocument,
getTaskDocuments,
};
}
async function runTool(
tool: { execute: (...args: any[]) => Promise<any> },
callId: string,
params: Record<string, unknown>,
) {
return tool.execute(callId, params, undefined as any, undefined as any, undefined as any);
}
function getText(result: any): string {
const first = result?.content?.[0];
return first?.type === "text" ? first.text : "";
}
describe("task_document_write tool", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls store.upsertTaskDocument with taskId, key, content, and author", async () => {
const { store, upsertTaskDocument } = createMockStore();
upsertTaskDocument.mockResolvedValue(
createMockDocument({ key: "plan", content: "Refined implementation plan", revision: 3, author: "triage-agent" }),
);
const tool = createTaskDocumentWriteTool(store, TASK_ID);
const result = await runTool(tool, "call-1", {
key: "plan",
content: "Refined implementation plan",
author: "triage-agent",
});
expect(upsertTaskDocument).toHaveBeenCalledWith(TASK_ID, {
key: "plan",
content: "Refined implementation plan",
author: "triage-agent",
});
expect(getText(result)).toContain("Saved document \"plan\"");
expect(getText(result)).toContain("revision 3");
});
it("defaults author to agent when not provided", async () => {
const { store, upsertTaskDocument } = createMockStore();
upsertTaskDocument.mockResolvedValue(createMockDocument({ key: "notes", revision: 2 }));
const tool = createTaskDocumentWriteTool(store, TASK_ID);
await runTool(tool, "call-2", {
key: "notes",
content: "Executor notes",
});
expect(upsertTaskDocument).toHaveBeenCalledWith(TASK_ID, {
key: "notes",
content: "Executor notes",
author: "agent",
});
});
it("returns a user-facing error message for invalid key validation errors", async () => {
const { store, upsertTaskDocument } = createMockStore();
upsertTaskDocument.mockRejectedValue(
new Error("Invalid document key: \"invalid key\". Must be 1-64 characters: letters, digits, hyphens, or underscores."),
);
const tool = createTaskDocumentWriteTool(store, TASK_ID);
const result = await runTool(tool, "call-3", {
key: "invalid key",
content: "anything",
author: "agent",
});
expect(getText(result)).toContain("ERROR: Failed to save document");
expect(getText(result)).toContain("Invalid document key");
});
it("returns a user-facing error message for store errors", async () => {
const { store, upsertTaskDocument } = createMockStore();
upsertTaskDocument.mockRejectedValue(new Error("database temporarily unavailable"));
const tool = createTaskDocumentWriteTool(store, TASK_ID);
const result = await runTool(tool, "call-4", {
key: "research",
content: "Notes",
author: "agent",
});
expect(getText(result)).toContain("ERROR: Failed to save document");
expect(getText(result)).toContain("database temporarily unavailable");
});
});
describe("task_document_read tool", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reads a specific document by key and returns content", async () => {
const { store, getTaskDocument } = createMockStore();
getTaskDocument.mockResolvedValue(
createMockDocument({ key: "plan", content: "Detailed execution checklist", revision: 4 }),
);
const tool = createTaskDocumentReadTool(store, TASK_ID);
const result = await runTool(tool, "call-5", { key: "plan" });
expect(getTaskDocument).toHaveBeenCalledWith(TASK_ID, "plan");
expect(getText(result)).toContain("Document: plan");
expect(getText(result)).toContain("Revision: 4");
expect(getText(result)).toContain("Detailed execution checklist");
});
it("returns not found message when the requested key does not exist", async () => {
const { store, getTaskDocument } = createMockStore();
getTaskDocument.mockResolvedValue(null);
const tool = createTaskDocumentReadTool(store, TASK_ID);
const result = await runTool(tool, "call-6", { key: "plan" });
expect(getTaskDocument).toHaveBeenCalledWith(TASK_ID, "plan");
expect(getText(result)).toContain("Document \"plan\" not found.");
});
it("lists all documents when no key is provided", async () => {
const { store, getTaskDocuments } = createMockStore();
getTaskDocuments.mockResolvedValue([
createMockDocument({ key: "plan", revision: 2, updatedAt: "2026-04-08T12:15:00.000Z" }),
createMockDocument({ key: "research", revision: 1, updatedAt: "2026-04-08T12:30:00.000Z" }),
]);
const tool = createTaskDocumentReadTool(store, TASK_ID);
const result = await runTool(tool, "call-7", {});
expect(getTaskDocuments).toHaveBeenCalledWith(TASK_ID);
expect(getText(result)).toContain("Task documents:");
expect(getText(result)).toContain("- plan (revision 2, updated 2026-04-08T12:15:00.000Z)");
expect(getText(result)).toContain("- research (revision 1, updated 2026-04-08T12:30:00.000Z)");
});
it("returns a no-documents message when list is empty", async () => {
const { store, getTaskDocuments } = createMockStore();
getTaskDocuments.mockResolvedValue([]);
const tool = createTaskDocumentReadTool(store, TASK_ID);
const result = await runTool(tool, "call-8", {});
expect(getTaskDocuments).toHaveBeenCalledWith(TASK_ID);
expect(getText(result)).toBe("No documents found for this task.");
});
it("returns a user-facing error message for read failures", async () => {
const { store, getTaskDocuments } = createMockStore();
getTaskDocuments.mockRejectedValue(new Error("read timeout"));
const tool = createTaskDocumentReadTool(store, TASK_ID);
const result = await runTool(tool, "call-9", {});
expect(getText(result)).toContain("ERROR: Failed to read task documents");
expect(getText(result)).toContain("read timeout");
});
});
describe("document tool factory integration", () => {
it("uses the provided store instance across write and read tools", async () => {
const { store, upsertTaskDocument, getTaskDocument, getTaskDocuments } = createMockStore();
upsertTaskDocument.mockResolvedValue(createMockDocument({ key: "plan", revision: 1 }));
getTaskDocument.mockResolvedValue(createMockDocument({ key: "plan", content: "Saved plan", revision: 1 }));
getTaskDocuments.mockResolvedValue([
createMockDocument({ key: "plan", revision: 1, updatedAt: "2026-04-08T12:45:00.000Z" }),
]);
const writeTool = createTaskDocumentWriteTool(store, TASK_ID);
const readTool = createTaskDocumentReadTool(store, TASK_ID);
await runTool(writeTool, "call-10", { key: "plan", content: "Saved plan" });
await runTool(readTool, "call-11", { key: "plan" });
await runTool(readTool, "call-12", {});
expect(upsertTaskDocument).toHaveBeenCalledTimes(1);
expect(getTaskDocument).toHaveBeenCalledTimes(1);
expect(getTaskDocuments).toHaveBeenCalledTimes(1);
});
});

View File

@@ -7,7 +7,7 @@
* The parameter schemas are canonical here — executor.ts imports and reuses them.
*/
import type { TaskStore } from "@fusion/core";
import type { TaskDocument, TaskDocumentCreateInput, TaskStore } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import type { AgentReflectionService } from "./agent-reflection.js";
@@ -26,6 +26,20 @@ export const taskLogParams = Type.Object({
outcome: Type.Optional(Type.String({ description: "Result or consequence (optional)" })),
});
export const taskDocumentWriteParams = Type.Object({
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 taskDocumentReadParams = Type.Object({
key: Type.Optional(
Type.String({ description: "Document key to read. Omit to list all documents for this task." }),
),
});
export const reflectOnPerformanceParams = Type.Object({
focus_area: Type.Optional(
Type.String({ description: "Optional focus area for reflection (e.g., 'code quality', 'speed', 'testing')" }),
@@ -93,6 +107,117 @@ export function createTaskLogTool(store: TaskStore, taskId: string): ToolDefinit
};
}
/**
* Create a `task_document_write` tool that stores a named task document.
*
* @param store - TaskStore for task document persistence
* @param taskId - The task ID to write documents against
* @returns ToolDefinition for the `task_document_write` tool
*/
export function createTaskDocumentWriteTool(store: TaskStore, taskId: string): ToolDefinition {
return {
name: "task_document_write",
label: "Write Document",
description:
"Save a named document for this task (for example plan, notes, or research). " +
"Each write creates a new revision so you can update documents over time.",
parameters: taskDocumentWriteParams,
execute: async (_id: string, params: Static<typeof taskDocumentWriteParams>) => {
const input: TaskDocumentCreateInput = {
key: params.key,
content: params.content,
author: params.author || "agent",
};
try {
const document: TaskDocument = await store.upsertTaskDocument(taskId, input);
return {
content: [{
type: "text" as const,
text: `Saved document "${document.key}" (revision ${document.revision}).`,
}],
details: {},
};
} catch (err: any) {
return {
content: [{
type: "text" as const,
text: `ERROR: Failed to save document "${params.key}": ${err.message}`,
}],
details: {},
};
}
},
};
}
/**
* Create a `task_document_read` tool that reads task-scoped documents.
*
* @param store - TaskStore for task document reads
* @param taskId - The task ID to read documents from
* @returns ToolDefinition for the `task_document_read` tool
*/
export function createTaskDocumentReadTool(store: TaskStore, taskId: string): ToolDefinition {
return {
name: "task_document_read",
label: "Read Document",
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<typeof taskDocumentReadParams>) => {
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: {},
};
}
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: {},
};
} catch (err: any) {
return {
content: [{
type: "text" as const,
text: `ERROR: Failed to read task documents: ${err.message}`,
}],
details: {},
};
}
},
};
}
/**
* Create a `reflect_on_performance` tool that asks the reflection service to
* analyze recent agent performance and return actionable insights.

View File

@@ -27,6 +27,8 @@ import type { AgentReflectionService } from "./agent-reflection.js";
import {
createReflectOnPerformanceTool,
createTaskCreateTool as sharedCreateTaskCreateTool,
createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool,
createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool,
createTaskLogTool as sharedCreateTaskLogTool,
taskCreateParams,
taskLogParams,
@@ -34,7 +36,14 @@ import {
// Re-export for backward compatibility (tests import from executor.ts)
export { summarizeToolArgs } from "./agent-logger.js";
export { createTaskCreateTool, createTaskLogTool, taskCreateParams, taskLogParams } from "./agent-tools.js";
export {
createTaskCreateTool,
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
createTaskLogTool,
taskCreateParams,
taskLogParams,
} from "./agent-tools.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
@@ -155,6 +164,16 @@ model, read-only access) to independently assess your work.
- **RETHINK (code review)** → your code changes have been reverted and conversation rewound. Read the feedback carefully and take a fundamentally different approach. Do NOT repeat the rejected strategy.
- **RETHINK (plan review)** → conversation rewound to before the step (no git reset since no code was written). Read the feedback and take a fundamentally different approach to planning this step.
## Task Documents
You can save and retrieve named documents for this task. Use these to store planning notes, research findings, or any persistent data that should survive across sessions.
- **Save a document:** \`task_document_write(key="plan", content="...")\`
- **Read a document:** \`task_document_read(key="plan")\`
- **List all documents:** \`task_document_read()\` (no key)
Documents are versioned — each write creates a new revision. Use meaningful keys like "plan", "notes", "research", "architecture".
## Git discipline
- Commit after completing each step (not after every file change)
- Use conventional commit messages prefixed with the task ID
@@ -1047,6 +1066,8 @@ export class TaskExecutor {
this.createTaskDoneTool(task.id, () => { taskDone = true; }),
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail, stuckDetector),
this.createSpawnAgentTool(task.id, worktreePath, settings),
this.createTaskDocumentWriteTool(task.id),
this.createTaskDocumentReadTool(task.id),
// Conditionally add agent self-reflection when enabled and task has an assigned agent.
...reflectionTools,
];
@@ -1649,6 +1670,14 @@ export class TaskExecutor {
return sharedCreateTaskCreateTool(this.store);
}
private createTaskDocumentWriteTool(taskId: string): ToolDefinition {
return sharedCreateTaskDocumentWriteTool(this.store, taskId);
}
private createTaskDocumentReadTool(taskId: string): ToolDefinition {
return sharedCreateTaskDocumentReadTool(this.store, taskId);
}
private createTaskAddDepTool(taskId: string): ToolDefinition {
const store = this.store;
return {

View File

@@ -1,4 +1,14 @@
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
export {
createTaskCreateTool,
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
createTaskLogTool,
taskCreateParams,
taskDocumentReadParams,
taskDocumentWriteParams,
taskLogParams,
} from "./agent-tools.js";
export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";
export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";

View File

@@ -29,6 +29,10 @@ import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./re
import type { StuckTaskDetector } from "./stuck-task-detector.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
} from "./agent-tools.js";
export const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "kb", an AI-orchestrated task board.
@@ -201,6 +205,10 @@ You have these extra tools during triage:
- \`task_list\` — list existing active tasks
- \`task_get\` — inspect a task and its PROMPT.md
- \`task_create\` — create a child/follow-up task while triaging
- \`task_document_write\` — save a planning document (e.g., key="plan")
- \`task_document_read\` — read back a previously saved document
When the planning conversation produces a structured plan, save it as a document with \`task_document_write(key='plan', content='...')\` so the executor can reference it during implementation.
## Guidelines
- Read the project structure and relevant source files to understand context BEFORE writing
@@ -544,6 +552,8 @@ export class TriageProcessor {
allowTaskCreate: true,
createdSubtasksRef,
}),
createTaskDocumentWriteTool(this.store, task.id),
createTaskDocumentReadTool(this.store, task.id),
this.createReviewSpecTool(
task.id,
promptPath,