fix(dashboard): expose full permission-mapped task toolset in chat sessions (#2376)

## Bug
Chat-session tool surface missing task-mutation tools that exist outside
of chat, even when the agent's permission record grants them.\n\nRepro:
agent-09dcf8b2 (role: custom, CEO) in NextGenEHS has tasks:archive /
tasks:delete / tasks:merge / tasks:retry / tasks:update true in its
permission record with permissionPolicy.presetId = unrestricted and
task_agent_mutation = allow. Calling fn_task_archive / fn_task_delete /
fn_task_merge in chat returns: Tool fn_task_* not found.\n\nRoot cause:
packages/dashboard/src/chat.ts createChatFusionToolset() built a
hardcoded narrow chat-only allowlist while heartbeat registered the
complete lifecycle surface unconditionally.\n\nFix:\n- Add exported
factories in packages/engine/src/agent-tools.ts for missing lifecycle
tools: fn_task_archive, fn_task_unarchive, fn_task_delete,
fn_task_retry, fn_task_pause, fn_task_unpause, fn_task_duplicate,
fn_task_merge, fn_task_update, fn_task_add_dep, fn_task_promote,
fn_trait_list, fn_ask_question, fn_reflect_on_performance,
fn_read_evaluations, fn_update_identity, fn_send_message,
fn_read_messages.\n- Wire those factories into
createChatFusionToolset(). Mission/ideation mutations stay behind
missionMutationGated. Agent-scoped tools still require agentId.\n-
Re-export from packages/engine/src/index.ts.\n- Regression test:
packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts (3/3
passing). Existing chat.test.ts (14/14 passing).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Chat now exposes task lifecycle actions—including archive, retry,
pause, duplicate, merge, and dependency updates—when permitted by the
agent’s action controls.
- Added support for identity updates and evaluation viewing in
agent-linked chats.
- Existing read-only tools remain available, while restricted actions
stay hidden when authorization is unavailable.
- **Tests**
- Added regression coverage for authorized and unauthorized chat tool
surfaces, including preservation of read-only capabilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Drew Donaldson
2026-07-31 00:51:09 -04:00
committed by GitHub
parent dd930c8d7d
commit c1c1b964af
9 changed files with 601 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Chat sessions now expose the full permission-mapped task toolset for gated agents.
category: feature
dev: createChatFusionToolset binds fn_task_archive/unarchive/delete/retry/pause/unpause/duplicate/merge only when an enforceable actionGateContext is present (wrapToolsWithActionGate is a pass-through without a gate, so ungated registration would bypass task_agent_mutation policy). Adds 10 task-lifecycle tool factories to @fusion/engine agent-tools. fn_task_update/add_dep/promote are intentionally not bound in project-scoped chat (no ambient task id). fn_read_evaluations degrades to ratings-only (no ReflectionStore in chat); fn_reflect_on_performance is omitted (no AgentReflectionService). Regression tests assert the gated surface, the withheld surface without a gate, and absence of ambient-task tools.

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore, AgentStore, MessageStore, Settings } from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { createChatFusionToolset } from "../chat.js";
function makeTool(name: string): ToolDefinition {
return { name, label: name, description: "", parameters: { type: "object", properties: {} }, execute: async () => ({ content: [], details: {} }) };
}
const baseTaskStore = () => ({
getSettings: vi.fn(async () => ({})),
} as unknown as TaskStore);
const baseAgentStore = {} as unknown as AgentStore;
const baseMessageStore = {} as unknown as MessageStore;
describe("createChatFusionToolset — permission-parity regression", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Task-lifecycle mutation tools that require an enforceable action-gate context.
// These are only exposed when actionGateContext is present, because
// wrapToolsWithActionGate is a pass-through without a gate (pi.ts) — advertising
// them ungated would let archive/delete/retry/etc. run with no policy enforcement.
const gatedMutationTools = [
"fn_task_archive",
"fn_task_unarchive",
"fn_task_delete",
"fn_task_retry",
"fn_task_pause",
"fn_task_unpause",
"fn_task_duplicate",
"fn_task_merge",
];
// Tools that target the factory's ambient current-task id. Project-scoped chat has
// no ambient task, so binding "" would make them operate on no task — they are
// intentionally NOT part of the chat surface (executor/heartbeat bind them with a
// concrete task id instead).
const ambientTaskTools = ["fn_task_update", "fn_task_add_dep", "fn_task_promote"];
it("exposes gated task-mutation surface when an action-gate context is present", async () => {
const taskStore = baseTaskStore();
const tools = await createChatFusionToolset({
taskStore,
agentStore: baseAgentStore,
rootDir: "/project",
agentId: "agent-abc",
missionMutationGated: true,
actionGateContext: {} as any,
});
const names = new Set(tools.map((t) => t.name));
for (const name of gatedMutationTools) {
expect(names.has(name), `missing gated mutation tool: ${name}`).toBe(true);
}
});
it("withholds task-mutation tools when there is no enforceable action-gate context", async () => {
const taskStore = baseTaskStore();
const tools = await createChatFusionToolset({
taskStore,
agentStore: baseAgentStore,
rootDir: "/project",
agentId: "agent-abc",
missionMutationGated: false,
// no actionGateContext
});
const names = new Set(tools.map((t) => t.name));
for (const name of gatedMutationTools) {
expect(names.has(name), `mutation tool leaked without gate: ${name}`).toBe(false);
}
});
it("never binds ambient-task tools in project-scoped chat (no ambient task id)", async () => {
const taskStore = baseTaskStore();
for (const gate of [undefined, {} as any]) {
const tools = await createChatFusionToolset({
taskStore,
agentStore: baseAgentStore,
rootDir: "/project",
agentId: "agent-abc",
missionMutationGated: gate ? true : false,
...(gate ? { actionGateContext: gate } : {}),
});
const names = new Set(tools.map((t) => t.name));
for (const name of ambientTaskTools) {
expect(names.has(name), `ambient-task tool must not be bound: ${name}`).toBe(false);
}
}
});
it("does not bind fn_reflect_on_performance in chat (no reflection service available)", async () => {
const taskStore = baseTaskStore();
const tools = await createChatFusionToolset({
taskStore,
agentStore: baseAgentStore,
rootDir: "/project",
agentId: "agent-abc",
actionGateContext: {} as any,
});
const names = new Set(tools.map((t) => t.name));
expect(names.has("fn_reflect_on_performance")).toBe(false);
// read-only evaluations tool still present (degrades to ratings-only without a store)
expect(names.has("fn_read_evaluations")).toBe(true);
});
it("does not regress existing read-only tools", async () => {
const taskStore = baseTaskStore();
const tools = await createChatFusionToolset({
taskStore,
agentStore: baseAgentStore,
rootDir: "/project",
agentId: "agent-abc",
});
const names = new Set(tools.map((t) => t.name));
expect(names.has("fn_task_list")).toBe(true);
expect(names.has("fn_task_show")).toBe(true);
expect(names.has("fn_task_search")).toBe(true);
expect(names.has("fn_task_create")).toBe(true);
expect(names.has("fn_task_assign")).toBe(true);
expect(names.has("fn_list_agents")).toBe(true);
expect(names.has("fn_web_fetch")).toBe(true);
expect(names.has("fn_trait_list")).toBe(true);
expect(names.has("fn_ask_question")).toBe(true);
});
});

