feat(FN-3320): add agent configuration tools for settings management
The merge completes FN-3320 by implementing configuration tools for the agent system (`agent-tools.ts`), wiring them through the executor, and adding test coverage (`agent-tools-config.test.ts`). Documentation was updated in `AGENTS.md` and the engine tools reference, with minor updates to the agent Fusion-Task-Id: FN-3320
This commit is contained in:
231
packages/engine/src/__tests__/agent-tools-config.test.ts
Normal file
231
packages/engine/src/__tests__/agent-tools-config.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, AgentStore } from "@fusion/core";
|
||||
import { createGetAgentConfigTool, createUpdateAgentConfigTool } from "../agent-tools.js";
|
||||
|
||||
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: "manager-1",
|
||||
name: "Manager",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
|
||||
return {
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
updateAgent: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
describe("createGetAgentConfigTool", () => {
|
||||
let agentStore: AgentStore;
|
||||
|
||||
beforeEach(() => {
|
||||
agentStore = createMockAgentStore();
|
||||
});
|
||||
|
||||
it("returns full configuration for a direct report", async () => {
|
||||
const report = createAgent({
|
||||
id: "report-1",
|
||||
reportsTo: "manager-1",
|
||||
soul: "Careful and concise",
|
||||
instructionsText: "Always verify with tests",
|
||||
instructionsPath: ".fusion/instructions/report.md",
|
||||
heartbeatProcedurePath: ".fusion/procedure.md",
|
||||
memory: "Knows release pipeline",
|
||||
runtimeConfig: {
|
||||
heartbeatIntervalMs: 30000,
|
||||
heartbeatTimeoutMs: 120000,
|
||||
maxConcurrentRuns: 2,
|
||||
messageResponseMode: "immediate",
|
||||
budget: { dailyLimitUsd: 10 },
|
||||
},
|
||||
});
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(report);
|
||||
|
||||
const tool = createGetAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "report-1" }, undefined as never, undefined as never, undefined as never);
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
|
||||
expect(text).toContain("Agent Config: Manager (report-1)");
|
||||
expect(text).toContain("Soul:\nCareful and concise");
|
||||
expect(text).toContain("Instructions Text:\nAlways verify with tests");
|
||||
expect(text).toContain("heartbeatIntervalMs: 30000");
|
||||
expect(text).toContain("heartbeatTimeoutMs: 120000");
|
||||
expect(text).toContain("maxConcurrentRuns: 2");
|
||||
expect(text).toContain("messageResponseMode: immediate");
|
||||
expect(text).toContain("Memory:\nKnows release pipeline");
|
||||
expect(result.details).toEqual({ agent: report });
|
||||
});
|
||||
|
||||
it("returns error when target agent not found", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(null);
|
||||
const tool = createGetAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "missing" }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: Agent missing not found");
|
||||
});
|
||||
|
||||
it("returns error when target is not a direct report", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(createAgent({ id: "other", reportsTo: "another-manager" }));
|
||||
const tool = createGetAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "other" }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: You can only read configuration of agents that report to you");
|
||||
});
|
||||
|
||||
it("returns error when target is the calling agent itself", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(createAgent({ id: "manager-1" }));
|
||||
const tool = createGetAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "manager-1" }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: You can only read configuration of agents that report to you");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUpdateAgentConfigTool", () => {
|
||||
let agentStore: AgentStore;
|
||||
|
||||
beforeEach(() => {
|
||||
agentStore = createMockAgentStore();
|
||||
});
|
||||
|
||||
it("successfully updates soul on a direct report", async () => {
|
||||
const report = createAgent({ id: "report-1", reportsTo: "manager-1" });
|
||||
const updated = createAgent({ id: "report-1", reportsTo: "manager-1", soul: "New soul" });
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(report);
|
||||
vi.mocked(agentStore.updateAgent).mockResolvedValue(updated);
|
||||
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
await tool.execute("session", { agent_id: "report-1", soul: "New soul" }, undefined as never, undefined as never, undefined as never);
|
||||
|
||||
expect(agentStore.updateAgent).toHaveBeenCalledWith("report-1", { soul: "New soul" });
|
||||
});
|
||||
|
||||
it("successfully updates instructionsText on a direct report", async () => {
|
||||
const report = createAgent({ id: "report-1", reportsTo: "manager-1" });
|
||||
const updated = createAgent({ id: "report-1", reportsTo: "manager-1", instructionsText: "Do X" });
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(report);
|
||||
vi.mocked(agentStore.updateAgent).mockResolvedValue(updated);
|
||||
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
await tool.execute("session", { agent_id: "report-1", instructions_text: "Do X" }, undefined as never, undefined as never, undefined as never);
|
||||
|
||||
expect(agentStore.updateAgent).toHaveBeenCalledWith("report-1", { instructionsText: "Do X" });
|
||||
});
|
||||
|
||||
it("successfully updates heartbeat interval by merging runtimeConfig", async () => {
|
||||
const report = createAgent({ id: "report-1", reportsTo: "manager-1", runtimeConfig: { custom: true } });
|
||||
const updated = createAgent({ id: "report-1", reportsTo: "manager-1", runtimeConfig: { custom: true, heartbeatIntervalMs: 2000 } });
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(report);
|
||||
vi.mocked(agentStore.updateAgent).mockResolvedValue(updated);
|
||||
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
await tool.execute("session", { agent_id: "report-1", heartbeat_interval_ms: 2000 }, undefined as never, undefined as never, undefined as never);
|
||||
|
||||
expect(agentStore.updateAgent).toHaveBeenCalledWith("report-1", {
|
||||
runtimeConfig: { custom: true, heartbeatIntervalMs: 2000 },
|
||||
});
|
||||
});
|
||||
|
||||
it("successfully updates multiple fields at once", async () => {
|
||||
const report = createAgent({ id: "report-1", reportsTo: "manager-1" });
|
||||
const updated = createAgent({ id: "report-1", reportsTo: "manager-1" });
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(report);
|
||||
vi.mocked(agentStore.updateAgent).mockResolvedValue(updated);
|
||||
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
await tool.execute("session", {
|
||||
agent_id: "report-1",
|
||||
soul: "A",
|
||||
instructions_text: "B",
|
||||
heartbeat_timeout_ms: 9000,
|
||||
message_response_mode: "on-heartbeat",
|
||||
}, undefined as never, undefined as never, undefined as never);
|
||||
|
||||
expect(agentStore.updateAgent).toHaveBeenCalledWith("report-1", {
|
||||
soul: "A",
|
||||
instructionsText: "B",
|
||||
runtimeConfig: {
|
||||
heartbeatTimeoutMs: 9000,
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing runtimeConfig keys when updating heartbeat fields", async () => {
|
||||
const report = createAgent({ id: "report-1", reportsTo: "manager-1", runtimeConfig: { budget: { cap: 1 }, existing: "keep" } });
|
||||
const updated = createAgent({ id: "report-1", reportsTo: "manager-1" });
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(report);
|
||||
vi.mocked(agentStore.updateAgent).mockResolvedValue(updated);
|
||||
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
await tool.execute("session", { agent_id: "report-1", max_concurrent_runs: 3 }, undefined as never, undefined as never, undefined as never);
|
||||
|
||||
expect(agentStore.updateAgent).toHaveBeenCalledWith("report-1", {
|
||||
runtimeConfig: { budget: { cap: 1 }, existing: "keep", maxConcurrentRuns: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when target agent not found", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(null);
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "missing", soul: "x" }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: Agent missing not found");
|
||||
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error when target is not a direct report", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(createAgent({ id: "other", reportsTo: "different" }));
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "other", soul: "x" }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: You can only update configuration of agents that report to you");
|
||||
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error when target is ephemeral", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(createAgent({ id: "ephemeral", reportsTo: "manager-1", metadata: { agentKind: "task-worker" } }));
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "ephemeral", soul: "x" }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: Cannot update ephemeral/runtime agent ephemeral");
|
||||
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error when no fields provided to update", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(createAgent({ id: "report-1", reportsTo: "manager-1" }));
|
||||
const tool = createUpdateAgentConfigTool(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: Provide at least one field to update");
|
||||
expect(agentStore.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates soul max length", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(createAgent({ id: "report-1", reportsTo: "manager-1" }));
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "report-1", soul: "x".repeat(10001) }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: soul exceeds 10000 character limit");
|
||||
});
|
||||
|
||||
it("validates instructionsText max length", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValue(createAgent({ id: "report-1", reportsTo: "manager-1" }));
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
const result = await tool.execute("session", { agent_id: "report-1", instructions_text: "x".repeat(50001) }, undefined as never, undefined as never, undefined as never);
|
||||
expect((result.content[0] as { text: string }).text).toContain("ERROR: instructions_text exceeds 50000 character limit");
|
||||
});
|
||||
|
||||
it("validates heartbeatIntervalMs minimum", async () => {
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
const schema = tool.parameters as { properties: Record<string, { minimum?: number }> };
|
||||
expect(schema.properties.heartbeat_interval_ms?.minimum).toBe(1000);
|
||||
});
|
||||
|
||||
it("validates heartbeatTimeoutMs minimum", async () => {
|
||||
const tool = createUpdateAgentConfigTool(agentStore, "manager-1");
|
||||
const schema = tool.parameters as { properties: Record<string, { minimum?: number }> };
|
||||
expect(schema.properties.heartbeat_timeout_ms?.minimum).toBe(5000);
|
||||
});
|
||||
});
|
||||
@@ -1714,19 +1714,21 @@ describe("executeHeartbeat", () => {
|
||||
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
|
||||
expect(callArgs.tools).toBe("readonly");
|
||||
// Tools: fn_task_create, fn_task_log, fn_task_document_write, fn_task_document_read, fn_list_agents, fn_delegate_task,
|
||||
// fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
|
||||
expect(callArgs.customTools).toHaveLength(10);
|
||||
// fn_get_agent_config, fn_update_agent_config, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
|
||||
expect(callArgs.customTools).toHaveLength(12);
|
||||
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");
|
||||
expect(callArgs.customTools![3]!.name).toBe("fn_task_document_read");
|
||||
expect(callArgs.customTools![4]!.name).toBe("fn_list_agents");
|
||||
expect(callArgs.customTools![5]!.name).toBe("fn_delegate_task");
|
||||
expect(callArgs.customTools![6]!.name).toBe("fn_memory_search");
|
||||
expect(callArgs.customTools![7]!.name).toBe("fn_memory_get");
|
||||
expect(callArgs.customTools![8]!.name).toBe("fn_memory_append");
|
||||
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_memory_search");
|
||||
expect(callArgs.customTools![9]!.name).toBe("fn_memory_get");
|
||||
expect(callArgs.customTools![10]!.name).toBe("fn_memory_append");
|
||||
// fn_heartbeat_done is last (terminal tool)
|
||||
expect(callArgs.customTools![9]!.name).toBe("fn_heartbeat_done");
|
||||
expect(callArgs.customTools![11]!.name).toBe("fn_heartbeat_done");
|
||||
});
|
||||
|
||||
it("includes memory instructions even when agent has no custom instructions", async () => {
|
||||
|
||||
@@ -88,19 +88,21 @@ describe("createHeartbeatTools", () => {
|
||||
mockTaskStore = createMockTaskStoreForTools();
|
||||
});
|
||||
|
||||
it("returns fn_task_create, fn_task_log, fn_task_document_write, and fn_task_document_read tools", () => {
|
||||
it("returns task, delegation, and agent-config tools", () => {
|
||||
const store = createMockStore();
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
|
||||
|
||||
expect(tools).toHaveLength(6);
|
||||
expect(tools).toHaveLength(8);
|
||||
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");
|
||||
expect(tools[3]!.name).toBe("fn_task_document_read");
|
||||
expect(tools[4]!.name).toBe("fn_list_agents");
|
||||
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");
|
||||
});
|
||||
|
||||
it("fn_task_create tool creates a task in triage via TaskStore", async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity }
|
||||
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, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions, resolveAgentHeartbeatProcedure } from "./agent-instructions.js";
|
||||
import { heartbeatLog, formatError } from "./logger.js";
|
||||
@@ -150,6 +150,7 @@ Your job:
|
||||
2. Do ONE useful action that changes project clarity or flow.
|
||||
3. Use fn_task_create to spawn follow-up work, fn_task_log to record observations, and fn_task_document_write for durable artifacts.
|
||||
4. Use fn_list_agents + fn_delegate_task when work should be assigned to a specific capable agent now.
|
||||
5. Use fn_get_agent_config and fn_update_agent_config to tune direct reports before delegating recurring work.
|
||||
5. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
|
||||
|
||||
Examples of ONE useful action:
|
||||
@@ -173,6 +174,7 @@ Use this decision rule:
|
||||
- **Task document (fn_task_document_write):** when findings are structured and likely useful across future sessions for the same task.
|
||||
- **Create task (fn_task_create):** when someone must do new executable work.
|
||||
- **Delegate task (fn_delegate_task):** when that new work should go to a specific agent based on role/availability.
|
||||
- **Manage report config (fn_get_agent_config / fn_update_agent_config):** when direct reports need heartbeat, instruction, or personality tuning.
|
||||
|
||||
Prefer fn_task_create when assignment is unclear and scheduler routing is fine.
|
||||
Prefer fn_delegate_task when immediate ownership by a specific agent materially reduces latency or risk.
|
||||
@@ -231,6 +233,7 @@ Your job:
|
||||
2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory.
|
||||
3. Use fn_task_create to spawn follow-up work.
|
||||
4. Use fn_list_agents and fn_delegate_task to coordinate with other agents.
|
||||
5. Use fn_get_agent_config and fn_update_agent_config to read/tune direct-report agents for better routing outcomes.
|
||||
5. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
|
||||
|
||||
Examples of ONE useful action:
|
||||
@@ -244,6 +247,7 @@ Keep work lightweight — this is a single-pass ambient check, not a full implem
|
||||
You have readonly file access plus:
|
||||
- fn_task_create
|
||||
- fn_list_agents and fn_delegate_task
|
||||
- fn_get_agent_config and fn_update_agent_config (for direct reports only)
|
||||
- fn_memory_search, fn_memory_get, and fn_memory_append
|
||||
- fn_heartbeat_done
|
||||
- fn_send_message and fn_read_messages when messaging is enabled for this run (they may not always be available)
|
||||
@@ -1351,7 +1355,7 @@ export class HeartbeatMonitor {
|
||||
// For no-task runs, exclude fn_task_log and document tools (they require a taskId)
|
||||
let heartbeatTools: ToolDefinition[];
|
||||
if (isNoTaskRun) {
|
||||
// No-task runs: fn_task_create, fn_list_agents, fn_delegate_task, messaging, memory, fn_heartbeat_done
|
||||
// No-task runs: fn_task_create, fn_list_agents, fn_delegate_task, fn_get_agent_config, fn_update_agent_config, messaging, memory, fn_heartbeat_done
|
||||
heartbeatTools = [];
|
||||
|
||||
// fn_task_create tool
|
||||
@@ -1364,6 +1368,8 @@ export class HeartbeatMonitor {
|
||||
// Agent delegation tools
|
||||
heartbeatTools.push(createListAgentsTool(this.store));
|
||||
heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir }));
|
||||
heartbeatTools.push(createGetAgentConfigTool(this.store, agentId));
|
||||
heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId));
|
||||
|
||||
// Messaging tools — when MessageStore is available
|
||||
if (this.messageStore) {
|
||||
@@ -1891,6 +1897,8 @@ export class HeartbeatMonitor {
|
||||
// Agent delegation tools — discover and delegate work to other agents
|
||||
tools.push(createListAgentsTool(this.store));
|
||||
tools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir }));
|
||||
tools.push(createGetAgentConfigTool(this.store, agentId));
|
||||
tools.push(createUpdateAgentConfigTool(this.store, agentId));
|
||||
|
||||
// Messaging tools — when MessageStore is available, agents can send and receive messages
|
||||
if (messageStore) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput } from "@fusion/core";
|
||||
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput } from "@fusion/core";
|
||||
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
|
||||
import { ResearchOrchestrator } from "./research-orchestrator.js";
|
||||
import { ResearchProviderRegistry } from "./research/provider-registry.js";
|
||||
@@ -75,6 +75,25 @@ export const delegateTaskParams = Type.Object({
|
||||
),
|
||||
});
|
||||
|
||||
export const getAgentConfigParams = Type.Object({
|
||||
agent_id: Type.String({ description: "The agent ID to read configuration for" }),
|
||||
});
|
||||
|
||||
export const updateAgentConfigParams = Type.Object({
|
||||
agent_id: Type.String({ description: "The agent ID to update" }),
|
||||
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 })),
|
||||
heartbeat_procedure_path: Type.Optional(Type.String({ description: "Path to heartbeat procedure markdown file", maxLength: 500 })),
|
||||
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 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)" }),
|
||||
@@ -981,6 +1000,170 @@ export function createListAgentsTool(agentStore: AgentStore): ToolDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_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 `fn_delegate_task` tool
|
||||
*/
|
||||
export function createGetAgentConfigTool(agentStore: AgentStore, callingAgentId: string): ToolDefinition {
|
||||
return {
|
||||
name: "fn_get_agent_config",
|
||||
label: "Get Agent Config",
|
||||
description: "Read full configuration for one of your direct-report agents.",
|
||||
parameters: getAgentConfigParams,
|
||||
execute: async (_id: string, params: Static<typeof getAgentConfigParams>) => {
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (target.reportsTo !== callingAgentId) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: You can only read configuration of agents that report to you" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeConfig = (target.runtimeConfig ?? {}) as Record<string, unknown>;
|
||||
const lines: string[] = [
|
||||
`Agent Config: ${target.name} (${target.id})`,
|
||||
`Role: ${target.role}`,
|
||||
`State: ${target.state}`,
|
||||
`Title: ${target.title ?? "(none)"}`,
|
||||
`Icon: ${target.icon ?? "(none)"}`,
|
||||
"",
|
||||
"Soul:",
|
||||
target.soul ?? "(none)",
|
||||
"",
|
||||
"Instructions Text:",
|
||||
target.instructionsText ?? "(none)",
|
||||
`Instructions Path: ${target.instructionsPath ?? "(none)"}`,
|
||||
`Heartbeat Procedure Path: ${target.heartbeatProcedurePath ?? "(none)"}`,
|
||||
"",
|
||||
"Runtime Config:",
|
||||
`heartbeatIntervalMs: ${String(runtimeConfig.heartbeatIntervalMs ?? "(default)")}`,
|
||||
`heartbeatTimeoutMs: ${String(runtimeConfig.heartbeatTimeoutMs ?? "(default)")}`,
|
||||
`maxConcurrentRuns: ${String(runtimeConfig.maxConcurrentRuns ?? "(default)")}`,
|
||||
`messageResponseMode: ${String(runtimeConfig.messageResponseMode ?? "(default)")}`,
|
||||
`budget: ${JSON.stringify(runtimeConfig.budget ?? null)}`,
|
||||
"",
|
||||
"Memory:",
|
||||
target.memory ?? "(none)",
|
||||
];
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: lines.join("\n") }],
|
||||
details: { agent: target },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateAgentConfigTool(agentStore: AgentStore, callingAgentId: string): ToolDefinition {
|
||||
return {
|
||||
name: "fn_update_agent_config",
|
||||
label: "Update Agent Config",
|
||||
description: "Update configuration for one of your direct-report agents.",
|
||||
parameters: updateAgentConfigParams,
|
||||
execute: async (_id: string, params: Static<typeof updateAgentConfigParams>) => {
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (target.reportsTo !== callingAgentId) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: You can only update configuration of agents that report to you" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (isEphemeralAgent(target)) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Cannot update ephemeral/runtime agent ${params.agent_id}` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (params.soul && params.soul.length > 10000) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: soul exceeds 10000 character limit" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
if (params.instructions_text && params.instructions_text.length > 50000) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: instructions_text exceeds 50000 character limit" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
if (params.instructions_path && params.instructions_path.length > 500) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: instructions_path exceeds 500 character limit" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
if (params.heartbeat_procedure_path && params.heartbeat_procedure_path.length > 500) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: heartbeat_procedure_path exceeds 500 character limit" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const hasRuntimeConfigUpdates = [
|
||||
params.heartbeat_interval_ms,
|
||||
params.heartbeat_timeout_ms,
|
||||
params.max_concurrent_runs,
|
||||
params.message_response_mode,
|
||||
].some((value) => value !== undefined);
|
||||
|
||||
const updateInput: AgentUpdateInput = {};
|
||||
if (params.soul !== undefined) updateInput.soul = params.soul;
|
||||
if (params.instructions_text !== undefined) updateInput.instructionsText = params.instructions_text;
|
||||
if (params.instructions_path !== undefined) updateInput.instructionsPath = params.instructions_path;
|
||||
if (params.heartbeat_procedure_path !== undefined) updateInput.heartbeatProcedurePath = params.heartbeat_procedure_path;
|
||||
if (hasRuntimeConfigUpdates) {
|
||||
updateInput.runtimeConfig = {
|
||||
...((target.runtimeConfig ?? {}) as 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(updateInput).length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: Provide at least one field to update" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await agentStore.updateAgent(params.agent_id, updateInput);
|
||||
const updatedRuntimeConfig = (updated.runtimeConfig ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Updated ${updated.name} (${updated.id})\n` +
|
||||
`heartbeatIntervalMs: ${String(updatedRuntimeConfig.heartbeatIntervalMs ?? "(default)")}\n` +
|
||||
`heartbeatTimeoutMs: ${String(updatedRuntimeConfig.heartbeatTimeoutMs ?? "(default)")}\n` +
|
||||
`maxConcurrentRuns: ${String(updatedRuntimeConfig.maxConcurrentRuns ?? "(default)")}\n` +
|
||||
`messageResponseMode: ${String(updatedRuntimeConfig.messageResponseMode ?? "(default)")}`,
|
||||
}],
|
||||
details: { agent: updated },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_delegate_task` tool that creates and assigns a task to a specific agent.
|
||||
*
|
||||
|
||||
@@ -47,10 +47,12 @@ import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from
|
||||
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
|
||||
import {
|
||||
createDelegateTaskTool,
|
||||
createGetAgentConfigTool,
|
||||
createListAgentsTool,
|
||||
createMemoryTools,
|
||||
createReadMessagesTool,
|
||||
createReflectOnPerformanceTool,
|
||||
createUpdateAgentConfigTool,
|
||||
createResearchTools,
|
||||
createSendMessageTool,
|
||||
createTaskCreateTool as sharedCreateTaskCreateTool,
|
||||
@@ -67,8 +69,10 @@ import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
export { summarizeToolArgs } from "./agent-logger.js";
|
||||
export {
|
||||
createDelegateTaskTool,
|
||||
createGetAgentConfigTool,
|
||||
createListAgentsTool,
|
||||
createReadMessagesTool,
|
||||
createUpdateAgentConfigTool,
|
||||
createSendMessageTool,
|
||||
createTaskCreateTool,
|
||||
createTaskDocumentReadTool,
|
||||
@@ -2766,6 +2770,10 @@ export class TaskExecutor {
|
||||
...(this.options.agentStore ? [
|
||||
createListAgentsTool(this.options.agentStore),
|
||||
createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }),
|
||||
...(assignedAgentId ? [
|
||||
createGetAgentConfigTool(this.options.agentStore, assignedAgentId),
|
||||
createUpdateAgentConfigTool(this.options.agentStore, assignedAgentId),
|
||||
] : []),
|
||||
] : []),
|
||||
// Messaging tools — allows executor agents to send and receive messages.
|
||||
...(this.options.messageStore && assignedAgentId ? [
|
||||
|
||||
Reference in New Issue
Block a user