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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user