feat(FN-3777): add create_agent and delete_agent tools

Adds agent creation and deletion tools to the pi extension, with engine-side implementation in `agent-tools.ts`, core store integration for agent lifecycle management, and corresponding tests and documentation updates across the workspace.

Fusion-Task-Id: FN-3777
This commit is contained in:
Fusion
2026-05-08 19:02:59 -07:00
committed by gsxdsm
parent e04af96d9c
commit 3a91534f98
18 changed files with 352 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent, AgentStore } from "@fusion/core";
import { createGetAgentConfigTool, createUpdateAgentConfigTool } from "../agent-tools.js";
import { createAgentCreateTool, createAgentDeleteTool, createGetAgentConfigTool, createUpdateAgentConfigTool } from "../agent-tools.js";
function createAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date().toISOString();
@@ -19,7 +19,10 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
return {
getAgent: vi.fn().mockResolvedValue(null),
createAgent: vi.fn(),
deleteAgent: vi.fn(),
updateAgent: vi.fn(),
updateAgentState: vi.fn(),
...overrides,
} as unknown as AgentStore;
}
@@ -229,3 +232,44 @@ describe("createUpdateAgentConfigTool", () => {
expect(schema.properties.heartbeat_timeout_ms?.minimum).toBe(5000);
});
});
describe("agent lifecycle tools", () => {
it("create tool allows direct-report creation", async () => {
const manager = createAgent({ id: "manager-1", reportsTo: "ceo-1" });
const created = createAgent({ id: "report-1", reportsTo: "manager-1", name: "Report" });
const agentStore = createMockAgentStore();
vi.mocked(agentStore.getAgent).mockResolvedValue(manager);
vi.mocked(agentStore.createAgent).mockResolvedValue(created);
const tool = createAgentCreateTool(agentStore, "manager-1");
const result = await tool.execute("session", { name: "Report", role: "executor" }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("Created agent Report (report-1)");
expect(agentStore.createAgent).toHaveBeenCalledWith(expect.objectContaining({ reportsTo: "manager-1" }));
});
it("create tool blocks non-privileged cross-manager create", async () => {
const manager = createAgent({ id: "manager-1", reportsTo: "ceo-1" });
const agentStore = createMockAgentStore();
vi.mocked(agentStore.getAgent).mockResolvedValue(manager);
const tool = createAgentCreateTool(agentStore, "manager-1");
const result = await tool.execute("session", { name: "Report", role: "executor", reportsTo: "other" }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("ERROR: You can only create agents that report to you");
expect(agentStore.createAgent).not.toHaveBeenCalled();
});
it("delete tool blocks non-direct report delete", async () => {
const manager = createAgent({ id: "manager-1", reportsTo: "ceo-1" });
const target = createAgent({ id: "report-1", reportsTo: "other" });
const agentStore = createMockAgentStore();
vi.mocked(agentStore.getAgent).mockResolvedValueOnce(manager).mockResolvedValueOnce(target);
const tool = createAgentDeleteTool(agentStore, "manager-1");
const result = await tool.execute("session", { agent_id: "report-1" }, undefined as never, undefined as never, undefined as never);
expect((result.content[0] as { text: string }).text).toContain("ERROR: You can only delete agents that report to you");
expect(agentStore.deleteAgent).not.toHaveBeenCalled();
});
});

View File

@@ -1919,9 +1919,9 @@ describe("executeHeartbeat", () => {
expect(callArgs.systemPrompt).toContain("fn_task_log");
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("coding");
// Tools: fn_task_create, fn_task_log, fn_task_document_write, fn_task_document_read, fn_list_agents, fn_delegate_task,
// fn_get_agent_config, fn_update_agent_config, fn_read_evaluations, fn_update_identity, fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(15);
// fn_get_agent_config, fn_update_agent_config, fn_agent_create, fn_agent_delete, fn_read_evaluations, fn_update_identity,
// fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(17);
expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
@@ -1930,14 +1930,16 @@ describe("executeHeartbeat", () => {
expect(callArgs.customTools![5]!.name).toBe("fn_delegate_task");
expect(callArgs.customTools![6]!.name).toBe("fn_get_agent_config");
expect(callArgs.customTools![7]!.name).toBe("fn_update_agent_config");
expect(callArgs.customTools![8]!.name).toBe("fn_read_evaluations");
expect(callArgs.customTools![9]!.name).toBe("fn_update_identity");
expect(callArgs.customTools![10]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![11]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![12]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![13]!.name).toBe("fn_memory_append");
expect(callArgs.customTools![8]!.name).toBe("fn_agent_create");
expect(callArgs.customTools![9]!.name).toBe("fn_agent_delete");
expect(callArgs.customTools![10]!.name).toBe("fn_read_evaluations");
expect(callArgs.customTools![11]!.name).toBe("fn_update_identity");
expect(callArgs.customTools![12]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![13]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![14]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![15]!.name).toBe("fn_memory_append");
// fn_heartbeat_done is last (terminal tool)
expect(callArgs.customTools![14]!.name).toBe("fn_heartbeat_done");
expect(callArgs.customTools![16]!.name).toBe("fn_heartbeat_done");
});
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {

View File

@@ -106,7 +106,7 @@ describe("createHeartbeatTools", () => {
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
expect(tools).toHaveLength(10);
expect(tools).toHaveLength(12);
expect(tools[0]!.name).toBe("fn_task_create");
expect(tools[1]!.name).toBe("fn_task_log");
expect(tools[2]!.name).toBe("fn_task_document_write");
@@ -115,8 +115,10 @@ describe("createHeartbeatTools", () => {
expect(tools[5]!.name).toBe("fn_delegate_task");
expect(tools[6]!.name).toBe("fn_get_agent_config");
expect(tools[7]!.name).toBe("fn_update_agent_config");
expect(tools[8]!.name).toBe("fn_read_evaluations");
expect(tools[9]!.name).toBe("fn_update_identity");
expect(tools[8]!.name).toBe("fn_agent_create");
expect(tools[9]!.name).toBe("fn_agent_delete");
expect(tools[10]!.name).toBe("fn_read_evaluations");
expect(tools[11]!.name).toBe("fn_update_identity");
});
it("fn_task_create tool creates a task in triage via TaskStore", async () => {

View File

@@ -22,7 +22,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createHash } from "node:crypto";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import {
resolveAgentInstructionsWithRatings,
@@ -1735,6 +1735,8 @@ export class HeartbeatMonitor {
heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir }));
heartbeatTools.push(createGetAgentConfigTool(this.store, agentId));
heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId));
heartbeatTools.push(createAgentCreateTool(this.store, agentId));
heartbeatTools.push(createAgentDeleteTool(this.store, agentId));
// Messaging tools — when MessageStore is available
if (this.messageStore) {
@@ -2406,6 +2408,8 @@ export class HeartbeatMonitor {
tools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir }));
tools.push(createGetAgentConfigTool(this.store, agentId));
tools.push(createUpdateAgentConfigTool(this.store, agentId));
tools.push(createAgentCreateTool(this.store, agentId));
tools.push(createAgentDeleteTool(this.store, agentId));
// Messaging tools — when MessageStore is available, agents can send and receive messages
if (messageStore) {

View File

@@ -103,6 +103,35 @@ export const updateAgentConfigParams = Type.Object({
], { description: "How agent responds to messages" })),
});
export const createAgentParams = Type.Object({
name: Type.String({ description: "Name for the new agent" }),
role: Type.Union([
Type.Literal("triage"),
Type.Literal("executor"),
Type.Literal("reviewer"),
Type.Literal("merger"),
Type.Literal("engineer"),
Type.Literal("custom"),
], { description: "Agent role/capability" }),
soul: Type.Optional(Type.String({ description: "Agent personality/identity text", maxLength: 10000 })),
instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions", maxLength: 50000 })),
instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown file", maxLength: 500 })),
reportsTo: Type.Optional(Type.String({ description: "Manager agent ID. Defaults to the calling agent." })),
heartbeat_interval_ms: Type.Optional(Type.Number({ description: "Heartbeat polling interval in ms", minimum: 1000 })),
heartbeat_timeout_ms: Type.Optional(Type.Number({ description: "Heartbeat timeout in ms", minimum: 5000 })),
max_concurrent_runs: Type.Optional(Type.Number({ description: "Max concurrent heartbeat runs", minimum: 1 })),
message_response_mode: Type.Optional(Type.Union([
Type.Literal("immediate"),
Type.Literal("on-heartbeat"),
], { description: "How agent responds to messages" })),
});
export const deleteAgentParams = Type.Object({
agent_id: Type.String({ description: "Agent ID to delete" }),
force: Type.Optional(Type.Boolean({ description: "Force delete even if the agent currently holds a checkout lease" })),
reassign_to: Type.Optional(Type.String({ description: "Optional replacement agent ID for tasks currently assigned to the deleted agent" })),
});
export const sendMessageParams = Type.Object({
to_id: Type.String({ description: "Recipient ID (agent ID or user ID, depending on message type)" }),
content: Type.String({ description: "Message body (1-2000 characters)" }),
@@ -1322,6 +1351,11 @@ export function createGetAgentConfigTool(agentStore: AgentStore, callingAgentId:
};
}
function isCallerPrivileged(caller: { id: string; role: string; reportsTo?: string | null } | null): boolean {
if (!caller) return false;
return caller.role === "ceo" || caller.reportsTo == null;
}
export function createUpdateAgentConfigTool(agentStore: AgentStore, callingAgentId: string): ToolDefinition {
return {
name: "fn_update_agent_config",
@@ -1429,6 +1463,100 @@ export function createUpdateAgentConfigTool(agentStore: AgentStore, callingAgent
* @param taskStore - TaskStore for task creation
* @returns ToolDefinition for the `fn_delegate_task` tool
*/
export function createAgentCreateTool(
agentStore: AgentStore,
callingAgentId: string,
options?: { hireApprovalEnabled?: boolean },
): ToolDefinition {
return {
name: "fn_agent_create",
label: "Create Agent",
description: "Create a new non-ephemeral direct-report agent.",
parameters: createAgentParams,
execute: async (_id: string, params: Static<typeof createAgentParams>) => {
const caller = await agentStore.getAgent(callingAgentId);
const privileged = isCallerPrivileged(caller);
const reportsTo = params.reportsTo ?? callingAgentId;
if (!privileged && reportsTo !== callingAgentId) {
return {
content: [{ type: "text" as const, text: "ERROR: You can only create agents that report to you" }],
details: {},
};
}
const runtimeConfig: Record<string, unknown> = {
...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}),
...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}),
...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}),
};
const created = await agentStore.createAgent({
name: params.name,
role: params.role,
...(params.soul !== undefined ? { soul: params.soul } : {}),
...(params.instructions_text !== undefined ? { instructionsText: params.instructions_text } : {}),
...(params.instructions_path !== undefined ? { instructionsPath: params.instructions_path } : {}),
reportsTo,
...(Object.keys(runtimeConfig).length > 0 ? { runtimeConfig } : {}),
});
if (options?.hireApprovalEnabled) {
await agentStore.updateAgentState(created.id, "paused");
await agentStore.updateAgent(created.id, {
metadata: { ...(created.metadata ?? {}), pendingApproval: true },
});
}
return {
content: [{ type: "text" as const, text: `Created agent ${created.name} (${created.id})${options?.hireApprovalEnabled ? " in pending_approval" : ""}` }],
details: { agent: created, pendingApproval: options?.hireApprovalEnabled === true },
};
},
};
}
export function createAgentDeleteTool(agentStore: AgentStore, callingAgentId: string): ToolDefinition {
return {
name: "fn_agent_delete",
label: "Delete Agent",
description: "Delete one of your direct-report non-ephemeral agents.",
parameters: deleteAgentParams,
execute: async (_id: string, params: Static<typeof deleteAgentParams>) => {
const caller = await agentStore.getAgent(callingAgentId);
const target = await agentStore.getAgent(params.agent_id);
if (!target) {
return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: {} };
}
const privileged = isCallerPrivileged(caller);
if (!privileged && target.reportsTo !== callingAgentId) {
return {
content: [{ type: "text" as const, text: "ERROR: You can only delete agents that report to you" }],
details: {},
};
}
if (isEphemeralAgent(target)) {
return { content: [{ type: "text" as const, text: `ERROR: Cannot delete ephemeral/runtime agent ${params.agent_id}` }], details: {} };
}
try {
await agentStore.deleteAgent(params.agent_id, { force: params.force === true, reassignTo: params.reassign_to });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { content: [{ type: "text" as const, text: `ERROR: ${message}` }], details: {} };
}
return {
content: [{ type: "text" as const, text: `Deleted agent ${target.name} (${target.id})` }],
details: { agentId: target.id },
};
},
};
}
export function createDelegateTaskTool(
agentStore: AgentStore,
taskStore: TaskStore,

View File

@@ -57,6 +57,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 {
createAgentCreateTool,
createAgentDeleteTool,
createDelegateTaskTool,
createGetAgentConfigTool,
createListAgentsTool,
@@ -86,6 +88,8 @@ import type { AgentActionGateContext } from "./agent-action-gate.js";
// Re-export for backward compatibility (tests import from executor.ts)
export { summarizeToolArgs } from "./agent-logger.js";
export {
createAgentCreateTool,
createAgentDeleteTool,
createDelegateTaskTool,
createGetAgentConfigTool,
createListAgentsTool,
@@ -2954,6 +2958,8 @@ export class TaskExecutor {
...(assignedAgentId ? [
createGetAgentConfigTool(this.options.agentStore, assignedAgentId),
createUpdateAgentConfigTool(this.options.agentStore, assignedAgentId),
createAgentCreateTool(this.options.agentStore, assignedAgentId),
createAgentDeleteTool(this.options.agentStore, assignedAgentId),
] : []),
] : []),
// Messaging tools — allows executor agents to send and receive messages.

View File

@@ -4,7 +4,7 @@
export const READONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["read", "find", "grep", "ls"]);
export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["write", "edit"]);
const SHARED_TASK_AGENT_TOOLS = ["fn_task_add_dep", "fn_spawn_agent", "fn_update_agent_config"] as const;
const SHARED_TASK_AGENT_TOOLS = ["fn_task_add_dep", "fn_spawn_agent", "fn_update_agent_config", "fn_agent_create", "fn_agent_delete"] as const;
const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = ["fn_task_create", "fn_delegate_task", "fn_update_identity"] as const;
const PERMANENT_TASK_AGENT_ONLY_TOOLS = [