feat(FN-1950): add list_agents and delegate_task agent delegation tools

- Add list_agents tool for discovering agents by role, state, or includeEphemeral filter
- Add delegate_task tool for assigning work to a specific agent by ID
- Add comprehensive test coverage for both delegation tools in agent-tools-delegation.test.ts
- Update executor.ts to wire up delegation tools when agentStore is configured
- Document both tools in AGENTS.md and docs/agents.md
This commit is contained in:
Fusion
2026-04-16 13:17:17 -07:00
committed by gsxdsm
parent 18722ae361
commit 222a807dc2
7 changed files with 545 additions and 6 deletions

View File

@@ -1631,14 +1631,16 @@ describe("HeartbeatMonitor", () => {
expect(callArgs.cwd).toBe("/tmp/test");
expect(callArgs.systemPrompt).toBe(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.tools).toBe("readonly");
// Tools: task_create, task_log, task_document_write, task_document_read, heartbeat_done
expect(callArgs.customTools).toHaveLength(5);
// Tools: task_create, task_log, task_document_write, task_document_read, list_agents, delegate_task, heartbeat_done
expect(callArgs.customTools).toHaveLength(7);
expect(callArgs.customTools![0]!.name).toBe("task_create");
expect(callArgs.customTools![1]!.name).toBe("task_log");
expect(callArgs.customTools![2]!.name).toBe("task_document_write");
expect(callArgs.customTools![3]!.name).toBe("task_document_read");
expect(callArgs.customTools![4]!.name).toBe("list_agents");
expect(callArgs.customTools![5]!.name).toBe("delegate_task");
// heartbeat_done is last (terminal tool)
expect(callArgs.customTools![4]!.name).toBe("heartbeat_done");
expect(callArgs.customTools![6]!.name).toBe("heartbeat_done");
});
it("includes document tools in heartbeat session", async () => {
@@ -2388,11 +2390,13 @@ describe("HeartbeatMonitor", () => {
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
expect(tools).toHaveLength(4);
expect(tools).toHaveLength(6);
expect(tools[0]!.name).toBe("task_create");
expect(tools[1]!.name).toBe("task_log");
expect(tools[2]!.name).toBe("task_document_write");
expect(tools[3]!.name).toBe("task_document_read");
expect(tools[4]!.name).toBe("list_agents");
expect(tools[5]!.name).toBe("delegate_task");
});
it("task_create tool creates a task in triage via TaskStore", async () => {

View File

@@ -20,7 +20,7 @@
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import { heartbeatLog } from "./logger.js";
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
@@ -1129,6 +1129,10 @@ export class HeartbeatMonitor {
tools.push(createTaskDocumentWriteTool(taskStore, taskId));
tools.push(createTaskDocumentReadTool(taskStore, taskId));
// Agent delegation tools — discover and delegate work to other agents
tools.push(createListAgentsTool(this.store));
tools.push(createDelegateTaskTool(this.store, taskStore));
return tools;
}

View File

@@ -0,0 +1,318 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Agent, AgentStore, TaskStore, Task } from "@fusion/core";
import { createListAgentsTool, createDelegateTaskTool } from "./agent-tools.js";
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
return {
listAgents: vi.fn().mockResolvedValue([]),
getAgent: vi.fn().mockResolvedValue(null),
...overrides,
} as unknown as AgentStore;
}
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
createTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "",
dependencies: [],
column: "triage" as const,
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
...overrides,
} as unknown as TaskStore;
}
function createAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date().toISOString();
return {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "idle",
createdAt: now,
updatedAt: now,
metadata: {},
...overrides,
};
}
describe("createListAgentsTool", () => {
let agentStore: AgentStore;
beforeEach(() => {
agentStore = createMockAgentStore();
});
it("returns formatted list of agents with their details", async () => {
const agents = [
createAgent({ id: "agent-001", name: "Alice", role: "executor", state: "idle", taskId: undefined }),
createAgent({ id: "agent-002", name: "Bob", role: "reviewer", state: "running", taskId: "FN-100" }),
];
vi.mocked(agentStore.listAgents).mockResolvedValue(agents);
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
expect(result.content[0]).toHaveProperty("text");
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Available agents:");
expect(text).toContain("ID: agent-001");
expect(text).toContain("Name: Alice");
expect(text).toContain("Role: executor");
expect(text).toContain("State: idle");
expect(text).toContain("ID: agent-002");
expect(text).toContain("Name: Bob");
expect(text).toContain("Role: reviewer");
expect(text).toContain("State: running");
expect(text).toContain("Current Task: FN-100");
});
it("includes soul truncated to 200 chars when present", async () => {
const longSoul = "A".repeat(300);
const agent = createAgent({ id: "agent-001", soul: longSoul });
vi.mocked(agentStore.listAgents).mockResolvedValue([agent]);
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Soul: " + "A".repeat(200));
expect(text).not.toContain("Soul: " + "A".repeat(201));
});
it("includes title when present", async () => {
const agent = createAgent({ id: "agent-001", title: "Senior Engineer" });
vi.mocked(agentStore.listAgents).mockResolvedValue([agent]);
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Title: Senior Engineer");
});
it("includes instructionsText summary when present", async () => {
const agent = createAgent({ id: "agent-001", instructionsText: "Be thorough and check edge cases." });
vi.mocked(agentStore.listAgents).mockResolvedValue([agent]);
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Custom Instructions: Be thorough and check edge cases.");
});
it("includes instructionsText truncated to 100 chars with ellipsis", async () => {
const longInstructions = "X".repeat(150);
const agent = createAgent({ id: "agent-001", instructionsText: longInstructions });
vi.mocked(agentStore.listAgents).mockResolvedValue([agent]);
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Custom Instructions: " + "X".repeat(100) + "…");
expect(text).not.toContain("Custom Instructions: " + "X".repeat(101));
});
it("filters by role when provided", async () => {
const tool = createListAgentsTool(agentStore);
await tool.execute("session-1", { role: "executor" }, undefined as any, undefined as any, undefined as any);
expect(agentStore.listAgents).toHaveBeenCalledWith({ role: "executor" });
});
it("filters by state when provided", async () => {
const tool = createListAgentsTool(agentStore);
await tool.execute("session-1", { state: "idle" }, undefined as any, undefined as any, undefined as any);
expect(agentStore.listAgents).toHaveBeenCalledWith({ state: "idle" });
});
it("passes includeEphemeral when provided", async () => {
const tool = createListAgentsTool(agentStore);
await tool.execute("session-1", { includeEphemeral: true }, undefined as any, undefined as any, undefined as any);
expect(agentStore.listAgents).toHaveBeenCalledWith({ includeEphemeral: true });
});
it("returns no-agents message when list is empty", async () => {
vi.mocked(agentStore.listAgents).mockResolvedValue([]);
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("No agents found matching the specified filters.");
});
});
describe("createDelegateTaskTool", () => {
let agentStore: AgentStore;
let taskStore: TaskStore;
beforeEach(() => {
agentStore = createMockAgentStore();
taskStore = createMockTaskStore();
});
it("creates task with correct assignedAgentId, column todo, and description", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);
vi.mocked(taskStore.createTask).mockResolvedValue({
id: "FN-050",
description: "Write tests",
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);
const result = await tool.execute("session-1", {
agent_id: "agent-001",
description: "Write tests",
}, undefined as any, undefined as any, undefined as any);
expect(taskStore.createTask).toHaveBeenCalledWith({
description: "Write tests",
dependencies: undefined,
column: "todo",
assignedAgentId: "agent-001",
});
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Delegated to Bob (agent-001)");
expect(text).toContain("Created FN-050");
expect(text).toContain("picked up by Bob on their next heartbeat cycle");
});
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);
vi.mocked(taskStore.createTask).mockResolvedValue({
id: "FN-051",
description: "Write tests",
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);
const result = await tool.execute("session-1", {
agent_id: "agent-001",
description: "Write tests",
}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("FN-051");
expect(text).toContain("Bob");
expect(result.details).toEqual({ taskId: "FN-051", agentId: "agent-001", agentName: "Bob" });
});
it("returns error when target agent not found", async () => {
vi.mocked(agentStore.getAgent).mockResolvedValue(null);
const tool = createDelegateTaskTool(agentStore, taskStore);
const result = await tool.execute("session-1", {
agent_id: "nonexistent-agent",
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 nonexistent-agent not found");
expect(taskStore.createTask).not.toHaveBeenCalled();
});
it("returns error when target agent is ephemeral", async () => {
const ephemeralAgent = createAgent({
id: "executor-FN-100",
metadata: { agentKind: "task-worker" },
});
vi.mocked(agentStore.getAgent).mockResolvedValue(ephemeralAgent);
const tool = createDelegateTaskTool(agentStore, taskStore);
const result = await tool.execute("session-1", {
agent_id: "executor-FN-100",
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: Cannot delegate to ephemeral/runtime agent executor-FN-100");
expect(taskStore.createTask).not.toHaveBeenCalled();
});
it("passes dependencies through to task creation", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);
vi.mocked(taskStore.createTask).mockResolvedValue({
id: "FN-052",
description: "Integration test",
dependencies: ["FN-010"],
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);
const result = await tool.execute("session-1", {
agent_id: "agent-001",
description: "Integration test",
dependencies: ["FN-010"],
}, undefined as any, undefined as any, undefined as any);
expect(taskStore.createTask).toHaveBeenCalledWith({
description: "Integration test",
dependencies: ["FN-010"],
column: "todo",
assignedAgentId: "agent-001",
});
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("depends on: FN-010");
});
it("creates task without dependencies when none specified", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);
vi.mocked(taskStore.createTask).mockResolvedValue({
id: "FN-053",
description: "Simple task",
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);
const result = await tool.execute("session-1", {
agent_id: "agent-001",
description: "Simple task",
}, undefined as any, undefined as any, undefined as any);
expect(taskStore.createTask).toHaveBeenCalledWith(
expect.objectContaining({ dependencies: undefined }),
);
const text = (result.content[0] as { text: string }).text;
expect(text).not.toContain("depends on:");
});
});