View File

@@ -109,6 +109,22 @@ vi.mock("@fusion/engine", () => ({
createIdeationTools: vi.fn(() => []), createIdeationTools: vi.fn(() => []),
createMemoryTools: vi.fn(() => []), createMemoryTools: vi.fn(() => []),
createResearchTools: vi.fn(() => []), createResearchTools: vi.fn(() => []),
/*
FNXC:ChatToolset 2026-07-26-12:00:
#2376 chat permission-parity imported task-lifecycle / identity / evaluation factories into chat.ts.
Keep this hardcoded @fusion/engine mock complete so check-mock-completeness stays green (Gate).
*/
createTaskArchiveTool: vi.fn(() => ({})),
createTaskUnarchiveTool: vi.fn(() => ({})),
createTaskDeleteTool: vi.fn(() => ({})),
createTaskRetryTool: vi.fn(() => ({})),
createTaskPauseTool: vi.fn(() => ({})),
createTaskUnpauseTool: vi.fn(() => ({})),
createTaskDuplicateTool: vi.fn(() => ({})),
createTaskMergeTool: vi.fn(() => ({})),
createTraitListTool: vi.fn(() => ({})),
createReadEvaluationsTool: vi.fn(() => ({})),
createUpdateIdentityTool: vi.fn(() => ({})),
})); }));
describe("resolveFileReferences", () => { describe("resolveFileReferences", () => {

View File

@@ -85,6 +85,17 @@ import {
resolveMcpServersForStore, resolveMcpServersForStore,
resolveExecutorThinkingLevel, resolveExecutorThinkingLevel,
wrapToolsWithActionGate, wrapToolsWithActionGate,
createTaskArchiveTool,
createTaskUnarchiveTool,
createTaskDeleteTool,
createTaskRetryTool,
createTaskPauseTool,
createTaskUnpauseTool,
createTaskDuplicateTool,
createTaskMergeTool,
createTraitListTool,
createReadEvaluationsTool,
createUpdateIdentityTool,
} from "@fusion/engine"; } from "@fusion/engine";
import * as engineModule from "@fusion/engine"; import * as engineModule from "@fusion/engine";
@@ -562,7 +573,7 @@ function createTaskVerificationTools(taskStore: TaskStore, actionGateContext?: A
} }
export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise<ChatCustomTool[]> { export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise<ChatCustomTool[]> {
const { taskStore, agentStore, rootDir, agentId, missionMutationGated = false } = options; const { taskStore, agentStore, rootDir, agentId, missionMutationGated = false, actionGateContext } = options;
const tools: ChatCustomTool[] = []; const tools: ChatCustomTool[] = [];
if (taskStore) { if (taskStore) {
@@ -573,7 +584,35 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
createTaskSearchTool(taskStore), createTaskSearchTool(taskStore),
...createTaskVerificationTools(taskStore, options.actionGateContext), ...createTaskVerificationTools(taskStore, options.actionGateContext),
createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }), createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }),
/* FNXC:ResearchMissionBridge 2026-07-18-12:00: Promotion is a mission mutation because it creates canonical roadmap work; dashboard chat exposes it only through the same permanent-agent action gate as all hierarchy writes. */ );
/*
FNXC:ChatTaskMutationGate 2026-07-22-00:00:
Task-lifecycle mutations are only exposed when an enforceable action-gate context is
present for the bound agent. wrapToolsWithActionGate is a pass-through when the gate
context is absent or ephemeral (see pi.ts), so registering these tools without a gate
would leave archive/delete/retry/etc. callable with NO task_agent_mutation policy
enforcement. Withhold them from the surface instead of advertising unenforceable
mutations. fn_task_update, fn_task_add_dep, and fn_task_promote are intentionally NOT
bound in project-scoped chat: they target the factory's ambient task id, which project
chat does not have (binding "" makes them operate on no task). The executor/heartbeat
lanes bind those with a concrete current-task id.
*/
if (actionGateContext) {
tools.push(
createTaskArchiveTool(taskStore),
createTaskUnarchiveTool(taskStore),
createTaskDeleteTool(taskStore),
createTaskRetryTool(taskStore),
createTaskPauseTool(taskStore),
createTaskUnpauseTool(taskStore),
createTaskDuplicateTool(taskStore),
createTaskMergeTool(taskStore, ""),
);
}
tools.push(
/* FNXC:ResearchMissionBridge 2026-07-18-12:00: Mission writes are exposed only through the same permanent-agent action gate as all hierarchy writes. */
...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)), ...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)),
/* FNXC:Ideation 2026-07-30-15:30: Unbound or ephemeral chat exposes only positive ideation reads; mutations require the same durable gate context as Mission writes. */ /* FNXC:Ideation 2026-07-30-15:30: Unbound or ephemeral chat exposes only positive ideation reads; mutations require the same durable gate context as Mission writes. */
...createIdeationTools(taskStore).filter((tool) => missionMutationGated || CHAT_IDEATION_READ_TOOL_NAMES.has(tool.name)), ...createIdeationTools(taskStore).filter((tool) => missionMutationGated || CHAT_IDEATION_READ_TOOL_NAMES.has(tool.name)),
@@ -592,10 +631,21 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
} }
if (agentId) { if (agentId) {
tools.push(createGetAgentConfigTool(agentStore, agentId)); tools.push(createGetAgentConfigTool(agentStore, agentId));
tools.push(createUpdateIdentityTool(agentStore, agentId));
/*
FNXC:ChatEvaluations 2026-07-22-00:00:
Chat has no ReflectionStore/AgentReflectionService, so pass undefined for the
reflection store (fn_read_evaluations degrades to ratings-only) and omit
fn_reflect_on_performance entirely — matching the heartbeat/executor guard that
only binds the reflect tool when a reflection service is available.
*/
tools.push(createReadEvaluationsTool(agentStore, undefined, agentId));
} }
} }
tools.push(createWebFetchTool()); tools.push(createWebFetchTool());
tools.push(createTraitListTool());
tools.push(createAskQuestionTool());
return dedupeChatTools(tools); return dedupeChatTools(tools);
} }

