feat(FN-3957): add delegate_task override flag to bypass executor role poli

Adds a `delegate_override` flag to the `delegate_task` tool that allows executor-role tasks to be assigned to non-executor agents. The override is persisted as metadata in the extension and honored by the heartbeat inbox guard, with test coverage across engine, core, and CLI packages plus updated do

Fusion-Task-Id: FN-3957
This commit is contained in:
Fusion
2026-05-10 19:19:15 -07:00
committed by gsxdsm
parent a5a794d12e
commit a0c7c3315a
12 changed files with 160 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Expose and honor `override` on `fn_delegate_task` so intentional non-executor delegations work end-to-end for durable agents while preserving default executor-role safeguards.

View File

@@ -871,6 +871,7 @@ Implementation tasks require an agent with `role: "executor"`.
- Heartbeat inbox and auto-claim paths filter out role-incompatible implementation tasks.
- `PATCH /api/tasks/:id/assign` returns `409` for non-executor assignment attempts unless `override: true` is provided in the request body.
- `fn_delegate_task` enforces the same policy and supports `override: true` when intentional.
- Override delegations are persisted with task source metadata (`executorRoleOverride`) so inbox selection and heartbeat execution can intentionally run that assigned implementation task on the targeted durable non-executor agent.
## Heartbeat Monitoring and Trigger Scheduling

View File