View File

@@ -7,7 +7,8 @@
* The parameter schemas are canonical here — executor.ts imports and reuses them.
*/
import type { TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext } from "@fusion/core";
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext } from "@fusion/core";
import { isEphemeralAgent } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import type { AgentReflectionService } from "./agent-reflection.js";
@@ -46,6 +47,26 @@ export const reflectOnPerformanceParams = Type.Object({
),
});
export const listAgentsParams = Type.Object({
role: Type.Optional(
Type.String({ description: "Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa')" }),
),
state: Type.Optional(
Type.String({ description: "Filter by agent state (e.g., 'idle', 'active', 'running')" }),
),
includeEphemeral: Type.Optional(
Type.Boolean({ description: "Include ephemeral/runtime agents (default: false)" }),
),
});
export const delegateTaskParams = Type.Object({
agent_id: Type.String({ description: "The agent ID to delegate work to" }),
description: Type.String({ description: "What needs to be done" }),
dependencies: Type.Optional(
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"])" }),
),
});
// ── Tool factory functions ────────────────────────────────────────────────
/**
@@ -293,3 +314,115 @@ export function createReflectOnPerformanceTool(
},
};
}
/**
* Create a `list_agents` tool that lists all available agents.
*
* @param agentStore - AgentStore for agent discovery
* @returns ToolDefinition for the `list_agents` tool
*/
export function createListAgentsTool(agentStore: AgentStore): ToolDefinition {
return {
name: "list_agents",
label: "List Agents",
description:
"List all available agents in the system. Shows each agent's name, role, state, " +
"personality (soul), and current assignment. Use this to discover which agents exist " +
"and what they specialize in before delegating work.",
parameters: listAgentsParams,
execute: async (_id: string, params: Static<typeof listAgentsParams>) => {
const filter: { role?: AgentCapability; state?: AgentState; includeEphemeral?: boolean } = {};
if (params.role) filter.role = params.role as AgentCapability;
if (params.state) filter.state = params.state as AgentState;
if (params.includeEphemeral !== undefined) filter.includeEphemeral = params.includeEphemeral;
const agents = await agentStore.listAgents(filter);
if (agents.length === 0) {
return {
content: [{ type: "text" as const, text: "No agents found matching the specified filters." }],
details: {},
};
}
const lines = agents.map((agent) => {
const parts: string[] = [
`ID: ${agent.id}`,
`Name: ${agent.name}`,
`Role: ${agent.role}`,
`State: ${agent.state}`,
];
if (agent.title) parts.push(`Title: ${agent.title}`);
if (agent.soul) parts.push(`Soul: ${agent.soul.slice(0, 200)}`);
if (agent.instructionsText) {
const snippet = agent.instructionsText.slice(0, 100);
parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`);
}
if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`);
return parts.join("\n");
});
return {
content: [{ type: "text" as const, text: `Available agents:\n\n${lines.join("\n\n")}` }],
details: { agents },
};
},
};
}
/**
* Create a `delegate_task` tool that creates and assigns a task to a specific agent.
*
* @param agentStore - AgentStore for agent lookup
* @param taskStore - TaskStore for task creation
* @returns ToolDefinition for the `delegate_task` tool
*/
export function createDelegateTaskTool(agentStore: AgentStore, taskStore: TaskStore): ToolDefinition {
return {
name: "delegate_task",
label: "Delegate Task",
description:
"Create a new task and assign it to a specific agent for execution. The task goes to " +
"'todo' and will be picked up by the target agent on their next heartbeat cycle. " +
"Use list_agents first to find available agents and their capabilities.",
parameters: delegateTaskParams,
execute: async (_id: string, params: Static<typeof delegateTaskParams>) => {
// Validate target agent exists
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: {},
};
}
// Validate target agent is not ephemeral
if (isEphemeralAgent(agent)) {
return {
content: [{ type: "text" as const, text: `ERROR: Cannot delegate to ephemeral/runtime agent ${params.agent_id}` }],
details: {},
};
}
// Create task assigned to the target agent
const task = await taskStore.createTask({
description: params.description,
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
});
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
return {
content: [{
type: "text" as const,
text: `Delegated to ${agent.name} (${agent.id}): Created ${task.id}${deps}. ` +
`The task will be picked up by ${agent.name} on their next heartbeat cycle.`,
}],
details: { taskId: task.id, agentId: agent.id, agentName: agent.name },
};
},
};
}

