FN-7118: add shared task read tool factories

Add reusable heartbeat-safe task read tools for agents.\n\n- Add shared fn_task_list, fn_task_show, and fn_task_search factories with text-safe formatting.\n- Wire the task read tools into permanent/custom heartbeat work tooling and prompts.\n- Classify task read surfaces as read-only coordination exemptions and export the factories.\n- Cover cross-surface task read behavior with engine tests and document the agent surface.\n\nFiles changed:\n .changeset/FN-7118-shared-task-read-tools.md       |   7 +\n docs/agents.md                                     |   2 +-\n .../src/__tests__/agent-task-read-tools.test.ts    | 160 ++++++++++++++++++++\n .../src/__tests__/gating-classifications.test.ts   |  15 ++\n .../src/__tests__/heartbeat-executor.test.ts       |  31 ++--\n .../src/__tests__/heartbeat-session-prompt.test.ts |   8 +-\n packages/engine/src/agent-heartbeat.ts             |  16 +-\n packages/engine/src/agent-tools.ts                 | 161 ++++++++++++++++++++-\n packages/engine/src/gating-classifications.ts      |  11 ++\n packages/engine/src/index.ts                       |   7 +\n 10 files changed, 396 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-7118

Fusion-Task-Lineage: 89a5b3a2-9295-435f-8bb9-f66f8ec73cc1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 11:08:09 -07:00
parent 5ab4a5961c
commit 8f0f020caa
10 changed files with 396 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Permanent and custom agents can list, show, and search tasks during heartbeat runs.
category: feature
dev: Adds shared read-only task tool factories (createTaskListTool/createTaskShowTool/createTaskSearchTool/createTaskReadTools), wires them into createSharedHeartbeatWorkTools, classifies fn_task_search/fn_task_get read-only, and adds cross-surface drift tests.

View File

