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:
@@ -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