View File

@@ -32,6 +32,8 @@ import type { AgentReflectionService } from "./agent-reflection.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
import {
createDelegateTaskTool,
createListAgentsTool,
createReflectOnPerformanceTool,
createTaskCreateTool as sharedCreateTaskCreateTool,
createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool,
@@ -43,10 +45,14 @@ import { getTaskCompletionBlockerForStore } from "./task-completion.js";
// Re-export for backward compatibility (tests import from executor.ts)
export { summarizeToolArgs } from "./agent-logger.js";
export {
createDelegateTaskTool,
createListAgentsTool,
createTaskCreateTool,
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
createTaskLogTool,
delegateTaskParams,
listAgentsParams,
taskCreateParams,
taskLogParams,
} from "./agent-tools.js";
@@ -1404,6 +1410,11 @@ export class TaskExecutor {
this.createTaskDocumentReadTool(task.id),
// Conditionally add agent self-reflection when enabled and task has an assigned agent.
...reflectionTools,
// Agent delegation tools — discover and delegate work to other agents.
...(this.options.agentStore ? [
createListAgentsTool(this.options.agentStore),
createDelegateTaskTool(this.options.agentStore, this.store),
] : []),
// Add plugin tools from PluginRunner
...(this.options.pluginRunner?.getPluginTools() ?? []),
];