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:
233
packages/engine/src/__tests__/agent-document-tools.test.ts
Normal file
233
packages/engine/src/__tests__/agent-document-tools.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user