@@ -27,7 +27,7 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_update_identity` | heartbeat | Update the current agent's own `soul`, `instructionsText`, or `memory` fields | `soul?` (string), `instructionsText?` (string), `memory?` (string) |
| `fn_reflect_on_performance` | executor, heartbeat (when reflection service enabled) | Generate reflection insights from prior runs | `focus_area?` (string) |
| `fn_list_agents` | triage, executor, heartbeat | List agents (optionally filtered) | `role?` (string), `state?` (string), `includeEphemeral?` (boolean) |
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]) |
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]), `override?` (boolean) |
| `fn_get_agent_config` | executor, heartbeat | Read full config for a direct-report agent | `agent_id` (string) |
| `fn_update_agent_config` | executor, heartbeat | Update config fields for a direct-report, non-ephemeral agent | `agent_id` (string), optional: `soul`, `instructions_text`, `instructions_path`, `heartbeat_procedure_path`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |
| `fn_agent_create` | executor, heartbeat | Create a non-ephemeral direct-report agent | `name` (string), `role` (string), optional: `soul`, `instructions_text`, `instructions_path`, `reportsTo`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |

View File

@@ -2007,6 +2007,14 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.isError).not.toBe(true);
expect(result.details.agentId).toBe(reviewer.id);
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.getTask(result.details.taskId);
expect(task.sourceMetadata).toMatchObject({ executorRoleOverride: true });
const selected = await store.selectNextTaskForAgent(reviewer.id, { id: reviewer.id, role: reviewer.role });
expect(selected?.task.id).toBe(task.id);
});
it("wires dependencies correctly", async () => {

View File

@@ -2747,7 +2747,10 @@ export default function kbExtension(pi: ExtensionAPI) {
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
source: { sourceType: "api" },
source: {
sourceType: "api",
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
},
});
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";

View File

@@ -963,6 +963,23 @@ describe("TaskStore", () => {
expect(selected?.task.id).toBe(todo.id);
expect(selected?.priority).toBe("todo");
});
it("allows non-executor role agents to pick assigned todos when override metadata is set", async () => {
const delegated = await store.createTask({
description: "Assigned todo override",
column: "todo",
assignedAgentId: "agent-1",
source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } },
});
const selected = await store.selectNextTaskForAgent("agent-1", {
id: "agent-1",
role: "reviewer",
});
expect(selected?.task.id).toBe(delegated.id);
expect(selected?.priority).toBe("todo");
});
});
// ── Lock serialization test ──────────────────────────────────────

View File

@@ -3029,6 +3029,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
agentId: string,
agent?: Pick<Agent, "id" | "role">,
): Promise<InboxTask | null> {
const hasExecutorRoleOverride = (task: Task): boolean => task.sourceMetadata?.executorRoleOverride === true;
const tasks = await this.listTasks({ slim: true });
if (tasks.length === 0) {
return null;
@@ -3056,7 +3057,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const roleCompatibleAssignedTasks = agent
? assignedTasks.filter((task) => {
if (task.column === "in-progress") {
if (task.column === "in-progress" || hasExecutorRoleOverride(task)) {
return true;
}
return canAgentTakeImplementationTask(agent, task);

View File

@@ -256,6 +256,49 @@ describe("createDelegateTaskTool", () => {
expect(taskStore.createTask).not.toHaveBeenCalled();
});
it("rejects non-executor target without override", async () => {
const reviewer = createAgent({ id: "agent-002", name: "Rita", role: "reviewer" });
vi.mocked(agentStore.getAgent).mockResolvedValue(reviewer);
const tool = createDelegateTaskTool(agentStore, taskStore);
const result = await tool.execute("session-1", {
agent_id: "agent-002",
description: "Do something",
}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("ERROR: Agent agent-002 has role \"reviewer\"");
expect(text).toContain("Pass override=true to bypass");
expect(taskStore.createTask).not.toHaveBeenCalled();
});
it("allows non-executor target with override", async () => {
const reviewer = createAgent({ id: "agent-002", name: "Rita", role: "reviewer" });
vi.mocked(agentStore.getAgent).mockResolvedValue(reviewer);
vi.mocked(taskStore.createTask).mockResolvedValue({
id: "FN-054",
description: "Do something",
dependencies: [],
column: "todo" as const,
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const tool = createDelegateTaskTool(agentStore, taskStore);
await tool.execute("session-1", {
agent_id: "agent-002",
description: "Do something",
override: true,
}, undefined as any, undefined as any, undefined as any);
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } },
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
});
it("passes dependencies through to task creation", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);

View File

@@ -164,6 +164,23 @@ describe("createDelegateTaskTool", () => {
}));
});
it("forwards override=true marker in source metadata", async () => {
const agentStore = {
getAgent: vi.fn().mockResolvedValue({ id: "agent-2", name: "Planner", role: "triage", state: "idle" }),
};
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
createTask: vi.fn().mockResolvedValue({ id: "FN-102", dependencies: [], description: "Delegated" }),
};
const tool = createDelegateTaskTool(agentStore as any, taskStore as any);
await tool.execute("call-1", { agent_id: "agent-2", description: "Delegated", override: true } as any, undefined, undefined, {} as any);
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } },
}), expect.any(Object));
});
it("wires title summarization callback when rootDir is provided", async () => {
const summarizeSpy = vi.spyOn(core, "summarizeTitle").mockResolvedValue("Short title");
const agentStore = {

View File

@@ -1707,7 +1707,11 @@ describe("executeHeartbeat", () => {
});
describe("executeHeartbeat - inbox selection", () => {
const makeInboxSelection = (taskId: string, priority: "in_progress" | "todo" | "blocked" = "todo") => {
const makeInboxSelection = (
taskId: string,
priority: "in_progress" | "todo" | "blocked" = "todo",
sourceMetadata?: Record<string, unknown>,
) => {
const now = new Date().toISOString();
return {
task: {
@@ -1720,6 +1724,7 @@ describe("executeHeartbeat", () => {
log: [],
createdAt: now,
updatedAt: now,
...(sourceMetadata ? { sourceMetadata } : {}),
},
priority,
reason: `selected:${priority}`,
@@ -1791,6 +1796,40 @@ describe("executeHeartbeat", () => {
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-EXISTING");
});
it("allows non-executor inbox selection when override metadata is present", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, role: "reviewer" });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(
makeInboxSelection("FN-INBOX", "todo", { executorRoleOverride: true }),
);
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent,
getTask: vi.fn().mockResolvedValue({
id: "FN-INBOX",
title: "Inbox Task",
description: "Inbox-selected task",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
sourceMetadata: { executorRoleOverride: true },
} as unknown as TaskDetail),
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "reviewer" });
expect(store.assignTask).toHaveBeenCalledWith("agent-001", "FN-INBOX", expect.objectContaining({ agentId: "agent-001" }));
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "inbox_selected", taskId: "FN-INBOX" }));
});
it("when inbox returns null, heartbeat completes with no_assignment", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(null);

View File

@@ -1491,10 +1491,13 @@ export class HeartbeatMonitor {
if (!taskId) {
inboxSelection = await taskStore.selectNextTaskForAgent(agentId, { id: agent.id, role: agent.role });
if (inboxSelection && !canAgentTakeImplementationTask(agent, inboxSelection.task)) {
heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped inbox-selected task ${inboxSelection.task.id} due to executor-role assignment policy`,
);
inboxSelection = null;
const hasRoleOverride = inboxSelection.task.sourceMetadata?.executorRoleOverride === true;
if (!hasRoleOverride) {
heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped inbox-selected task ${inboxSelection.task.id} due to executor-role assignment policy`,
);
inboxSelection = null;
}
}
if (inboxSelection) {
taskId = inboxSelection.task.id;

View File

@@ -12,7 +12,7 @@ import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings } from "@fusion/core";
import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTask, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { ResearchStepRunner } from "./research-step-runner.js";
@@ -83,6 +83,7 @@ export const delegateTaskParams = Type.Object({
dependencies: Type.Optional(
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"])" }),
),
override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })),
});
export const getAgentConfigParams = Type.Object({
@@ -1732,13 +1733,25 @@ export function createDelegateTaskTool(
};
}
const override = params.override === true;
const newTaskRef = { id: "<new>", column: "todo" } as const;
if (!override && !canAgentTakeImplementationTask(agent, { column: newTaskRef.column })) {
return {
content: [{ type: "text" as const, text: `ERROR: ${formatRoleMismatchReason(agent, newTaskRef)}` }],
details: {},
};
}
// Create task assigned to the target agent
const task = await createAgentTask(taskStore, {
description: params.description,
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
source: { sourceType: "api" },
source: {
sourceType: "api",
...(override ? { sourceMetadata: { executorRoleOverride: true } } : {}),
},
}, options);
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";