View File

@@ -228,6 +228,40 @@ describe("agent-action-gate", () => {
expect(blockedDecision.disposition).toBe("block"); expect(blockedDecision.disposition).toBe("block");
}); });
/*
FNXC:AgentGating 2026-07-26-12:00:
#2376 greptile P1: project chat has no ambient gate taskId. Approvals for fn_task_delete/archive must key off the invocation target so approving task A cannot authorize task B.
*/
it("scopes task_agent_mutation approval dedupe keys to the param-supplied target task", () => {
const deleteA = evaluateAgentActionGate({
agentId: "agent-chat",
toolName: "fn_task_delete",
args: { id: "FN-A" },
permissionPolicy: approvalPolicy,
});
const deleteB = evaluateAgentActionGate({
agentId: "agent-chat",
toolName: "fn_task_delete",
args: { id: "FN-B" },
permissionPolicy: approvalPolicy,
});
const mergeC = evaluateAgentActionGate({
agentId: "agent-chat",
toolName: "fn_task_merge",
args: { task_id: "FN-C" },
permissionPolicy: approvalPolicy,
});
expect(deleteA.resourceType).toBe("task");
expect(deleteA.resourceId).toBe("FN-A");
expect(deleteB.resourceId).toBe("FN-B");
expect(mergeC.resourceId).toBe("FN-C");
expect(deleteA.approvalDedupeKey).not.toBe(deleteB.approvalDedupeKey);
expect(deleteA.approvalDedupeKey).toContain("FN-A");
expect(deleteB.approvalDedupeKey).toContain("FN-B");
expect(mergeC.approvalDedupeKey).toContain("FN-C");
});
// FN-7728: fn_task_bypass_review must classify as its own review_gate_bypass category, // FN-7728: fn_task_bypass_review must classify as its own review_gate_bypass category,
// not task_agent_mutation, and must never fall through to the unrecognized-tool exempt fallback. // not task_agent_mutation, and must never fall through to the unrecognized-tool exempt fallback.
it("classifies fn_task_bypass_review as review_gate_bypass, distinct from task_agent_mutation and exempt", () => { it("classifies fn_task_bypass_review as review_gate_bypass, distinct from task_agent_mutation and exempt", () => {

View File

@@ -198,9 +198,20 @@ export function evaluateAgentActionGate(params: {
operation = params.toolName; operation = params.toolName;
resourceType = "file"; resourceType = "file";
} else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) { } else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) {
/*
FNXC:AgentGating 2026-07-26-12:00:
PR #2376 review (greptile): chat sessions pass an empty ambient gateContext.taskId for project-scoped tools, so approval dedupe previously collapsed every fn_task_delete/archive/… call by the same agent into one empty-task key — approving task A let task B execute under that approval.
Prefer the invocation's target id (`id` or `task_id`) as resourceId (and as the effective taskId when ambient is empty) so each target gets its own approval identity.
*/
category = "task_agent_mutation"; category = "task_agent_mutation";
operation = params.toolName; operation = params.toolName;
resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task"; resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task";
if (resourceType === "task") {
const fromId = typeof args.id === "string" ? args.id.trim() : "";
const fromTaskId = typeof args.task_id === "string" ? args.task_id.trim() : "";
const targetTaskId = fromId || fromTaskId || undefined;
if (targetTaskId) resourceId = targetTaskId;
}
} else if (COMMAND_EXECUTION_TOOLS.has(params.toolName)) { } else if (COMMAND_EXECUTION_TOOLS.has(params.toolName)) {
category = "command_execution"; category = "command_execution";
operation = params.toolName; operation = params.toolName;
@@ -283,9 +294,11 @@ export function evaluateAgentActionGate(params: {
? "allow" ? "allow"
: exactDisposition ?? params.permissionPolicy.rules[category]; : exactDisposition ?? params.permissionPolicy.rules[category];
// Prefer ambient task scope; fall back to arg-derived resourceId so chat tools (empty ambient taskId) still isolate approvals per target.
const effectiveTaskId = params.taskId?.trim() || resourceId;
const dedupeKey = computeApprovalDedupeKey({ const dedupeKey = computeApprovalDedupeKey({
agentId: params.agentId, agentId: params.agentId,
taskId: params.taskId, taskId: effectiveTaskId,
toolName: params.toolName, toolName: params.toolName,
category, category,
resourceType, resourceType,

View File

@@ -273,6 +273,66 @@ export const taskPromoteParams = Type.Object({
), ),
}); });
export const taskArchiveParams = Type.Object({
id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }),
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references (child sourceParentTaskId) before archiving, so a task still referenced as a lineage parent can be archived." })),
});
export const taskDeleteParams = Type.Object({
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
allowResurrection: Type.Optional(Type.Boolean({ description: "When true, mark this tombstone as explicitly reusable for future recreation." })),
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references before deleting." })),
});
export const taskUnarchiveParams = Type.Object({
id: Type.String({ description: "Task ID to unarchive (e.g. FN-001). Must be in 'archived' column." }),
});
export const taskRetryParams = Type.Object({
id: Type.String({ description: "Task ID to retry (e.g. FN-001)." }),
});
export const taskPauseParams = Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
});
export const taskUnpauseParams = Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
});
export const taskDuplicateParams = Type.Object({
id: Type.String({ description: "Source task ID to duplicate (e.g. FN-001)" }),
});
export const taskMergeParams = Type.Object({
task_id: Type.String({ description: "The task ID to merge into the current task." }),
});
export const taskAddDepParams = Type.Object({
task_id: Type.String({ description: "The ID of the task to depend on (e.g. \"KB-001\")" }),
confirm: Type.Optional(Type.Boolean({ description: "Set to true to confirm adding the dependency. Required because adding a dependency to an in-progress task will stop execution and discard current work." })),
});
export const STEP_STATUSES = ["pending", "in-progress", "done", "skipped"] as const;
export const taskUpdateParams = Type.Object({
step: Type.Optional(Type.Number({ description: "Step number (0-indexed; matches the `### Step N:` numbers in PROMPT.md — Step 0 is Preflight). Omit when updating only custom_fields/dependencies." })),
status: Type.Optional(Type.Union(
STEP_STATUSES.map((s) => Type.Literal(s)),
{ description: "New status: pending, in-progress, done, or skipped. Required when step is set." },
)),
dependencies: Type.Optional(Type.Array(Type.String(), {
description: "Optional task dependency array. Replaces existing dependencies. Pass ['FN-001', 'FN-002'] to set dependencies. Pass [] to clear all dependencies. Omit parameter to preserve existing dependencies.",
})),
custom_fields: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
description:
"Optional patch of workflow-defined custom field values, keyed by field id. " +
"Values are validated against the task's workflow field schema (type/enum membership); " +
"pass null for a field to clear it. Rejected writes return the offending field id and reason. " +
"Only fields declared by the task's workflow may be written.",
})),
});
export const workflowCreateParams = Type.Object({ export const workflowCreateParams = Type.Object({
name: Type.String({ description: "Workflow name (required, non-empty)." }), name: Type.String({ description: "Workflow name (required, non-empty)." }),
description: Type.Optional(Type.String({ description: "Optional human-readable description." })), description: Type.Optional(Type.String({ description: "Optional human-readable description." })),
@@ -3053,6 +3113,281 @@ export function createTaskPromoteTool(store: TaskStore, currentTaskId: string):
}; };
} }
/*
FNXC:ChatTaskMutationTools 2026-07-26-12:00:
Chat permission-parity (#2376) adds these lifecycle tools so permanent-agent chat can archive/delete/retry/etc under the same task_agent_mutation gate as heartbeat/executor.
Keep catch blocks typed as unknown (no-explicit-any) and surface err.message via instanceof — the PR lint gate fails bare `any` here even though older factories still use the disable-comment pattern.
*/
function toolErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function createTaskArchiveTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_archive",
label: "Archive Task",
description:
"Archive a task from any live column (move to archived). " +
"Archived tasks are preserved for historical reference but moved out of the main board view. " +
"If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed.",
parameters: taskArchiveParams,
execute: async (_id: string, params: Static<typeof taskArchiveParams>) => {
try {
const task = await store.archiveTask(params.id, {
removeLineageReferences: params.removeLineageReferences === true,
});
return {
content: [{ type: "text" as const, text: `Archived ${task.id} → ${task.column}` }],
details: { taskId: task.id, column: task.column },
};
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to archive task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskUnarchiveTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_unarchive",
label: "Unarchive Task",
description:
"Unarchive an archived task (move from archived → its restore column). " +
"Restores to the pre-archive column when available, with active execution columns downgraded to todo.",
parameters: taskUnarchiveParams,
execute: async (_id: string, params: Static<typeof taskUnarchiveParams>) => {
try {
const task = await store.unarchiveTask(params.id);
return {
content: [{ type: "text" as const, text: `Unarchived ${task.id} → ${task.column}` }],
details: { taskId: task.id, column: task.column },
};
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to unarchive task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskDeleteTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_delete",
label: "Delete Task",
description:
"Soft-delete a task from active Fusion board views. " +
"The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. " +
"If the task is still referenced as a lineage parent by another task, deletion is rejected unless removeLineageReferences:true is passed.",
parameters: taskDeleteParams,
execute: async (_id: string, params: Static<typeof taskDeleteParams>) => {
try {
const task = await store.deleteTask(params.id, {
allowResurrection: params.allowResurrection === true,
removeLineageReferences: params.removeLineageReferences === true,
auditContext: { agentId: "chat", runId: `chat-delete-${params.id}-${Date.now()}`, taskId: params.id },
});
return { content: [{ type: "text" as const, text: `Deleted ${task.id}` }], details: { taskId: task.id } };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to delete task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskRetryTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_retry",
label: "Retry Task",
description: "Retry a failed task. Clears failure state and re-queues the task for execution.",
parameters: taskRetryParams,
execute: async (_id: string, params: Static<typeof taskRetryParams>) => {
try {
const task = await store.getTask(params.id);
if (task.status !== "failed" && task.status !== "stuck-killed") {
return { content: [{ type: "text" as const, text: `Task ${params.id} is not in a retryable state (status: ${task.status || "none"})` }], details: { taskId: params.id, currentStatus: task.status }, isError: true };
}
await store.updateTask(params.id, { status: null, error: null });
await store.moveTask(params.id, "todo");
await store.logEntry(params.id, "Retry requested via chat tool", "Task reset to todo for retry");
return { content: [{ type: "text" as const, text: `Retried ${params.id} → todo` }], details: { taskId: params.id, newColumn: "todo" } };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to retry task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskPauseTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_pause",
label: "Pause Task",
description: "Pause an active task so the executor will not pick it up on the next heartbeat.",
parameters: taskPauseParams,
execute: async (_id: string, params: Static<typeof taskPauseParams>) => {
try {
const task = await store.pauseTask(params.id, true);
return { content: [{ type: "text" as const, text: `Paused ${task.id}` }], details: { taskId: task.id, column: task.column } };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to pause task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskUnpauseTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_unpause",
label: "Unpause Task",
description: "Resume a previously paused task so the executor can pick it up again.",
parameters: taskUnpauseParams,
execute: async (_id: string, params: Static<typeof taskUnpauseParams>) => {
try {
const task = await store.pauseTask(params.id, false);
return { content: [{ type: "text" as const, text: `Unpaused ${task.id}` }], details: { taskId: task.id, column: task.column } };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to unpause task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskDuplicateTool(store: TaskStore): ToolDefinition {
return {
name: "fn_task_duplicate",
label: "Duplicate Task",
description: "Duplicate an existing task, preserving its description, workflow, and dependencies.",
parameters: taskDuplicateParams,
execute: async (_id: string, params: Static<typeof taskDuplicateParams>) => {
try {
const task = await store.duplicateTask(params.id);
return { content: [{ type: "text" as const, text: `Duplicated to ${task.id}` }], details: { taskId: task.id } };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to duplicate task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
/*
FNXC:ChatTaskMutationTools 2026-07-26-12:00:
fn_task_merge targets params.task_id only; the ambient currentTaskId arg is retained for call-site parity with other task tools but is intentionally unused (project chat passes "").
Prefix with underscore so the lint gate does not fail on the unused parameter.
*/
export function createTaskMergeTool(store: TaskStore, _currentTaskId: string): ToolDefinition {
return {
name: "fn_task_merge",
label: "Merge Task",
description:
"Merge a task into the current/parent task. The target task is closed and its work is rolled into the parent.",
parameters: taskMergeParams,
execute: async (_id: string, params: Static<typeof taskMergeParams>) => {
const targetId = params.task_id?.trim();
if (!targetId) return { content: [{ type: "text" as const, text: "ERROR: task_id is required." }], details: {}, isError: true };
try {
const result = await store.mergeTask(targetId);
const mergedInto = result?.task?.id ?? targetId;
return { content: [{ type: "text" as const, text: `Merged ${targetId} into ${mergedInto}` }], details: { targetId, mergedInto } };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to merge task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskUpdateTool(store: TaskStore, taskId: string): ToolDefinition {
return {
name: "fn_task_update",
label: "Update Step / Custom Fields / Dependencies",
description:
"Update a task step status, dependencies, or workflow custom fields without leaving chat. " +
"Use step+status to report progress, dependencies to rewire blockers, or custom_fields to set workflow-defined fields.",
parameters: taskUpdateParams,
execute: async (_id: string, params: Static<typeof taskUpdateParams>) => {
try {
if (params.custom_fields !== undefined) {
const res = await store.updateTaskCustomFields(taskId, params.custom_fields);
if (!res.ok) {
const r = res.rejection;
return {
content: [{ type: "text" as const, text: `ERROR: custom field '${r.fieldId}' rejected (${r.code}): ${r.detail}` }],
details: { fieldId: r.fieldId, code: r.code, detail: r.detail },
isError: true,
};
}
}
if (params.dependencies !== undefined) {
if (params.dependencies.includes(taskId)) {
return { content: [{ type: "text" as const, text: "ERROR: self-dependency not allowed." }], details: {}, isError: true };
}
const invalidIds: string[] = [];
for (const depId of params.dependencies) {
try { await store.getTask(depId); } catch { invalidIds.push(depId); }
}
if (invalidIds.length) {
return { content: [{ type: "text" as const, text: `ERROR: Unknown dependency task(s): ${invalidIds.join(", ")}` }], details: {}, isError: true };
}
await store.updateTask(taskId, { dependencies: params.dependencies });
}
if (params.step !== undefined && params.status !== undefined) {
const task = await store.updateStep(taskId, params.step, params.status);
return { content: [{ type: "text" as const, text: `Updated ${taskId}: step ${params.step} → ${params.status}` }], details: { taskId: task.id, step: params.step, status: params.status } };
}
if (params.custom_fields !== undefined || params.dependencies !== undefined) {
return { content: [{ type: "text" as const, text: "Updated." }], details: {} };
}
return { content: [{ type: "text" as const, text: "No-op: provide step+status, dependencies, or custom_fields." }], details: {} };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
export function createTaskAddDepTool(store: TaskStore, taskId: string): ToolDefinition {
return {
name: "fn_task_add_dep",
label: "Add Task Dependency",
description:
"Add a dependency to the current task. Adding a dependency to an in-progress task will stop execution " +
"and discard current work. Confirm is required for in-progress tasks.",
parameters: taskAddDepParams,
execute: async (_id: string, params: Static<typeof taskAddDepParams>) => {
try {
const depId = params.task_id?.trim();
if (!depId) return { content: [{ type: "text" as const, text: "ERROR: task_id is required." }], details: {}, isError: true };
const task = await store.getTask(taskId);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-00:30:
The board's WIP lanes, not the literal. This guard is the only thing standing between an
operator and losing in-flight work: adding a dependency to a running task stops execution and
discards it, so the confirmation must fire on whatever lane that board calls "in progress".
Keyed on the literal it was silently skipped on every renamed board — the work is destroyed
with no prompt, which is the failure you cannot undo.
Same shape as the terminal-column resolution earlier in this file: seed the legacy id as the
floor, union the resolved trait columns, and degrade to the legacy id alone if the workflow
cannot be read.
*/
const wipColumns = new Set<string>(["in-progress"]);
try {
const wipIr = await fusionCore.resolveWorkflowIrForTask(store, taskId);
if (wipIr) for (const id of fusionCore.columnsWithFlag(wipIr, "countsTowardWip")) wipColumns.add(id);
} catch { /* degraded: legacy id only */ }
if (wipColumns.has(task.column) && params.confirm !== true) {
return {
content: [{ type: "text" as const, text: "WARNING: adding a dependency to an in-progress task will stop execution and discard current work. Pass confirm:true to proceed." }],
details: { requiresConfirm: true, taskId },
};
}
if (depId === taskId) return { content: [{ type: "text" as const, text: "ERROR: cannot add self-dependency." }], details: {}, isError: true };
await store.updateTask(taskId, { dependencies: [...(task.dependencies || []), depId] });
return { content: [{ type: "text" as const, text: `Added dependency ${depId} to ${taskId}` }], details: { taskId, dependency: depId } };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to add dependency: ${toolErrorMessage(err)}` }], details: {}, isError: true };
}
},
};
}
/** /**
* Shared write-time column-agent gate for the `fn_workflow_*` tools (R11/R13). * Shared write-time column-agent gate for the `fn_workflow_*` tools (R11/R13).
* Runs the SAME `validateColumnAgentBindings` check the dashboard route runs, so * Runs the SAME `validateColumnAgentBindings` check the dashboard route runs, so

View File

@@ -88,6 +88,8 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
"fn_task_archive", "fn_task_archive",
"fn_task_unarchive", "fn_task_unarchive",
"fn_task_delete", "fn_task_delete",
// FNXC:AgentGating 2026-07-26-12:00: #2376 chat permission-parity exposes fn_task_merge; classify it so the action gate cannot fall through to exempt.
"fn_task_merge",
"fn_task_plan", "fn_task_plan",
"fn_mission_create", "fn_mission_create",
"fn_mission_delete", "fn_mission_delete",

View File

@@ -74,6 +74,18 @@ export {
createWorkflowDeleteTool, createWorkflowDeleteTool,
createWorkflowSettingsTool, createWorkflowSettingsTool,
createTraitListTool, createTraitListTool,
createTaskArchiveTool,
createTaskUnarchiveTool,
createTaskDeleteTool,
createTaskRetryTool,
createTaskPauseTool,
createTaskUnpauseTool,
createTaskDuplicateTool,
createTaskMergeTool,
createTaskUpdateTool,
createTaskAddDepTool,
createReadEvaluationsTool,
createUpdateIdentityTool,
createWorkflowAuthoringTools, createWorkflowAuthoringTools,
createAgentTask, createAgentTask,
taskCreateParams, taskCreateParams,