FN-8207: add task reassignment for delegated work

Correct delegation routing for duplicate tasks and enable explicit task owner reassignment.

- Preserve requested assignee and todo routing on duplicate canonical tasks
- Add governed fn_task_assign to engine, heartbeat, triage, workflow, and chat sessions
- Cover assignment validation, truthful delegation responses, and tool availability

Files changed:
 .changeset/fn-8207-delegate-assign.md              |   7 ++
 docs/agents.md                                     |   6 ++
 packages/core/src/types.ts                         |   1 +
 packages/core/src/usage-events.ts                  |   2 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   |   2 +
 packages/dashboard/src/__tests__/chat.test.ts      |   2 +
 packages/dashboard/src/chat.ts                     |   2 +
 .../engine/src/__tests__/agent-action-gate.test.ts |   2 +
 .../src/__tests__/agent-tools-delegation.test.ts   |  99 ++++++++++++++++++-
 .../src/__tests__/agent-tools-task-assign.test.ts  |  75 +++++++++++++++
 .../src/__tests__/gating-classifications.test.ts   |   1 +
 .../src/__tests__/heartbeat-executor.test.ts       |   8 +-
 .../src/__tests__/permanent-agent-gating.test.ts   |   2 +
 .../src/__tests__/step-session-executor.test.ts    |   4 +-
 packages/engine/src/__tests__/triage.test.ts       |   5 +-
 packages/engine/src/agent-heartbeat.ts             |   4 +-
 packages/engine/src/agent-tools.ts                 | 105 ++++++++++++++++++++-
 packages/engine/src/executor.ts                    |   3 +
 packages/engine/src/gating-classifications.ts      |   1 +
 packages/engine/src/index.ts                       |   2 +
 packages/engine/src/step-session-executor.ts       |   2 +
 packages/engine/src/triage.ts                      |   2 +
 22 files changed, 324 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-8207

Fusion-Task-Lineage: db6f3876-bdc8-4279-b726-29344d9acdb9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 10:53:19 -07:00
parent 86c281bc38
commit eb7d223a03
22 changed files with 324 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Correct duplicate delegation ownership and add engine task reassignment.
category: fix
dev: Engine sessions now expose fn_task_assign; CLI reassignment remains fn_task_update(agentId).

View File

@@ -1226,6 +1226,12 @@ Delete a non-ephemeral direct-report agent. Deletion is blocked when the target
- `"ERROR: Cannot delete ephemeral/runtime agent {agent_id}"`
- Underlying store errors (for example, an active checkout lease) are returned as `"ERROR: {message}"`; provide `force: true` to bypass lease-related blocking.
### Engine-session task reassignment
Engine-managed agent sessions (executor, heartbeat, triage, workflow-step, and dashboard chat) expose `fn_task_assign(task_id, agent_id, override?)` to retarget an existing task by ID. It uses the same durable-agent and role/assignment-policy checks as `fn_delegate_task`; ephemeral agents and `assignmentPolicy: "none"` cannot be selected, and `override` only bypasses the role check.
`fn_task_update` in engine sessions remains lifecycle-only. In the CLI/pi extension, use the existing `fn_task_update(id, agentId)` reassignment field instead; there is no redundant CLI `fn_task_assign` alias. If engine `fn_delegate_task` finds a deterministic duplicate, it updates the canonical task's requested owner and column before returning and reports the actual owner rather than promising an incorrect heartbeat pickup.
### Role-based assignment policy
Implementation-task routing distinguishes explicit specialist assignment from generic backlog pickup:

View File

@@ -5831,6 +5831,7 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record<
"fn_spawn_agent",
"fn_update_agent_config",
"fn_task_update",
"fn_task_assign",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",

View File

