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:
7
.changeset/fn-8207-delegate-assign.md
Normal file
7
.changeset/fn-8207-delegate-assign.md
Normal 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).
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_") ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => []),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
createTaskReadTools,
|
||||
createListAgentsTool,
|
||||
createDelegateTaskTool,
|
||||
createTaskAssignTool,
|
||||
createGetAgentConfigTool,
|
||||
createWebFetchTool,
|
||||
createGoalRetrievalTools,
|
||||
@@ -84,6 +85,7 @@ export {
|
||||
traitListParams,
|
||||
listAgentsParams,
|
||||
delegateTaskParams,
|
||||
taskAssignParams,
|
||||
getAgentConfigParams,
|
||||
webFetchParams,
|
||||
memorySearchParams,
|
||||
|
||||
@@ -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!),
|
||||
]
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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),
|
||||
] : []),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user