feat(FN-3327): add agent delegation and org hierarchy tools to pi extension

Adds `list_agents` and `delegate_task` tools to the pi extension for inter-agent delegation, along with supporting reference docs and 248 new tests. The sync script was also updated to keep the skill tools in sync with the extension implementation.

Fusion-Task-Id: FN-3327
This commit is contained in:
Fusion
2026-05-04 02:05:32 -07:00
committed by gsxdsm
parent d38dd9bae1
commit 49368d876d
7 changed files with 669 additions and 2 deletions

View File

@@ -185,6 +185,10 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("fn pi extension", () => {
"fn_feature_link_task",
"fn_agent_stop",
"fn_agent_start",
"fn_list_agents",
"fn_delegate_task",
"fn_agent_show",
"fn_agent_org_chart",
"fn_skills_search",
"fn_skills_install",
] as const;
@@ -1262,4 +1266,248 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.details.error).toContain("ephemeral/runtime agent");
expect(result.content[0].text).toContain(ephemeralId);
});
describe("fn_list_agents", () => {
it("returns agent list", async () => {
await seedAgent(tmpDir, { name: "alpha-agent" });
await seedAgent(tmpDir, { name: "beta-agent" });
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("alpha-agent");
expect(result.content[0].text).toContain("beta-agent");
expect(result.details.count).toBeGreaterThanOrEqual(2);
});
it("filters by role", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
await agentStore.createAgent({ name: "exec-agent", role: "executor", metadata: {} });
await agentStore.createAgent({ name: "review-agent", role: "reviewer", metadata: {} });
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-2", { role: "executor" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("exec-agent");
expect(result.content[0].text).not.toContain("review-agent");
});
it("filters by state", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const active = await agentStore.createAgent({ name: "active-agent", role: "executor", metadata: {} });
await agentStore.updateAgentState(active.id, "active");
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-3", { state: "active" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("active-agent");
expect(result.details.agents.every((a: any) => a.state === "active")).toBe(true);
});
it("excludes ephemeral agents by default", async () => {
const ephemeralId = await seedAgent(tmpDir, { ephemeral: true, name: "eph-agent" });
await seedAgent(tmpDir, { name: "real-agent" });
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-4", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).not.toContain("eph-agent");
expect(result.content[0].text).toContain("real-agent");
expect(result.details.agents.every((a: any) => a.id !== ephemeralId)).toBe(true);
});
it("returns empty list message when no agents", async () => {
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-5", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("No agents found");
expect(result.details.count).toBe(0);
});
});
describe("fn_delegate_task", () => {
it("delegates task to agent", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-target" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-1",
{ agent_id: agentId, description: "Do important work" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("delegate-target");
expect(result.content[0].text).toContain(agentId);
expect(result.details.agentId).toBe(agentId);
expect(result.details.agentName).toBe("delegate-target");
expect(result.details.taskId).toBeTruthy();
// Verify task was actually created
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.getTask(result.details.taskId);
expect(task).toBeTruthy();
expect(task!.assignedAgentId).toBe(agentId);
expect(task!.column).toBe("todo");
});
it("rejects unknown agent", async () => {
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-2",
{ agent_id: "agent-no-such", description: "Will fail" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
it("rejects ephemeral agent", async () => {
const ephemeralId = await seedAgent(tmpDir, { ephemeral: true, name: "eph-delegate" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-3",
{ agent_id: ephemeralId, description: "Will fail" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("ephemeral/runtime agent");
});
it("wires dependencies correctly", async () => {
const agentId = await seedAgent(tmpDir, { name: "dep-agent" });
// Create a real task to use as a dependency
const store = new TaskStore(tmpDir);
await store.init();
const depTask = await store.createTask({ description: "Prerequisite", column: "todo" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-4",
{ agent_id: agentId, description: "Dependent work", dependencies: [depTask.id] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain(depTask.id);
const task = await store.getTask(result.details.taskId);
expect(task!.dependencies).toEqual([depTask.id]);
});
});
describe("fn_agent_show", () => {
it("shows agent by ID", async () => {
const agentId = await seedAgent(tmpDir, { name: "show-agent" });
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-1", { id: agentId }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("show-agent");
expect(result.content[0].text).toContain(agentId);
expect(result.details.agent.id).toBe(agentId);
});
it("shows agent by name", async () => {
await seedAgent(tmpDir, { name: "resolve-by-name" });
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-2", { id: "resolve-by-name" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("resolve-by-name");
expect(result.details.agent.name).toBe("resolve-by-name");
});
it("returns error for unknown agent", async () => {
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-3", { id: "no-such-agent" }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
it("shows reports-to and direct reports", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const manager = await agentStore.createAgent({ name: "the-manager", role: "executor", metadata: {} });
const report = await agentStore.createAgent({
name: "the-report",
role: "executor",
reportsTo: manager.id,
metadata: {},
});
const tool = api.tools.get("fn_agent_show")!;
// Check manager sees direct reports
const mgrResult = await tool.execute("as-4a", { id: manager.id }, undefined, undefined, makeCtx(tmpDir));
expect(mgrResult.content[0].text).toContain("the-report");
expect(mgrResult.details.directReports.length).toBeGreaterThan(0);
// Check report sees reports-to
const rptResult = await tool.execute("as-4b", { id: report.id }, undefined, undefined, makeCtx(tmpDir));
expect(rptResult.content[0].text).toContain("the-manager");
});
});
describe("fn_agent_org_chart", () => {
it("returns full tree", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
await agentStore.createAgent({ name: "ceo", role: "executor", metadata: {} });
await agentStore.createAgent({ name: "worker", role: "executor", metadata: {} });
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("ceo");
expect(result.content[0].text).toContain("worker");
expect(result.details.count).toBeGreaterThanOrEqual(2);
});
it("returns subtree by root agent", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const manager = await agentStore.createAgent({ name: "org-manager", role: "executor", metadata: {} });
await agentStore.createAgent({ name: "org-report", role: "executor", reportsTo: manager.id, metadata: {} });
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-2", { root_agent_id: manager.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("org-manager");
expect(result.content[0].text).toContain("org-report");
});
it("returns empty message when no agents", async () => {
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-3", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("No agents found");
expect(result.details.count).toBe(0);
});
it("returns single agent for lone agent", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const lone = await agentStore.createAgent({ name: "lone-agent", role: "executor", metadata: {} });
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-4", { root_agent_id: lone.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("lone-agent");
});
});
});

View File

@@ -2143,6 +2143,345 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── fn_list_agents ───────────────────────────────────────────────
pi.registerTool({
name: "fn_list_agents",
label: "fn: 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.",
promptSnippet: "List all available Fusion agents",
promptGuidelines: [
"Use fn_list_agents to discover which agents exist before delegating work",
"Filter by role or state to narrow results",
"Ephemeral/runtime agents are excluded by default",
],
parameters: 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)" }),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const filter: Record<string, unknown> = {};
if (params.role) filter.role = params.role;
if (params.state) filter.state = params.state;
if (params.includeEphemeral !== undefined) filter.includeEphemeral = params.includeEphemeral;
const agents = await agentStore.listAgents(filter as Parameters<typeof agentStore.listAgents>[0]);
if (agents.length === 0) {
return {
content: [{ type: "text" as const, text: "No agents found matching the specified filters." }],
details: { agents: [], count: 0 },
};
}
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 (${agents.length}):\n\n${lines.join("\n\n")}` }],
details: { agents, count: agents.length },
};
},
});
// ── fn_delegate_task ──────────────────────────────────────────────
pi.registerTool({
name: "fn_delegate_task",
label: "fn: 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 fn_list_agents first to find available agents and their capabilities.",
promptSnippet: "Delegate a task to a specific Fusion agent",
promptGuidelines: [
"Use fn_list_agents first to find available agents and their capabilities",
"The task is created in 'todo' and assigned to the target agent",
"Cannot delegate to ephemeral/runtime agents",
"Optionally specify dependencies on other tasks",
],
parameters: 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\"]" }),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Validate target agent exists and is not ephemeral
const agentError = await validateAssignableAgentId(ctx.cwd, params.agent_id);
if (agentError) {
return {
content: [{ type: "text", text: `ERROR: ${agentError}` }],
isError: true,
details: { error: agentError },
};
}
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.agent_id);
// Create task assigned to the target agent
const store = await getStore(ctx.cwd);
const task = await store.createTask({
description: params.description,
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
source: { sourceType: "api" },
});
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 },
};
},
});
// ── fn_agent_show ─────────────────────────────────────────────────
pi.registerTool({
name: "fn_agent_show",
label: "fn: Show Agent",
description:
"Show detailed information about a single agent, including their role, state, " +
"position in the org hierarchy (reports-to, direct reports), skills, and current assignment.",
promptSnippet: "Show details of a specific Fusion agent",
promptGuidelines: [
"Use to get full details about a specific agent",
"Provide agent ID or a resolvable name",
"Shows the agent's position in the org hierarchy",
],
parameters: Type.Object({
id: Type.String({ description: "Agent ID or resolvable name" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.resolveAgent(params.id);
if (!agent) {
return {
content: [{ type: "text", text: `Agent '${params.id}' not found` }],
isError: true,
details: { error: "Agent not found" },
};
}
// Get direct reports
const directReports = await agentStore.getAgentsByReportsTo(agent.id);
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.icon) parts.push(`Icon: ${agent.icon}`);
if (agent.reportsTo) {
const manager = await agentStore.getAgent(agent.reportsTo);
if (manager) {
parts.push(`Reports To: ${manager.name} (${manager.id})`);
} else {
parts.push(`Reports To: ${agent.reportsTo}`);
}
}
if (directReports.length > 0) {
parts.push(`Direct Reports: ${directReports.map((r) => `${r.name} (${r.id})`).join(", ")}`);
}
if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`);
if (agent.instructionsText) {
const snippet = agent.instructionsText.slice(0, 100);
parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`);
}
if (agent.soul) {
const snippet = agent.soul.slice(0, 200);
parts.push(`Soul: ${snippet}${agent.soul.length > 200 ? "…" : ""}`);
}
if (agent.metadata?.skills) {
parts.push(`Skills: ${JSON.stringify(agent.metadata.skills)}`);
}
return {
content: [{ type: "text" as const, text: parts.join("\n") }],
details: {
agent,
directReports: directReports.map((r) => ({ id: r.id, name: r.name, role: r.role })),
},
};
},
});
// ── fn_agent_org_chart ────────────────────────────────────────────
pi.registerTool({
name: "fn_agent_org_chart",
label: "fn: Agent Org Chart",
description:
"Show the organizational tree of agents, displaying the role hierarchy. " +
"Optionally filter to a subtree rooted at a specific agent.",
promptSnippet: "Show the Fusion agent org chart",
promptGuidelines: [
"Use to understand the team structure and reporting hierarchy",
"Optionally specify a root agent to see only their subtree",
"Ephemeral/runtime agents are excluded by default",
],
parameters: Type.Object({
root_agent_id: Type.Optional(
Type.String({ description: "If provided, show only the subtree rooted at this agent" }),
),
include_ephemeral: Type.Optional(
Type.Boolean({ description: "Include ephemeral/runtime agents (default: false)" }),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
type OrgTreeNode = { agent: { id: string; icon?: string; name: string; role: string; state: string; taskId?: string }; children: OrgTreeNode[] };
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const includeEphemeral = params.include_ephemeral ?? false;
// If root_agent_id specified, show subtree via chain-of-command + reports
if (params.root_agent_id) {
const rootAgent = await agentStore.resolveAgent(params.root_agent_id);
if (!rootAgent) {
return {
content: [{ type: "text", text: `Agent '${params.root_agent_id}' not found` }],
isError: true,
details: { error: "Root agent not found" },
};
}
// Get the full tree, then find the subtree
const fullTree = await agentStore.getOrgTree({ includeEphemeral });
// Find the subtree rooted at the specified agent
const findSubtree = (nodes: OrgTreeNode[]): OrgTreeNode | null => {
for (const node of nodes) {
if (node.agent.id === rootAgent.id) return node;
const found = findSubtree(node.children);
if (found) return found;
}
return null;
};
const subtree = findSubtree(fullTree);
if (!subtree) {
// Agent exists but has no tree position — show just that agent
return {
content: [{
type: "text" as const,
text: `${rootAgent.icon ?? "🤖"} ${rootAgent.name} (${rootAgent.role}) — ${rootAgent.state}${rootAgent.taskId ? ` [${rootAgent.taskId}]` : ""}`,
}],
details: { tree: [{ agent: rootAgent, children: [] }] },
};
}
const lines: string[] = [];
const renderNode = (node: OrgTreeNode, indent: string) => {
const a = node.agent;
lines.push(
`${indent}${a.icon ?? "🤖"} ${a.name} (${a.role}) — ${a.state}${a.taskId ? ` [${a.taskId}]` : ""}`,
);
for (const child of node.children) {
renderNode(child, indent + " ");
}
};
renderNode(subtree, "");
return {
content: [{ type: "text" as const, text: `Agent Org Tree (subtree: ${rootAgent.name}):\n${lines.join("\n")}` }],
details: { tree: [subtree] },
};
}
// Full tree
const tree = await agentStore.getOrgTree({ includeEphemeral });
if (tree.length === 0) {
return {
content: [{ type: "text" as const, text: "No agents found." }],
details: { tree: [], count: 0 },
};
}
const lines: string[] = [];
let count = 0;
const renderNode = (node: OrgTreeNode, indent: string) => {
const a = node.agent;
lines.push(
`${indent}${a.icon ?? "🤖"} ${a.name} (${a.role}) — ${a.state}${a.taskId ? ` [${a.taskId}]` : ""}`,
);
count++;
for (const child of node.children) {
renderNode(child, indent + " ");
}
};
for (const root of tree) {
renderNode(root, "");
}
return {
content: [{ type: "text" as const, text: `Agent Org Tree (${count} agents):\n${lines.join("\n")}` }],
details: { tree, count },
};
},
});
// ── fn_skills_search ─────────────────────────────────────────────
pi.registerTool({