@@ -120,7 +120,7 @@ export function categorizeToolName(toolName: string | null | undefined): string
if (name.startsWith("fn_skills_")) return "skills";
if (name.startsWith("fn_memory_")) return "memory";
if (name === "fn_list_agents" || name === "fn_agent_org_chart") return "read";
if (name.startsWith("fn_agent_") || name === "fn_delegate_task") return "agents";
if (name.startsWith("fn_agent_") || name === "fn_delegate_task" || name === "fn_task_assign") return "agents";
if (
name.startsWith("fn_mission_") ||
name.startsWith("fn_milestone_") ||

View File

@@ -880,6 +880,7 @@ describe("ChatManager.sendMessage", () => {
"fn_task_search",
"fn_task_create",
"fn_delegate_task",
"fn_task_assign",
"fn_list_agents",
"fn_get_agent_config",
"fn_web_fetch",
@@ -3642,6 +3643,7 @@ describe("ChatManager generation isolation", () => {
"fn_task_search",
"fn_task_create",
"fn_delegate_task",
"fn_task_assign",
"fn_list_agents",
"fn_get_agent_config",
"fn_web_fetch",

View File

@@ -67,6 +67,7 @@ vi.mock("@fusion/engine", () => ({
createTaskSearchTool: vi.fn(),
createListAgentsTool: vi.fn(),
createDelegateTaskTool: vi.fn(),
createTaskAssignTool: vi.fn(),
createGetAgentConfigTool: vi.fn(),
createWebFetchTool: vi.fn(),
createGoalRetrievalTools: vi.fn(() => []),
@@ -88,6 +89,7 @@ vi.mock("@fusion/engine", () => ({
createTaskSearchTool: vi.fn(() => ({})),
createListAgentsTool: vi.fn(() => ({})),
createDelegateTaskTool: vi.fn(() => ({})),
createTaskAssignTool: vi.fn(() => ({})),
createGetAgentConfigTool: vi.fn(() => ({})),
createWebFetchTool: vi.fn(() => ({})),
createGoalRetrievalTools: vi.fn(() => []),

View File

@@ -64,6 +64,7 @@ import {
createTaskSearchTool,
createListAgentsTool,
createDelegateTaskTool,
createTaskAssignTool,
createGetAgentConfigTool,
createWebFetchTool,
createGoalRetrievalTools,
@@ -378,6 +379,7 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
tools.push(createListAgentsTool(agentStore));
if (taskStore) {
tools.push(createDelegateTaskTool(agentStore, taskStore, { rootDir }));
tools.push(createTaskAssignTool(agentStore, taskStore));
}
if (agentId) {
tools.push(createGetAgentConfigTool(agentStore, agentId));

View File

@@ -207,6 +207,7 @@ describe("agent-action-gate", () => {
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_assign", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
@@ -527,6 +528,7 @@ describe("agent-action-gate", () => {
it.each([
"fn_task_create",
"fn_delegate_task",
"fn_task_assign",
"fn_task_import_github",
"fn_task_import_github_issue",
"fn_task_import_gitlab_project_issues",

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Agent, AgentStore, TaskStore, Task } from "@fusion/core";
import { createListAgentsTool, createDelegateTaskTool } from "../agent-tools.js";
import { createAgentTask, createListAgentsTool, createDelegateTaskTool } from "../agent-tools.js";
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
return {
@@ -14,6 +14,9 @@ function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
findRecentTasksByContentFingerprint: vi.fn().mockResolvedValue([]),
updateTask: vi.fn(),
moveTask: vi.fn(),
createTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "",
@@ -239,6 +242,100 @@ describe("createDelegateTaskTool", () => {
expect(text).toContain("picked up by Bob on their next heartbeat cycle");
});
it("reassigns and moves a duplicate canonical task before reporting delegation", async () => {
const agent = createAgent({ id: "agent-002", name: "Rita" });
const existing = {
id: "FN-duplicate",
description: "Write tests",
dependencies: [],
column: "triage" as const,
assignedAgentId: "agent-001",
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const reassigned = { ...existing, assignedAgentId: "agent-002" };
const moved = { ...reassigned, column: "todo" as const };
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);
vi.mocked(taskStore.findRecentTasksByContentFingerprint).mockResolvedValue([existing]);
vi.mocked(taskStore.updateTask).mockResolvedValue(reassigned);
vi.mocked(taskStore.moveTask).mockResolvedValue(moved);
const result = await createDelegateTaskTool(agentStore, taskStore).execute("session-1", {
agent_id: "agent-002",
description: "Write tests",
}, undefined as any, undefined as any, undefined as any);
expect(taskStore.updateTask).toHaveBeenCalledWith("FN-duplicate", { assignedAgentId: "agent-002" });
expect(taskStore.moveTask).toHaveBeenCalledWith("FN-duplicate", "todo");
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Delegated to Rita (agent-002): Linked existing FN-duplicate");
expect(text).toContain("picked up by Rita on their next heartbeat cycle");
});
it("does not mutate a same-owner duplicate canonical task", async () => {
const existing = {
id: "FN-duplicate",
description: "Write tests",
dependencies: [],
column: "todo" as const,
assignedAgentId: "agent-001",
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
vi.mocked(taskStore.findRecentTasksByContentFingerprint).mockResolvedValue([existing]);
const result = await createAgentTask(taskStore, {
description: "Write tests",
column: "todo",
assignedAgentId: "agent-001",
});
expect(result.task).toBe(existing);
expect(taskStore.updateTask).not.toHaveBeenCalled();
expect(taskStore.moveTask).not.toHaveBeenCalled();
});
it("carries delegation routing onto the reconcile canonical task", async () => {
const created = {
id: "FN-new",
description: "Write tests",
dependencies: [],
column: "todo" as const,
steps: [], currentStep: 0, log: [],
createdAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z",
};
const canonical = { ...created, id: "FN-old", assignedAgentId: "agent-old", column: "triage" as const, createdAt: "2026-01-01T00:00:00.000Z" };
const reassigned = { ...canonical, assignedAgentId: "agent-002" };
const moved = { ...reassigned, column: "todo" as const };
vi.mocked(taskStore.createTask).mockResolvedValue(created);
vi.mocked(taskStore.findRecentTasksByContentFingerprint)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([canonical, created]);
vi.mocked(taskStore.updateTask).mockImplementation(async (id, updates) =>
id === "FN-old" ? reassigned : { ...created, ...updates },
);
vi.mocked(taskStore.moveTask).mockImplementation(async (id, column) =>
id === "FN-old" ? moved : { ...created, id, column },
);
const result = await createAgentTask(taskStore, {
description: "Write tests",
column: "todo",
assignedAgentId: "agent-002",
});
expect(result.wasDuplicate).toBe(true);
expect(result.task).toBe(moved);
expect(taskStore.updateTask).toHaveBeenCalledWith("FN-old", { assignedAgentId: "agent-002" });
expect(taskStore.moveTask).toHaveBeenCalledWith("FN-old", "todo");
});
it("returns success message with task ID and agent name", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core";
import { createTaskAssignTool } from "../agent-tools.js";
function agent(overrides: Partial<Agent> = {}): Agent {
const now = "2026-07-29T00:00:00.000Z";
return {
id: "agent-001", name: "Ari", role: "executor", state: "idle",
createdAt: now, updatedAt: now, metadata: {}, ...overrides,
};
}
function task(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001", description: "Implement fix", dependencies: [], column: "todo",
steps: [], currentStep: 0, log: [], createdAt: "2026-07-29T00:00:00.000Z",
updatedAt: "2026-07-29T00:00:00.000Z", ...overrides,
};
}
describe("createTaskAssignTool", () => {
let agentStore: AgentStore;
let taskStore: TaskStore;
beforeEach(() => {
agentStore = { getAgent: vi.fn().mockResolvedValue(null) } as unknown as AgentStore;
taskStore = {
getTask: vi.fn().mockResolvedValue(task()),
updateTask: vi.fn().mockImplementation(async (id, updates) => task({ id, ...updates })),
} as unknown as TaskStore;
});
it("assigns an existing task and truthfully confirms its owner", async () => {
vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ id: "agent-002", name: "Bea" }));
const result = await createTaskAssignTool(agentStore, taskStore).execute("run", {
task_id: "FN-001", agent_id: "agent-002",
}, undefined as never, undefined as never, undefined as never);
expect(taskStore.updateTask).toHaveBeenCalledWith("FN-001", { assignedAgentId: "agent-002" });
expect((result.content[0] as { text: string }).text).toBe("Assigned FN-001 to Bea (agent-002).");
});
it("reports missing tasks and agents", async () => {
const tool = createTaskAssignTool(agentStore, taskStore);
let result = await tool.execute("run", { task_id: "FN-missing", agent_id: "agent-missing" }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("Agent agent-missing not found");
vi.mocked(agentStore.getAgent).mockResolvedValue(agent());
vi.mocked(taskStore.getTask).mockRejectedValue(new Error("missing"));
result = await tool.execute("run", { task_id: "FN-missing", agent_id: "agent-001" }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("Task FN-missing not found");
});
it("rejects ephemeral and assignmentPolicy none agents", async () => {
const tool = createTaskAssignTool(agentStore, taskStore);
vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ id: "executor-FN-1", metadata: { agentKind: "task-worker" } }));
let result = await tool.execute("run", { task_id: "FN-001", agent_id: "executor-FN-1" }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("Cannot assign to ephemeral/runtime agent");
vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ runtimeConfig: { assignmentPolicy: "none" } }));
result = await tool.execute("run", { task_id: "FN-001", agent_id: "agent-001", override: true }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain('assignmentPolicy "none"');
expect(taskStore.updateTask).not.toHaveBeenCalled();
});
it("requires override for a reviewer target", async () => {
vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ role: "reviewer" }));
const tool = createTaskAssignTool(agentStore, taskStore);
let result = await tool.execute("run", { task_id: "FN-001", agent_id: "agent-001" }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("Pass override=true");
result = await tool.execute("run", { task_id: "FN-001", agent_id: "agent-001", override: true }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("Assigned FN-001");
});
});

View File

@@ -63,6 +63,7 @@ const FN_7111_GOVERNED_TOOLS = [
["fn_workflow_delete", "task_agent_mutation"],
["fn_workflow_settings", "task_agent_mutation"],
["fn_task_update", "task_agent_mutation"],
["fn_task_assign", "task_agent_mutation"],
["fn_task_promote", "task_agent_mutation"],
["fn_task_refine", "task_agent_mutation"],
["fn_run_verification", "command_execution"],

View File

@@ -1616,7 +1616,7 @@ describe("executeHeartbeat", () => {
expect(result.resultJson).toEqual({ reason: "no_assignment" });
});
it("identity agent without task receives correct tools (fn_task_create, fn_list_agents, fn_delegate_task, fn_heartbeat_done)", async () => {
it("identity agent without task receives delegation and task assignment tools", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
@@ -1630,10 +1630,11 @@ describe("executeHeartbeat", () => {
expect(callArgs.tools).toBe("coding");
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
// Should have fn_task_create, fn_list_agents, fn_delegate_task
// Delegation and direct reassignment must remain available together.
expect(toolNames).toContain("fn_task_create");
expect(toolNames).toContain("fn_list_agents");
expect(toolNames).toContain("fn_delegate_task");
expect(toolNames).toContain("fn_task_assign");
// Should have fn_heartbeat_done
expect(toolNames).toContain("fn_heartbeat_done");
// Should have memory tools
@@ -3274,7 +3275,7 @@ describe("executeHeartbeat", () => {
*/
// fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity,
// task read discovery (incl. logs_read), workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done.
expect(callArgs.customTools).toHaveLength(42);
expect(callArgs.customTools).toHaveLength(43);
expect(callArgs.customTools!.map((tool) => tool.name)).toEqual([
"fn_task_create",
"fn_task_log",
@@ -3286,6 +3287,7 @@ describe("executeHeartbeat", () => {
"fn_artifact_view",
"fn_list_agents",
"fn_delegate_task",
"fn_task_assign",
"fn_get_agent_config",
"fn_update_agent_config",
"fn_agent_create",

View File

@@ -52,6 +52,7 @@ const FN_7111_GOVERNED_TOOLS = [
["fn_workflow_delete", "task_agent_mutation"],
["fn_workflow_settings", "task_agent_mutation"],
["fn_task_update", "task_agent_mutation"],
["fn_task_assign", "task_agent_mutation"],
["fn_task_promote", "task_agent_mutation"],
["fn_task_refine", "task_agent_mutation"],
["fn_run_verification", "command_execution"],
@@ -109,6 +110,7 @@ describe("permanent-agent-gating", () => {
expect(classifyPermanentAgentToolCall("fn_research_cancel").category).toBe("network_api");
expect(classifyPermanentAgentToolCall("worktrunk_install").category).toBe("network_api");
expect(classifyPermanentAgentToolCall("fn_task_update").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_task_assign").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true });

View File

@@ -3062,7 +3062,7 @@ describe("StepSessionExecutor tool availability", () => {
return captured;
}
it("includes fn_list_agents and fn_delegate_task when agentStore is available", async () => {
it("includes fn_list_agents, fn_delegate_task, and fn_task_assign when agentStore is available", async () => {
const mockAgentStore = {
listAgents: vi.fn().mockResolvedValue([]),
getAgent: vi.fn().mockResolvedValue(null),
@@ -3075,6 +3075,7 @@ describe("StepSessionExecutor tool availability", () => {
const toolNames = tools.map((t: any) => t.name);
expect(toolNames).toContain("fn_list_agents");
expect(toolNames).toContain("fn_delegate_task");
expect(toolNames).toContain("fn_task_assign");
});
it("excludes delegation tools when agentStore is not provided", async () => {
@@ -3083,6 +3084,7 @@ describe("StepSessionExecutor tool availability", () => {
const toolNames = tools.map((t: any) => t.name);
expect(toolNames).not.toContain("fn_list_agents");
expect(toolNames).not.toContain("fn_delegate_task");
expect(toolNames).not.toContain("fn_task_assign");
});
it("includes fn_send_message and fn_read_messages when messageStore and assignedAgentId are available", async () => {

View File

@@ -6840,7 +6840,7 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
}
describe("skillSelection context propagation", () => {
it("passes skillSelection to createFnAgent with correct projectRootDir", async () => {
it("passes skillSelection and delegation reassignment tools to createFnAgent", async () => {
const args = await captureCreateFnAgentArgs({
assignedAgentId: "agent-001",
assignedAgentSkills: ["triage"],
@@ -6849,6 +6849,9 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
expect(args).not.toBeNull();
expect(args).toHaveProperty("skillSelection");
expect(args.skillSelection.projectRootDir).toBe(projectRoot);
const toolNames = args.customTools.map((tool: { name: string }) => tool.name);
expect(toolNames).toContain("fn_delegate_task");
expect(toolNames).toContain("fn_task_assign");
});
it("uses 'triage' as sessionPurpose for triage sessions", async () => {

View File

@@ -38,7 +38,7 @@ import {
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, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createTaskAssignTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import {
resolveAgentInstructionsWithRatings,
@@ -2496,6 +2496,7 @@ export class HeartbeatMonitor {
// Agent delegation tools
heartbeatTools.push(createListAgentsTool(this.store));
heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir }));
heartbeatTools.push(createTaskAssignTool(this.store, taskStore));
heartbeatTools.push(createGetAgentConfigTool(this.store, agentId));
heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId));
heartbeatTools.push(createAgentCreateTool(this.store, agentId));
@@ -3757,6 +3758,7 @@ export class HeartbeatMonitor {
// Agent delegation tools — discover and delegate work to other agents
tools.push(createListAgentsTool(this.store));
tools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir }));
tools.push(createTaskAssignTool(this.store, taskStore));
tools.push(createGetAgentConfigTool(this.store, agentId));
tools.push(createUpdateAgentConfigTool(this.store, agentId));
tools.push(createAgentCreateTool(this.store, agentId));

View File

@@ -13,7 +13,7 @@ import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import * as fusionCore from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode } from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
@@ -387,6 +387,12 @@ export const delegateTaskParams = Type.Object({
override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })),
});
export const taskAssignParams = Type.Object({
task_id: Type.String({ description: "Task ID to assign (e.g. FN-001)" }),
agent_id: Type.String({ description: "Durable agent ID to assign to the task" }),
override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })),
});
export const getAgentConfigParams = Type.Object({
agent_id: Type.String({ description: "The agent ID to read configuration for" }),
});
@@ -933,6 +939,28 @@ type AgentTaskCreationOptions = {
callerIsEphemeral?: boolean;
};
/*
FNXC:AgentRouting 2026-07-29-00:00:
FN-8207 requires deterministic-duplicate canonical tasks to honor an explicit delegate's owner and todo-column request. Carry both mutations in the engine task-creation seam so every canonical return path is truthful without changing the shared core duplicate-guard API.
*/
async function carryCanonicalTaskRouting(
store: TaskStore,
canonical: Task,
input: TaskCreateInput,
): Promise<Task> {
// Task creation without an explicit assignee must not mutate an existing duplicate.
if (input.assignedAgentId === undefined) return canonical;
let task = canonical;
if (input.assignedAgentId !== canonical.assignedAgentId) {
task = await store.updateTask(canonical.id, { assignedAgentId: input.assignedAgentId });
}
if (input.column !== undefined && input.column !== task.column) {
task = await store.moveTask(task.id, input.column);
}
return task;
}
export async function createAgentTask(
store: TaskStore,
input: TaskCreateInput,
@@ -954,7 +982,10 @@ export async function createAgentTask(
try {
if (guard.action === "duplicate" && guard.existing) {
return { task: guard.existing, wasDuplicate: true };
return {
task: await carryCanonicalTaskRouting(store, guard.existing, input),
wasDuplicate: true,
};
}
const sourceMetadata = {
@@ -1003,7 +1034,12 @@ export async function createAgentTask(
logger: log,
});
return { task: reconcile.canonical, wasDuplicate: reconcile.outcome === "archived" };
return {
task: reconcile.outcome === "archived"
? await carryCanonicalTaskRouting(store, reconcile.canonical, input)
: reconcile.canonical,
wasDuplicate: reconcile.outcome === "archived",
};
} finally {
guard.releaseLock();
}
@@ -4122,11 +4158,24 @@ export function createDelegateTaskTool(
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
const workflow = workflowId ? ` (workflow: ${workflowId})` : "";
/*
FNXC:AgentRouting 2026-07-29-00:00:
FN-8207 requires delegation confirmation to reflect the canonical task's actual owner. Never promise a heartbeat pickup by the requested agent when a duplicate canonical task remains owned by someone else.
*/
const assignedToRequestedAgent = !wasDuplicate || task.assignedAgentId === agent.id;
const actualOwner = task.assignedAgentId ? `agent ${task.assignedAgentId}` : "no agent";
const action = wasDuplicate
? assignedToRequestedAgent
? `Linked existing ${task.id} and assigned it to ${agent.name}`
: `Linked existing ${task.id}; it remains assigned to ${actualOwner}`
: `Created ${task.id}`;
const pickup = assignedToRequestedAgent
? ` The task will be picked up by ${agent.name} on their next heartbeat cycle.`
: "";
return {
content: [{
type: "text" as const,
text: `Delegated to ${agent.name} (${agent.id}): ${wasDuplicate ? "Linked existing" : "Created"} ${task.id}${deps}${workflow}. ` +
`The task will be picked up by ${agent.name} on their next heartbeat cycle.`,
text: `${assignedToRequestedAgent ? `Delegated to ${agent.name} (${agent.id})` : "Delegation linked"}: ${action}${deps}${workflow}.${pickup}`,
}],
details: { taskId: task.id, agentId: agent.id, agentName: agent.name },
};
@@ -4144,6 +4193,52 @@ export function createDelegateTaskTool(
};
}
/*
FNXC:AgentRouting 2026-07-29-00:00:
FN-8207 adds an engine-session reassignment tool because executor fn_task_update is lifecycle-only. Bind checks match delegation: ephemeral agents and assignmentPolicy "none" are never assignable, while override bypasses only role eligibility.
*/
export function createTaskAssignTool(
agentStore: AgentStore,
taskStore: TaskStore,
): ToolDefinition {
return {
name: "fn_task_assign",
label: "Assign Task",
description: "Assign an existing task to a durable agent by task ID. Use this to correct or change task ownership.",
parameters: taskAssignParams,
execute: async (_id: string, params: Static<typeof taskAssignParams>) => {
const agent = await agentStore.getAgent(params.agent_id);
if (!agent) {
return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: {} };
}
if (isEphemeralAgent(agent)) {
return { content: [{ type: "text" as const, text: `ERROR: Cannot assign to ephemeral/runtime agent ${params.agent_id}` }], details: {} };
}
let task: Task;
try {
task = await taskStore.getTask(params.task_id);
} catch {
return { content: [{ type: "text" as const, text: `ERROR: Task ${params.task_id} not found` }], details: {} };
}
const verdict = evaluateImplementationTaskBind(agent, task, {
explicitRouting: true,
executorRoleOverride: params.override === true,
});
if (!verdict.allowed) {
return { content: [{ type: "text" as const, text: `ERROR: ${verdict.reason}` }], details: {} };
}
const assigned = await taskStore.updateTask(task.id, { assignedAgentId: agent.id });
return {
content: [{ type: "text" as const, text: `Assigned ${assigned.id} to ${agent.name} (${agent.id}).` }],
details: { taskId: assigned.id, agentId: agent.id, agentName: agent.name },
};
},
};
}
type AskQuestionInput = Static<typeof askQuestionParams>;
function askQuestionError(message: string) {

View File

@@ -227,6 +227,7 @@ import {
createAgentCreateTool,
createAgentDeleteTool,
createDelegateTaskTool,
createTaskAssignTool,
createGetAgentConfigTool,
createListAgentsTool,
createMemoryTools,
@@ -278,6 +279,7 @@ export {
createAgentCreateTool,
createAgentDeleteTool,
createDelegateTaskTool,
createTaskAssignTool,
createGetAgentConfigTool,
createListAgentsTool,
createReadMessagesTool,
@@ -11665,6 +11667,7 @@ export class TaskExecutor {
...(this.options.agentStore ? [
createListAgentsTool(this.options.agentStore),
createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }),
createTaskAssignTool(this.options.agentStore, this.store),
...(assignedAgentId ? [
createGetAgentConfigTool(this.options.agentStore, assignedAgentId),
createUpdateAgentConfigTool(this.options.agentStore, assignedAgentId),

View File

@@ -11,6 +11,7 @@ export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["write", "
const SHARED_TASK_AGENT_TOOLS = [
"fn_task_add_dep",
"fn_task_update",
"fn_task_assign",
"fn_spawn_agent",
"fn_update_agent_config",
"fn_agent_create",

View File

@@ -27,6 +27,7 @@ export {
createTaskReadTools,
createListAgentsTool,
createDelegateTaskTool,
createTaskAssignTool,
createGetAgentConfigTool,
createWebFetchTool,
createGoalRetrievalTools,
@@ -84,6 +85,7 @@ export {
traitListParams,
listAgentsParams,
delegateTaskParams,
taskAssignParams,
getAgentConfigParams,
webFetchParams,
memorySearchParams,

View File

@@ -44,6 +44,7 @@ import { isContextLimitError } from "./context-limit-detector.js";
import { checkSessionError } from "./usage-limit-detector.js";
import {
createDelegateTaskTool,
createTaskAssignTool,
createListAgentsTool,
createMemoryTools,
createWebFetchTool,
@@ -1314,6 +1315,7 @@ export class StepSessionExecutor {
? [
createListAgentsTool(this.options.agentStore),
createDelegateTaskTool(this.options.agentStore, this.options.store!, { rootDir: this.options.rootDir }),
createTaskAssignTool(this.options.agentStore, this.options.store!),
]
: [];

View File

@@ -160,6 +160,7 @@ import { promisify } from "node:util";
import {
createAgentTask,
createDelegateTaskTool,
createTaskAssignTool,
createListAgentsTool,
createMemoryTools,
createGoalRetrievalTools,
@@ -1110,6 +1111,7 @@ export class TriageProcessor {
...(this.options.agentStore ? [
createListAgentsTool(this.options.agentStore),
createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }),
createTaskAssignTool(this.options.agentStore, this.store),
] : []),
];