@@ -34,7 +34,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- 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`.
- Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change.
- Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency.
- Permanent/custom heartbeat agents receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: workflow discovery (`fn_workflow_list`, `fn_workflow_get`, `fn_trait_list`), bounded research (`fn_research_run`, `fn_research_list`, `fn_research_get`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding safe tools from the session.
- Permanent/custom heartbeat agents receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery (`fn_workflow_list`, `fn_workflow_get`, `fn_trait_list`), bounded research (`fn_research_run`, `fn_research_list`, `fn_research_get`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding safe tools from the session.
### Artifact registry tools

View File

@@ -0,0 +1,160 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { MAX_TASK_LIST_TEXT_CHARS, type AgentPermissionPolicy, type Task, type TaskDetail, type TaskStore } from "@fusion/core";
import { createPlanningBoardTools } from "../../../dashboard/src/planning-board-tools.js";
import { createTaskReadTools } from "../agent-tools.js";
import { HeartbeatMonitor } from "../agent-heartbeat.js";
import { evaluateAgentActionGate } from "../agent-action-gate.js";
import { COORDINATION_EXEMPT_TOOLS, READONLY_FN_TOOLS } from "../gating-classifications.js";
import { classifyPermanentAgentToolCall } from "../permanent-agent-gating.js";
import { TriageProcessor } from "../triage.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const lockedDownPolicy: AgentPermissionPolicy = {
presetId: "locked-down",
rules: {
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
},
};
type TaskReadResult = Awaited<ReturnType<ReturnType<typeof createTaskReadTools>[number]["execute"]>>;
function textOf(result: TaskReadResult): string {
expect(result.content).toHaveLength(1);
expect(result.content[0]).toMatchObject({ type: "text" });
const text = result.content[0]?.text ?? "";
expect(text.trim().length).toBeGreaterThan(0);
return text;
}
function task(overrides: Partial<Task> & Pick<Task, "id">): Task {
return {
id: overrides.id,
title: overrides.title,
description: overrides.description ?? `Description for ${overrides.id}`,
column: overrides.column ?? "todo",
dependencies: overrides.dependencies ?? [],
steps: overrides.steps ?? [],
currentStep: overrides.currentStep ?? 0,
...overrides,
} as Task;
}
function taskDetail(overrides: Partial<TaskDetail> & Pick<TaskDetail, "id">): TaskDetail {
return {
...task(overrides),
prompt: overrides.prompt ?? "# Prompt body",
} as TaskDetail;
}
function createStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
listTasks: vi.fn(async () => []),
searchTasks: vi.fn(async () => []),
getTask: vi.fn(async (id: string) => taskDetail({ id })),
getSettings: vi.fn(async () => ({})),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function toolNames(tools: Array<{ name: string }>): string[] {
return tools.map((tool) => tool.name);
}
function extractRegisteredCliTaskReadNames(): string[] {
const source = readFileSync(resolve(__dirname, "../../../cli/src/extension.ts"), "utf8");
return [...source.matchAll(/name:\s*"(fn_task_(?:list|show|search|get))"/g)].map((match) => match[1]!);
}
describe("shared task read tools", () => {
it("returns the canonical heartbeat task-read names in order", () => {
expect(toolNames(createTaskReadTools(createStore()))).toEqual([
"fn_task_list",
"fn_task_show",
"fn_task_search",
]);
});
it("returns non-empty bounded text for empty, populated, no-match, and oversized boards", async () => {
const emptyTools = createTaskReadTools(createStore());
expect(textOf(await emptyTools[0]!.execute("list-empty", {}))).toBe("No active tasks.");
expect(textOf(await emptyTools[2]!.execute("search-empty", { query: "missing" }))).toBe("No tasks matched.");
const populatedStore = createStore({
listTasks: vi.fn(async () => [
task({ id: "FN-001", title: "Active task", column: "todo", dependencies: ["FN-000"] }),
task({ id: "FN-002", title: "Done task", column: "done" }),
]),
searchTasks: vi.fn(async () => [task({ id: "FN-003", title: "Search hit", column: "done" })]),
getTask: vi.fn(async () => taskDetail({ id: "FN-004", title: "Show me", prompt: "# Prompt" })),
});
const populatedTools = createTaskReadTools(populatedStore);
const listText = textOf(await populatedTools[0]!.execute("list-populated", {}));
expect(listText).toContain("FN-001 (todo): Active task [deps: FN-000]");
expect(listText).not.toContain("FN-002");
expect(textOf(await populatedTools[1]!.execute("show-populated", { id: "FN-004" }))).toContain("PROMPT.md:");
expect(textOf(await populatedTools[2]!.execute("search-populated", { query: "search", includeDone: true }))).toContain("FN-003 (done): Search hit");
const oversizedTasks = Array.from({ length: 120 }, (_, index) => task({
id: `FN-${String(index + 100).padStart(3, "0")}`,
title: `Oversized ${index} ${"x".repeat(120)}`,
column: "todo",
}));
const oversizedStore = createStore({
listTasks: vi.fn(async () => oversizedTasks),
searchTasks: vi.fn(async () => oversizedTasks),
});
const oversizedTools = createTaskReadTools(oversizedStore);
const oversizedListText = textOf(await oversizedTools[0]!.execute("list-oversized", {}));
const oversizedSearchText = textOf(await oversizedTools[2]!.execute("search-oversized", { query: "oversized" }));
expect(oversizedListText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(oversizedSearchText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(oversizedListText).toContain("truncated to fit");
});
it("exposes task reads through the shared heartbeat helper and task-scoped heartbeat tools", () => {
const monitor = new HeartbeatMonitor({ store: {} as never, taskStore: createStore(), rootDir: "/tmp/fn-test" });
const sharedNames = toolNames((monitor as unknown as { createSharedHeartbeatWorkTools: (store: TaskStore) => Array<{ name: string }> }).createSharedHeartbeatWorkTools(createStore()));
expect(sharedNames.slice(0, 3)).toEqual(["fn_task_list", "fn_task_show", "fn_task_search"]);
const taskScopedNames = toolNames(monitor.createHeartbeatTools("agent-1", createStore(), "FN-001"));
expect(taskScopedNames).toEqual(expect.arrayContaining(["fn_task_list", "fn_task_show", "fn_task_search"]));
});
it("positively classifies all task-read names as read-only", () => {
for (const toolName of ["fn_task_search", "fn_task_get", "fn_task_list", "fn_task_show"] as const) {
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(true);
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
const decision = evaluateAgentActionGate({ agentId: "agent-1", toolName, args: {}, permissionPolicy: lockedDownPolicy });
expect(decision).toMatchObject({ disposition: "allow", category: "exempt", operation: toolName });
}
});
it("pins per-surface task-read tool name parity", () => {
const triageProcessor = new TriageProcessor(createStore() as never, "/tmp/fn-test");
const triageNames = toolNames((triageProcessor as unknown as { createTriageTools: (opts: unknown) => Array<{ name: string }> }).createTriageTools({
parentTaskId: "FN-TRIAGE",
allowTaskCreate: true,
createdSubtasksRef: { current: [] },
})).filter((name) => name.startsWith("fn_task_") && name !== "fn_task_create");
expect(triageNames).toEqual(["fn_task_list", "fn_task_search", "fn_task_get"]);
expect(toolNames(createPlanningBoardTools(createStore())).filter((name) => name.startsWith("fn_task_"))).toEqual([
"fn_task_list",
"fn_task_get",
]);
expect(extractRegisteredCliTaskReadNames()).toEqual(["fn_task_list", "fn_task_show"]);
expect(toolNames(createTaskReadTools(createStore()))).toEqual(["fn_task_list", "fn_task_show", "fn_task_search"]);
});
});

View File

@@ -125,7 +125,11 @@ describe("gating-classifications parity", () => {
"fn_task_document_read",
"fn_task_document_write",
"fn_task_done",
"fn_task_get",
"fn_task_list",
"fn_task_log",
"fn_task_search",
"fn_task_show",
"fn_update_identity",
"fn_workflow_list",
"grep",
@@ -210,6 +214,17 @@ describe("gating-classifications parity", () => {
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_workflow_list")).toBe(true);
});
it.each(["fn_task_search", "fn_task_get", "fn_task_list", "fn_task_show"] as const)("classifies task read tool %s as read-only", (toolName) => {
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(true);
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: blockedPolicy })).toMatchObject({
category: "exempt",
disposition: "allow",
operation: toolName,
});
});
it("keeps fn_* category equivalence mappings across gates", () => {
const fnTools = new Set<string>();
for (const source of [

View File

@@ -3030,8 +3030,8 @@ describe("executeHeartbeat", () => {
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("coding");
// fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity,
// workflow discovery, bounded research, clarification, web fetch, memory, and fn_heartbeat_done.
expect(callArgs.customTools).toHaveLength(29);
// task read discovery, workflow discovery, bounded research, clarification, web fetch, memory, and fn_heartbeat_done.
expect(callArgs.customTools).toHaveLength(32);
expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
@@ -3049,19 +3049,22 @@ describe("executeHeartbeat", () => {
expect(callArgs.customTools![14]!.name).toBe("fn_goal_show");
expect(callArgs.customTools![15]!.name).toBe("fn_read_evaluations");
expect(callArgs.customTools![16]!.name).toBe("fn_update_identity");
expect(callArgs.customTools![17]!.name).toBe("fn_workflow_list");
expect(callArgs.customTools![18]!.name).toBe("fn_workflow_get");
expect(callArgs.customTools![19]!.name).toBe("fn_trait_list");
expect(callArgs.customTools![20]!.name).toBe("fn_ask_question");
expect(callArgs.customTools![21]!.name).toBe("fn_research_run");
expect(callArgs.customTools![22]!.name).toBe("fn_research_list");
expect(callArgs.customTools![23]!.name).toBe("fn_research_get");
expect(callArgs.customTools![24]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![25]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![26]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![27]!.name).toBe("fn_memory_append");
expect(callArgs.customTools![17]!.name).toBe("fn_task_list");
expect(callArgs.customTools![18]!.name).toBe("fn_task_show");
expect(callArgs.customTools![19]!.name).toBe("fn_task_search");
expect(callArgs.customTools![20]!.name).toBe("fn_workflow_list");
expect(callArgs.customTools![21]!.name).toBe("fn_workflow_get");
expect(callArgs.customTools![22]!.name).toBe("fn_trait_list");
expect(callArgs.customTools![23]!.name).toBe("fn_ask_question");
expect(callArgs.customTools![24]!.name).toBe("fn_research_run");
expect(callArgs.customTools![25]!.name).toBe("fn_research_list");
expect(callArgs.customTools![26]!.name).toBe("fn_research_get");
expect(callArgs.customTools![27]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![28]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![29]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![30]!.name).toBe("fn_memory_append");
// fn_heartbeat_done is last (terminal tool)
expect(callArgs.customTools![28]!.name).toBe("fn_heartbeat_done");
expect(callArgs.customTools![31]!.name).toBe("fn_heartbeat_done");
});
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {

View File

@@ -113,6 +113,9 @@ describe("createHeartbeatTools", () => {
const REQUIRED_NO_TASK_TOOLS = [
"fn_task_create",
"fn_task_list",
"fn_task_show",
"fn_task_search",
"fn_list_agents",
"fn_delegate_task",
"fn_get_agent_config",
@@ -180,7 +183,7 @@ describe("createHeartbeatTools", () => {
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
expect(tools).toHaveLength(24);
expect(tools).toHaveLength(27);
expect(tools[0]!.name).toBe("fn_task_create");
expect(tools[1]!.name).toBe("fn_task_log");
expect(tools[2]!.name).toBe("fn_task_document_write");
@@ -199,6 +202,9 @@ describe("createHeartbeatTools", () => {
expect(tools[15]!.name).toBe("fn_read_evaluations");
expect(tools[16]!.name).toBe("fn_update_identity");
expect(tools.slice(17).map((tool) => tool.name)).toEqual([
"fn_task_list",
"fn_task_show",
"fn_task_search",
"fn_workflow_list",
"fn_workflow_get",
"fn_trait_list",

View File

@@ -23,7 +23,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai";
import { createHash } from "node:crypto";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import {
resolveAgentInstructionsWithRatings,
@@ -484,10 +484,11 @@ You are not expected to implement large code changes in no-task mode.
Your job:
1. Review your context — check messages, memory, and project state.
2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory.
3. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate.
4. Use fn_list_agents and fn_delegate_task to coordinate with other agents.
5. Use fn_get_agent_config and fn_update_agent_config to read/tune direct-report agents for better routing outcomes.
6. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
3. Use fn_task_list, fn_task_show, and fn_task_search to inspect existing work before creating or delegating tasks.
4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate.
5. Use fn_list_agents and fn_delegate_task to coordinate with other agents.
6. Use fn_get_agent_config and fn_update_agent_config to read/tune direct-report agents for better routing outcomes.
7. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
Examples of ONE useful action:
- DO: create a clearly scoped task for a newly discovered reliability issue.
@@ -499,6 +500,7 @@ Examples of ONE useful action:
Keep work lightweight — this is a single-pass ambient check, not a full implementation run.
You have coding-capable workspace tools (read/write/edit/bash within worktree boundaries) plus:
- fn_task_create
- fn_task_list, fn_task_show, and fn_task_search
- fn_list_agents and fn_delegate_task
- fn_get_agent_config and fn_update_agent_config (for direct reports only)
- fn_agent_create and fn_agent_delete (for direct reports only)
@@ -3418,6 +3420,9 @@ export class HeartbeatMonitor {
/**
* FNXC:AgentTooling 2026-06-27-04:20:
* Permanent/custom heartbeat agents should receive the full safe coordination and work-discovery surface they may need; risky actions are governed at call time by AgentPermissionPolicy through wrapToolsWithActionGate, not by hiding tools from the session. Only expose mutating factories here when their tool names are classified by the action gate or are intentional benign coordination primitives.
*
* FNXC:AgentTooling 2026-06-27-14:21:
* Read-only task discovery tools are part of this shared heartbeat-safe surface so both no-task and task-scoped permanent/custom heartbeat runs can list, show, and search tasks for duplicate avoidance without bespoke tool copies.
*/
private createSharedHeartbeatWorkTools(taskStore: TaskStore): ToolDefinition[] {
const rootDir = this.rootDir ?? process.cwd();
@@ -3428,6 +3433,7 @@ export class HeartbeatMonitor {
}).filter((tool) => tool.name !== "fn_research_cancel");
return [
...createTaskReadTools(taskStore),
createWorkflowListTool(taskStore),
createWorkflowGetTool(taskStore),
createTraitListTool(),

View File

@@ -11,8 +11,9 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import * as fusionCore from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
@@ -58,6 +59,19 @@ export const taskLogParams = Type.Object({
outcome: Type.Optional(Type.String({ description: "Result or consequence (optional)" })),
});
export const taskListParams = Type.Object({});
export const taskShowParams = Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
});
export const taskSearchParams = Type.Object({
query: Type.String({ minLength: 1, description: "Search query" }),
includeDone: Type.Optional(Type.Boolean({ description: "Include done tasks (default true)" })),
includeArchived: Type.Optional(Type.Boolean({ description: "Include archived tasks (default true)" })),
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, description: "Max results (default 20, max 50)" })),
});
export const acquireRepoWorktreeParams = Type.Object({
repo: Type.String({
description:
@@ -985,6 +999,151 @@ export function createTaskCreateTool(
};
}
type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string;
type TaskListFormatter = (
lines: string[],
opts?: { maxChars?: number; clamp?: TaskListClamp },
) => string;
function inlineTaskReadListFallback(
lines: string[],
opts: { maxChars?: number } = {},
): string {
const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS));
try {
const text = lines.join("\n");
if (text.length <= maxChars) {
return text;
}
return text.slice(0, Math.max(0, maxChars - 1)) + "…";
} catch {
return "";
}
}
function resolveTaskReadListFormatter(core: { formatTaskListText?: unknown }): TaskListFormatter {
return typeof core.formatTaskListText === "function"
? (core.formatTaskListText as TaskListFormatter)
: inlineTaskReadListFallback;
}
function formatTaskReadLines(lines: string[], emptyStateText: string): string {
if (lines.length === 0) {
return emptyStateText;
}
const formatter = resolveTaskReadListFormatter(fusionCore);
const text = formatter(lines, { clamp: fusionCore.clampTaskListText });
return text.trim().length > 0 ? text : emptyStateText;
}
function formatTaskSummaryLine(task: { id: string; column: string; title?: string | null; description: string; dependencies: string[] }): string {
const desc = task.title || task.description.slice(0, 80) || "(no description)";
const deps = task.dependencies.length ? ` [deps: ${task.dependencies.join(", ")}]` : "";
return `${task.id} (${task.column}): ${desc}${deps}`;
}
/**
* FNXC:AgentTooling 2026-06-27-14:05:
* Shared read-only task discovery factories must return host-safe text and be reusable by triage, chat/planning, and heartbeat surfaces. Heartbeat agents now receive task read tools through this single store-backed implementation instead of bespoke copies, while model-visible legacy `fn_task_get` surfaces remain separately pinned by drift tests.
*/
export function createTaskListTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_list",
label: "List Tasks",
description:
"List active tasks that aren't done or archived. Returns ID, description, column, " +
"and dependencies for each. Use to discover work and check for duplicates.",
parameters: taskListParams,
execute: async () => {
const tasks = await store.listTasks({ slim: true, includeArchived: false });
const active = tasks.filter((task) => task.column !== "done");
const lines = active.map(formatTaskSummaryLine);
return {
content: [{ type: "text" as const, text: formatTaskReadLines(lines, "No active tasks.") }],
details: { count: active.length },
};
},
};
}
export function createTaskSearchTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_search",
label: "Search Tasks",
description:
"Keyword search across tasks, including done and archived tasks by default. " +
"Use for duplicate detection and work discovery before filing new tasks.",
parameters: taskSearchParams,
execute: async (_id: string, params: Static<typeof taskSearchParams>) => {
const query = params.query.trim();
if (query.length === 0) {
return {
content: [{ type: "text" as const, text: "No tasks matched." }],
details: { count: 0 },
};
}
const limit = Math.min(50, Math.max(1, Math.floor(params.limit ?? 20)));
const results = await store.searchTasks(query, {
slim: true,
includeArchived: params.includeArchived ?? true,
limit,
});
const includeDone = params.includeDone ?? true;
const filtered = includeDone ? results : results.filter((task) => task.column !== "done");
const lines = filtered.map(formatTaskSummaryLine);
const text = formatTaskReadLines(
lines.length > 0 ? [`Search results for "${query}" (${filtered.length}):`, ...lines] : [],
"No tasks matched.",
);
return {
content: [{ type: "text" as const, text }],
details: { count: filtered.length },
};
},
};
}
export function createTaskShowTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_show",
label: "Show Task",
description: "Show full details for a task including its PROMPT.md content.",
parameters: taskShowParams,
execute: async (_id: string, params: Static<typeof taskShowParams>) => {
try {
const task = await store.getTask(params.id);
const parts = [
`ID: ${task.id}`,
task.title ? `Title: ${task.title}` : null,
`Column: ${task.column}`,
`Status: ${task.status ?? task.column}`,
`Description: ${task.description || "(no description)"}`,
task.dependencies.length ? `Dependencies: ${task.dependencies.join(", ")}` : null,
Array.isArray(task.steps) && task.steps.length
? `Steps:\n${task.steps.map((step, index) => ` ${index}. ${step.name} — ${step.status}`).join("\n")}`
: null,
"",
"PROMPT.md:",
task.prompt || "(not yet specified)",
].filter((part): part is string => typeof part === "string");
return {
content: [{ type: "text" as const, text: parts.join("\n") || `Task ${params.id} has no details.` }],
details: { taskId: task.id },
};
} catch {
return {
content: [{ type: "text" as const, text: `Task ${params.id} not found.` }],
details: {},
};
}
},
};
}
export function createTaskReadTools(store: TaskStore): ToolDefinition[] {
return [createTaskListTool(store), createTaskShowTool(store), createTaskSearchTool(store)];
}
/**
* Create a `fn_task_log` tool that logs an entry for a specific task.
*

View File

@@ -112,6 +112,9 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
"fn_artifact_view",
"fn_task_list",
"fn_task_show",
// FNXC:ToolGovernance 2026-06-27-14:16: Task search and legacy task-get surfaces are read-only duplicate-discovery tools; classify them positively so heartbeat/triage calls never rely on the unknown-tool exempt fallback.
"fn_task_search",
"fn_task_get",
"fn_task_create",
"fn_task_document_write",
"fn_task_document_read",
@@ -161,6 +164,14 @@ export const COORDINATION_EXEMPT_TOOLS = [
"fn_artifact_view",
"fn_task_document_write",
"fn_task_document_read",
/**
* FNXC:ToolGovernance 2026-06-27-15:22:
* Task list/show/search/get are read-only discovery tools. Put them on the action-gate exempt registry, not only READONLY_FN_TOOLS, because evaluateAgentActionGate recognizes coordination exemptions directly and otherwise unknown fn_task_* reads silently fall through to exempt allow.
*/
"fn_task_list",
"fn_task_show",
"fn_task_search",
"fn_task_get",
"fn_memory_search",
"fn_memory_get",
"fn_read_messages",

View File

@@ -3,6 +3,10 @@ export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent
export { createFusionAuthStorage } from "./auth-storage.js";
export {
createTaskCreateTool,
createTaskListTool,
createTaskShowTool,
createTaskSearchTool,
createTaskReadTools,
createArtifactListTool,
createArtifactRegisterTool,
createArtifactViewTool,
@@ -23,6 +27,9 @@ export {
createTraitListTool,
createWorkflowAuthoringTools,
taskCreateParams,
taskListParams,
taskShowParams,
taskSearchParams,
artifactListParams,
artifactRegisterParams,
artifactViewParams,