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:
5
.changeset/fn-3957-delegate-override.md
Normal file
5
.changeset/fn-3957-delegate-override.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(", ")})` : "";
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(", ")})` : "";
|
||||
|
||||
Reference in New Issue
Block a user