feat(FN-3321): add agent self-improvement service and heartbeat evaluation
This merge delivers agent self-improvement (FN-3321) — adding evaluation identity tools, wiring evaluation into the heartbeat loop, implementing a self-improvement service, and providing test coverage. It also expands the dashboard guide view with regression tests, adds org chart full-view mode with Fusion-Task-Id: FN-3321
This commit is contained in:
271
packages/engine/src/__tests__/agent-self-improve.test.ts
Normal file
271
packages/engine/src/__tests__/agent-self-improve.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { AgentStore, ReflectionStore, TaskStore, Agent, AgentRatingSummary, AgentRating } from "@fusion/core";
|
||||
import { createReadEvaluationsTool, createUpdateIdentityTool } from "../agent-tools.js";
|
||||
import { MAX_INSTRUCTIONS_TEXT_LENGTH, MAX_MEMORY_LENGTH, MAX_SOUL_LENGTH } from "../agent-instructions.js";
|
||||
import { AgentSelfImproveService } from "../agent-self-improve.js";
|
||||
import { HeartbeatMonitor } from "../agent-heartbeat.js";
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
createLogger: () => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
heartbeatLog: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
formatError: (err: unknown) => ({ detail: err instanceof Error ? err.message : String(err) }),
|
||||
}));
|
||||
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<void> }, prompt: string) => {
|
||||
await session.prompt(prompt);
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createFnAgent } from "../pi.js";
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
|
||||
function makeSummary(partial: Partial<AgentRatingSummary> = {}): AgentRatingSummary {
|
||||
return {
|
||||
agentId: "agent-1",
|
||||
averageScore: 4.25,
|
||||
totalRatings: 2,
|
||||
categoryAverages: { quality: 4.5 },
|
||||
recentRatings: [],
|
||||
trend: "improving",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRating(partial: Partial<AgentRating> = {}): AgentRating {
|
||||
return {
|
||||
id: "r-1",
|
||||
agentId: "agent-1",
|
||||
raterType: "user",
|
||||
score: 4,
|
||||
comment: "Good progress",
|
||||
createdAt: new Date().toISOString(),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createReadEvaluationsTool", () => {
|
||||
it("returns formatted data with ratings and reflections", async () => {
|
||||
const agentStore = {
|
||||
getRatingSummary: vi.fn().mockResolvedValue(makeSummary()),
|
||||
getRatings: vi.fn().mockResolvedValue([makeRating(), makeRating({ id: "r-2", score: 5, comment: "Great" })]),
|
||||
} as unknown as AgentStore;
|
||||
const reflectionStore = {
|
||||
getLatestReflection: vi.fn().mockResolvedValue({
|
||||
summary: "Keep adding tests",
|
||||
insights: ["Some regressions came from missing coverage"],
|
||||
suggestedImprovements: ["Run focused tests before commit"],
|
||||
}),
|
||||
getReflections: vi.fn().mockResolvedValue([
|
||||
{ createdAt: "2026-05-01T00:00:00.000Z", summary: "Prioritize review" },
|
||||
]),
|
||||
} as unknown as ReflectionStore;
|
||||
|
||||
const tool = createReadEvaluationsTool(agentStore, reflectionStore, "agent-1");
|
||||
const result = await tool.execute("1", {}, undefined as any, undefined as any, undefined as any);
|
||||
const text = (result.content[0] as any).text;
|
||||
|
||||
expect(text).toContain("Evaluation Summary");
|
||||
expect(text).toContain("Average score: 4.25");
|
||||
expect(text).toContain("Trend: improving");
|
||||
expect(text).toContain("Category averages");
|
||||
expect(text).toContain("Recent rating comments");
|
||||
expect(text).toContain("Latest reflection");
|
||||
expect(text).toContain("Recent reflection history");
|
||||
});
|
||||
|
||||
it("returns ratings-only data when no reflection store is provided", async () => {
|
||||
const agentStore = {
|
||||
getRatingSummary: vi.fn().mockResolvedValue(makeSummary()),
|
||||
getRatings: vi.fn().mockResolvedValue([makeRating()]),
|
||||
} as unknown as AgentStore;
|
||||
const tool = createReadEvaluationsTool(agentStore, undefined, "agent-1");
|
||||
const result = await tool.execute("1", {}, undefined as any, undefined as any, undefined as any);
|
||||
const text = (result.content[0] as any).text;
|
||||
expect(text).toContain("Evaluation Summary");
|
||||
expect(text).not.toContain("Latest reflection");
|
||||
});
|
||||
|
||||
it("returns no-data message", async () => {
|
||||
const agentStore = {
|
||||
getRatingSummary: vi.fn().mockResolvedValue(makeSummary({ totalRatings: 0, averageScore: 0, categoryAverages: {}, trend: "insufficient-data" })),
|
||||
getRatings: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as AgentStore;
|
||||
const tool = createReadEvaluationsTool(agentStore, undefined, "agent-1");
|
||||
const result = await tool.execute("1", {}, undefined as any, undefined as any, undefined as any);
|
||||
expect((result.content[0] as any).text).toContain("No evaluation data available yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUpdateIdentityTool", () => {
|
||||
it("updates provided fields and returns previews", async () => {
|
||||
const agentStore = {
|
||||
updateAgent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as AgentStore;
|
||||
const tool = createUpdateIdentityTool(agentStore, "agent-1");
|
||||
|
||||
const result = await tool.execute("1", {
|
||||
soul: " new soul ",
|
||||
instructionsText: "new instructions",
|
||||
memory: "new memory",
|
||||
}, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
expect((agentStore.updateAgent as any)).toHaveBeenCalledWith("agent-1", {
|
||||
soul: "new soul",
|
||||
instructionsText: "new instructions",
|
||||
memory: "new memory",
|
||||
});
|
||||
expect((result.content[0] as any).text).toContain("Updated identity fields");
|
||||
expect((result.content[0] as any).text).toContain("soul");
|
||||
});
|
||||
|
||||
it("rejects empty update and length overages", async () => {
|
||||
const agentStore = { updateAgent: vi.fn() } as unknown as AgentStore;
|
||||
const tool = createUpdateIdentityTool(agentStore, "agent-1");
|
||||
|
||||
const empty = await tool.execute("1", {}, undefined as any, undefined as any, undefined as any);
|
||||
expect((empty.content[0] as any).text).toContain("Provide at least one field");
|
||||
|
||||
const tooLongSoul = await tool.execute("1", { soul: "x".repeat(MAX_SOUL_LENGTH + 1) }, undefined as any, undefined as any, undefined as any);
|
||||
expect((tooLongSoul.content[0] as any).text).toContain("soul exceeds");
|
||||
|
||||
const tooLongInstructions = await tool.execute("1", { instructionsText: "x".repeat(MAX_INSTRUCTIONS_TEXT_LENGTH + 1) }, undefined as any, undefined as any, undefined as any);
|
||||
expect((tooLongInstructions.content[0] as any).text).toContain("instructionsText exceeds");
|
||||
|
||||
const tooLongMemory = await tool.execute("1", { memory: "x".repeat(MAX_MEMORY_LENGTH + 1) }, undefined as any, undefined as any, undefined as any);
|
||||
expect((tooLongMemory.content[0] as any).text).toContain("memory exceeds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSelfImproveService", () => {
|
||||
let agent: Agent;
|
||||
let agentStore: AgentStore;
|
||||
|
||||
beforeEach(() => {
|
||||
agent = {
|
||||
id: "agent-1",
|
||||
name: "Agent",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
runtimeConfig: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as Agent;
|
||||
|
||||
agentStore = {
|
||||
getAgent: vi.fn().mockResolvedValue(agent),
|
||||
getRatingSummary: vi.fn().mockResolvedValue(makeSummary()),
|
||||
updateAgent: vi.fn().mockResolvedValue(agent),
|
||||
} as unknown as AgentStore;
|
||||
});
|
||||
|
||||
it("evaluates interval, first-run behavior, prompt, and record", async () => {
|
||||
const service = new AgentSelfImproveService({ agentStore, reflectionStore: {} as ReflectionStore, rootDir: "/tmp" });
|
||||
|
||||
agent.runtimeConfig = { selfImproveIntervalMs: 3_600_000, lastSelfImproveAt: new Date(Date.now() - 4_000_000).toISOString() };
|
||||
await expect(service.shouldRunSelfImprove("agent-1")).resolves.toBe(true);
|
||||
|
||||
agent.runtimeConfig = { selfImproveIntervalMs: 3_600_000, lastSelfImproveAt: new Date().toISOString() };
|
||||
await expect(service.shouldRunSelfImprove("agent-1")).resolves.toBe(false);
|
||||
|
||||
agent.runtimeConfig = {};
|
||||
await expect(service.shouldRunSelfImprove("agent-1")).resolves.toBe(true);
|
||||
|
||||
(agentStore.getRatingSummary as any).mockResolvedValueOnce(makeSummary({ totalRatings: 0 }));
|
||||
await expect(service.shouldRunSelfImprove("agent-1")).resolves.toBe(false);
|
||||
|
||||
agent.runtimeConfig = { selfImproveEnabled: false };
|
||||
await expect(service.shouldRunSelfImprove("agent-1")).resolves.toBe(false);
|
||||
|
||||
agent.runtimeConfig = { lastSelfImproveAt: "2026-05-01T00:00:00.000Z" };
|
||||
await expect(service.getSelfImprovePrompt("agent-1")).resolves.toContain("2026-05-01T00:00:00.000Z");
|
||||
agent.runtimeConfig = {};
|
||||
await expect(service.getSelfImprovePrompt("agent-1")).resolves.toContain("never");
|
||||
|
||||
await service.recordSelfImprove("agent-1");
|
||||
expect((agentStore.updateAgent as any)).toHaveBeenCalledWith("agent-1", expect.objectContaining({
|
||||
runtimeConfig: expect.objectContaining({ lastSelfImproveAt: expect.any(String) }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat integration for evaluation tools", () => {
|
||||
it("exposes tools in task-scoped and no-task runs", async () => {
|
||||
const agent: Agent = {
|
||||
id: "agent-1",
|
||||
name: "Agent",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-1",
|
||||
soul: "improve from feedback",
|
||||
runtimeConfig: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as Agent;
|
||||
|
||||
const store = {
|
||||
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
|
||||
startHeartbeatRun: vi.fn().mockResolvedValue({ id: "run-1", startedAt: new Date().toISOString(), status: "running", agentId: "agent-1" }),
|
||||
saveRun: vi.fn().mockResolvedValue(undefined),
|
||||
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
|
||||
getRunDetail: vi.fn().mockResolvedValue({ id: "run-1" }),
|
||||
getAgent: vi.fn().mockResolvedValue(agent),
|
||||
updateAgentState: vi.fn().mockResolvedValue(undefined),
|
||||
assignTask: vi.fn().mockResolvedValue(undefined),
|
||||
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
getLastBlockedState: vi.fn().mockResolvedValue(null),
|
||||
getBudgetStatus: vi.fn().mockResolvedValue({ isOverBudget: false, isOverThreshold: false }),
|
||||
appendRunLog: vi.fn().mockResolvedValue(undefined),
|
||||
getRatings: vi.fn().mockResolvedValue([]),
|
||||
getRatingSummary: vi.fn().mockResolvedValue(makeSummary({ totalRatings: 0, averageScore: 0, categoryAverages: {}, trend: "insufficient-data" })),
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
getRecentRuns: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const taskStore = {
|
||||
getTask: vi.fn().mockResolvedValue({ id: "FN-1", status: "todo", column: "todo", comments: [], steeringComments: [] }),
|
||||
createTask: vi.fn().mockResolvedValue({ id: "FN-2" }),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getTaskDocuments: vi.fn().mockResolvedValue([]),
|
||||
createTaskDocument: vi.fn().mockResolvedValue({ id: "doc-1" }),
|
||||
addComment: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
} as any,
|
||||
} as any);
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store,
|
||||
taskStore,
|
||||
rootDir: "/tmp",
|
||||
reflectionStore: {
|
||||
getLatestReflection: vi.fn().mockResolvedValue(null),
|
||||
getReflections: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as ReflectionStore,
|
||||
reflectionService: { generateReflection: vi.fn() } as any,
|
||||
});
|
||||
|
||||
const taskTools = monitor.createHeartbeatTools("agent-1", taskStore, "FN-1").map((tool) => tool.name);
|
||||
expect(taskTools).toContain("fn_read_evaluations");
|
||||
expect(taskTools).toContain("fn_update_identity");
|
||||
expect(taskTools).toContain("fn_reflect_on_performance");
|
||||
|
||||
agent.taskId = undefined;
|
||||
await monitor.executeHeartbeat({ agentId: "agent-1", source: "timer" });
|
||||
const call = mockedCreateFnAgent.mock.calls.at(-1)?.[0];
|
||||
const names = (call?.customTools ?? []).map((tool: { name: string }) => tool.name);
|
||||
expect(names).toContain("fn_read_evaluations");
|
||||
expect(names).toContain("fn_update_identity");
|
||||
expect(names).toContain("fn_reflect_on_performance");
|
||||
});
|
||||
});
|
||||
@@ -1889,8 +1889,8 @@ describe("executeHeartbeat", () => {
|
||||
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_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
|
||||
expect(callArgs.customTools).toHaveLength(12);
|
||||
// fn_get_agent_config, fn_update_agent_config, fn_read_evaluations, fn_update_identity, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
|
||||
expect(callArgs.customTools).toHaveLength(14);
|
||||
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");
|
||||
@@ -1899,11 +1899,13 @@ 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_memory_search");
|
||||
expect(callArgs.customTools![9]!.name).toBe("fn_memory_get");
|
||||
expect(callArgs.customTools![10]!.name).toBe("fn_memory_append");
|
||||
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_memory_search");
|
||||
expect(callArgs.customTools![11]!.name).toBe("fn_memory_get");
|
||||
expect(callArgs.customTools![12]!.name).toBe("fn_memory_append");
|
||||
// fn_heartbeat_done is last (terminal tool)
|
||||
expect(callArgs.customTools![11]!.name).toBe("fn_heartbeat_done");
|
||||
expect(callArgs.customTools![13]!.name).toBe("fn_heartbeat_done");
|
||||
});
|
||||
|
||||
it("includes memory instructions even when agent has no custom instructions", async () => {
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("createHeartbeatTools", () => {
|
||||
|
||||
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
|
||||
|
||||
expect(tools).toHaveLength(8);
|
||||
expect(tools).toHaveLength(10);
|
||||
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");
|
||||
@@ -116,6 +116,8 @@ 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");
|
||||
});
|
||||
|
||||
it("fn_task_create tool creates a task in triage via TaskStore", async () => {
|
||||
|
||||
@@ -17,17 +17,24 @@
|
||||
* - onTerminated: Called when an unresponsive agent is terminated
|
||||
*/
|
||||
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext, Settings, AgentConfigRevision } from "@fusion/core";
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore } from "@fusion/core";
|
||||
import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity } from "@fusion/core";
|
||||
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, taskCreateParams } from "./agent-tools.js";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, 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";
|
||||
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
|
||||
import { promptWithFallback } from "./pi.js";
|
||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||
|
||||
interface SelfImproveServiceLike {
|
||||
shouldRunSelfImprove(agentId: string): Promise<boolean>;
|
||||
getSelfImprovePrompt(agentId: string): Promise<string>;
|
||||
recordSelfImprove(agentId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Resolved per-agent heartbeat config after validation and fallback */
|
||||
interface ResolvedHeartbeatConfig {
|
||||
@@ -69,6 +76,12 @@ export interface HeartbeatMonitorOptions {
|
||||
rootDir?: string;
|
||||
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
|
||||
pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
/** Optional ReflectionStore for evaluation-reading tools */
|
||||
reflectionStore?: ReflectionStore;
|
||||
/** Optional AgentReflectionService for fn_reflect_on_performance tool */
|
||||
reflectionService?: AgentReflectionService;
|
||||
/** Optional self-improvement service for periodic self-improve injection */
|
||||
selfImproveService?: SelfImproveServiceLike;
|
||||
}
|
||||
|
||||
/** Options for waking up an agent */
|
||||
@@ -491,6 +504,9 @@ export class HeartbeatMonitor {
|
||||
private rootDir?: string;
|
||||
private messageStore?: MessageStore;
|
||||
private pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
private reflectionStore?: ReflectionStore;
|
||||
private reflectionService?: AgentReflectionService;
|
||||
private selfImproveService?: SelfImproveServiceLike;
|
||||
|
||||
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
||||
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
|
||||
@@ -515,6 +531,9 @@ export class HeartbeatMonitor {
|
||||
this.rootDir = options.rootDir;
|
||||
this.messageStore = options.messageStore;
|
||||
this.pluginRunner = options.pluginRunner;
|
||||
this.reflectionStore = options.reflectionStore;
|
||||
this.reflectionService = options.reflectionService;
|
||||
this.selfImproveService = options.selfImproveService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1460,6 +1479,12 @@ export class HeartbeatMonitor {
|
||||
heartbeatTools.push(createSendMessageTool(this.messageStore, agentId));
|
||||
heartbeatTools.push(createReadMessagesTool(this.messageStore, agentId));
|
||||
}
|
||||
|
||||
heartbeatTools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
||||
heartbeatTools.push(createUpdateIdentityTool(this.store, agentId));
|
||||
if (this.reflectionService) {
|
||||
heartbeatTools.push(createReflectOnPerformanceTool(this.reflectionService, agentId));
|
||||
}
|
||||
} else {
|
||||
// Task-scoped runs: full tool set including fn_task_log and document tools
|
||||
// taskId is guaranteed to be defined here because isNoTaskRun = !taskId
|
||||
@@ -1504,9 +1529,23 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
let selfImprovePrompt = "";
|
||||
let shouldRecordSelfImprove = false;
|
||||
if (this.selfImproveService) {
|
||||
try {
|
||||
const shouldSelfImprove = await this.selfImproveService.shouldRunSelfImprove(agentId);
|
||||
if (shouldSelfImprove) {
|
||||
selfImprovePrompt = await this.selfImproveService.getSelfImprovePrompt(agentId);
|
||||
shouldRecordSelfImprove = true;
|
||||
}
|
||||
} catch (selfImproveErr) {
|
||||
heartbeatLog.warn(`Failed to resolve self-improvement prompt for ${agentId}: ${selfImproveErr instanceof Error ? selfImproveErr.message : String(selfImproveErr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPromptWithInstructions(
|
||||
baseHeartbeatSystemPrompt,
|
||||
[resolvedInstructionsForIdentity, memoryInstructions].filter((part) => part.trim()).join("\n\n"),
|
||||
[resolvedInstructionsForIdentity, memoryInstructions, selfImprovePrompt].filter((part) => part.trim()).join("\n\n"),
|
||||
);
|
||||
|
||||
// fn_heartbeat_done must be the last tool in the array (stable terminal signal)
|
||||
@@ -1852,6 +1891,14 @@ export class HeartbeatMonitor {
|
||||
stdoutExcerpt: stdoutExcerpt || undefined,
|
||||
});
|
||||
|
||||
if (shouldRecordSelfImprove && this.selfImproveService) {
|
||||
try {
|
||||
await this.selfImproveService.recordSelfImprove(agentId);
|
||||
} catch (selfImproveRecordErr) {
|
||||
heartbeatLog.warn(`Failed to record self-improvement checkpoint for ${agentId}: ${selfImproveRecordErr instanceof Error ? selfImproveRecordErr.message : String(selfImproveRecordErr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
heartbeatLog.log(`Heartbeat completed for ${agentId} (${toolCallCount} tool calls, ${usageInput} input + ${usageOutput} output + ${usageCached} cached tokens)`);
|
||||
} catch (err) {
|
||||
const errorDetail = formatError(err).detail;
|
||||
@@ -2076,6 +2123,12 @@ export class HeartbeatMonitor {
|
||||
tools.push(createReadMessagesTool(messageStore, agentId));
|
||||
}
|
||||
|
||||
tools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
||||
tools.push(createUpdateIdentityTool(this.store, agentId));
|
||||
if (this.reflectionService) {
|
||||
tools.push(createReflectOnPerformanceTool(this.reflectionService, agentId));
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ import { createLogger } from "./logger.js";
|
||||
const log = createLogger("agent-instructions");
|
||||
|
||||
const MAX_INSTRUCTIONS_PATH_LENGTH = 500;
|
||||
const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
|
||||
const MAX_SOUL_LENGTH = 10_000;
|
||||
const MAX_MEMORY_LENGTH = 50_000;
|
||||
export const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
|
||||
export const MAX_SOUL_LENGTH = 10_000;
|
||||
export const MAX_MEMORY_LENGTH = 50_000;
|
||||
|
||||
function trimAndClamp(value: string, maxLength: number, label: string, agentId: string): string {
|
||||
const trimmed = value.trim();
|
||||
|
||||
98
packages/engine/src/agent-self-improve.ts
Normal file
98
packages/engine/src/agent-self-improve.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { AgentHeartbeatConfig, AgentStore, ReflectionStore } from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const selfImproveLog = createLogger("agent-self-improve");
|
||||
|
||||
const DEFAULT_SELF_IMPROVE_INTERVAL_MS = 14_400_000;
|
||||
const MIN_SELF_IMPROVE_INTERVAL_MS = 3_600_000;
|
||||
|
||||
export interface AgentSelfImproveServiceOptions {
|
||||
agentStore: AgentStore;
|
||||
reflectionStore: ReflectionStore;
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
export class AgentSelfImproveService {
|
||||
private readonly agentStore: AgentStore;
|
||||
private readonly reflectionStore: ReflectionStore;
|
||||
private readonly rootDir: string;
|
||||
|
||||
constructor(options: AgentSelfImproveServiceOptions) {
|
||||
this.agentStore = options.agentStore;
|
||||
this.reflectionStore = options.reflectionStore;
|
||||
this.rootDir = options.rootDir;
|
||||
}
|
||||
|
||||
async shouldRunSelfImprove(agentId: string): Promise<boolean> {
|
||||
void this.reflectionStore;
|
||||
void this.rootDir;
|
||||
|
||||
const agent = await this.agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const runtimeConfig = (agent.runtimeConfig ?? {}) as AgentHeartbeatConfig;
|
||||
if (runtimeConfig.selfImproveEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intervalMs = typeof runtimeConfig.selfImproveIntervalMs === "number" && Number.isFinite(runtimeConfig.selfImproveIntervalMs)
|
||||
? Math.max(MIN_SELF_IMPROVE_INTERVAL_MS, runtimeConfig.selfImproveIntervalMs)
|
||||
: DEFAULT_SELF_IMPROVE_INTERVAL_MS;
|
||||
|
||||
const lastSelfImproveAt = runtimeConfig.lastSelfImproveAt;
|
||||
if (!lastSelfImproveAt) {
|
||||
const summary = await this.agentStore.getRatingSummary(agentId);
|
||||
return summary.totalRatings > 0;
|
||||
}
|
||||
|
||||
const lastMs = Date.parse(lastSelfImproveAt);
|
||||
if (!Number.isFinite(lastMs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Date.now() - lastMs > intervalMs;
|
||||
}
|
||||
|
||||
async getSelfImprovePrompt(agentId: string): Promise<string> {
|
||||
const agent = await this.agentStore.getAgent(agentId);
|
||||
const runtimeConfig = (agent?.runtimeConfig ?? {}) as AgentHeartbeatConfig;
|
||||
const lastSelfImproveAt = runtimeConfig.lastSelfImproveAt ?? "never";
|
||||
|
||||
return `## Self-Improvement Phase
|
||||
|
||||
It is time for your periodic self-improvement review. Your last self-improvement was at ${lastSelfImproveAt}.
|
||||
|
||||
Follow this process:
|
||||
1. Call fn_read_evaluations to review your ratings, reflections, and feedback.
|
||||
2. Analyze the data for actionable patterns:
|
||||
- Declining scores or negative trends
|
||||
- Recurring error categories
|
||||
- Repeated negative feedback themes
|
||||
- Suggestions from reflections you haven't addressed
|
||||
3. Based on your analysis, call fn_update_identity to update your instructions, soul, or memory:
|
||||
- Update instructionsText to incorporate new operating procedures or avoid repeated mistakes
|
||||
- Update soul to refine your personality/behavior based on feedback
|
||||
- Update memory to record self-improvement observations and commitments
|
||||
4. Be conservative: only make changes you're confident will improve performance based on concrete evidence.
|
||||
5. Document your self-improvement decisions concisely.`;
|
||||
}
|
||||
|
||||
async recordSelfImprove(agentId: string): Promise<void> {
|
||||
const agent = await this.agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRuntime = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
|
||||
await this.agentStore.updateAgent(agentId, {
|
||||
runtimeConfig: {
|
||||
...existingRuntime,
|
||||
lastSelfImproveAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
selfImproveLog.log(`Recorded self-improve checkpoint for ${agentId}`);
|
||||
}
|
||||
}
|
||||
@@ -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, relative, resolve } from "node:path";
|
||||
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, 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, ReflectionStore } 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";
|
||||
@@ -19,6 +19,7 @@ import { ResearchStepRunner } from "./research-step-runner.js";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||
import { MAX_INSTRUCTIONS_TEXT_LENGTH, MAX_MEMORY_LENGTH, MAX_SOUL_LENGTH } from "./agent-instructions.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
||||
@@ -55,6 +56,14 @@ export const reflectOnPerformanceParams = Type.Object({
|
||||
),
|
||||
});
|
||||
|
||||
export const readEvaluationsParams = Type.Object({});
|
||||
|
||||
export const updateIdentityParams = Type.Object({
|
||||
soul: Type.Optional(Type.String({ description: "Updated soul/personality text" })),
|
||||
instructionsText: Type.Optional(Type.String({ description: "Updated operating instructions" })),
|
||||
memory: Type.Optional(Type.String({ description: "Updated agent memory text" })),
|
||||
});
|
||||
|
||||
export const listAgentsParams = Type.Object({
|
||||
role: Type.Optional(
|
||||
Type.String({ description: "Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa')" }),
|
||||
@@ -957,6 +966,162 @@ export function createReflectOnPerformanceTool(
|
||||
* @param agentStore - AgentStore for agent discovery
|
||||
* @returns ToolDefinition for the `fn_list_agents` tool
|
||||
*/
|
||||
function formatScore(score: number | null | undefined): string {
|
||||
if (typeof score !== "number" || !Number.isFinite(score)) return "n/a";
|
||||
return score.toFixed(2);
|
||||
}
|
||||
|
||||
function buildPreview(value: string, limit = 100): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit)}…` : trimmed;
|
||||
}
|
||||
|
||||
export function createReadEvaluationsTool(
|
||||
agentStore: AgentStore,
|
||||
reflectionStore: ReflectionStore | undefined,
|
||||
agentId: string,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
name: "fn_read_evaluations",
|
||||
label: "Read Evaluations",
|
||||
description: "Read your ratings, recent feedback, and reflection history to support self-improvement.",
|
||||
parameters: readEvaluationsParams,
|
||||
execute: async (_id: string, _params: Static<typeof readEvaluationsParams>) => {
|
||||
const [summary, ratings] = await Promise.all([
|
||||
agentStore.getRatingSummary(agentId),
|
||||
agentStore.getRatings(agentId, { limit: 10 }),
|
||||
]);
|
||||
|
||||
const latestReflection = reflectionStore
|
||||
? await reflectionStore.getLatestReflection(agentId)
|
||||
: null;
|
||||
const reflections = reflectionStore
|
||||
? await reflectionStore.getReflections(agentId, 5)
|
||||
: [];
|
||||
|
||||
const hasRatings = ratings.length > 0 || summary.totalRatings > 0;
|
||||
const hasReflections = Boolean(latestReflection) || reflections.length > 0;
|
||||
|
||||
if (!hasRatings && !hasReflections) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No evaluation data available yet." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"Evaluation Summary",
|
||||
`- Average score: ${formatScore(summary.averageScore)}`,
|
||||
`- Trend: ${summary.trend}`,
|
||||
`- Total ratings: ${summary.totalRatings}`,
|
||||
];
|
||||
|
||||
const categoryEntries = Object.entries(summary.categoryAverages ?? {});
|
||||
if (categoryEntries.length > 0) {
|
||||
lines.push("", "Category averages:");
|
||||
for (const [category, score] of categoryEntries) {
|
||||
lines.push(`- ${category}: ${formatScore(score)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const commentedRatings = ratings.filter((rating) => rating.comment?.trim());
|
||||
if (commentedRatings.length > 0) {
|
||||
lines.push("", "Recent rating comments:");
|
||||
for (const rating of commentedRatings.slice(0, 5)) {
|
||||
lines.push(`- [${rating.score}/5] ${rating.comment!.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestReflection) {
|
||||
lines.push("", "Latest reflection:", `- Summary: ${latestReflection.summary}`);
|
||||
if (latestReflection.insights.length > 0) {
|
||||
lines.push("- Insights:");
|
||||
latestReflection.insights.forEach((insight) => lines.push(` - ${insight}`));
|
||||
}
|
||||
if (latestReflection.suggestedImprovements.length > 0) {
|
||||
lines.push("- Suggested improvements:");
|
||||
latestReflection.suggestedImprovements.forEach((item) => lines.push(` - ${item}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (reflections.length > 0) {
|
||||
lines.push("", "Recent reflection history:");
|
||||
for (const reflection of reflections.slice(0, 5)) {
|
||||
lines.push(`- ${reflection.timestamp}: ${reflection.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: lines.join("\n") }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateIdentityTool(agentStore: AgentStore, agentId: string): ToolDefinition {
|
||||
return {
|
||||
name: "fn_update_identity",
|
||||
label: "Update Identity",
|
||||
description: "Update your own soul, instructionsText, or memory fields based on evaluation feedback.",
|
||||
parameters: updateIdentityParams,
|
||||
execute: async (_id: string, params: Static<typeof updateIdentityParams>) => {
|
||||
const updates: AgentUpdateInput = {};
|
||||
|
||||
if (params.soul !== undefined) {
|
||||
const soul = params.soul.trim();
|
||||
if (soul.length > MAX_SOUL_LENGTH) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: soul exceeds ${MAX_SOUL_LENGTH} character limit` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
updates.soul = soul;
|
||||
}
|
||||
|
||||
if (params.instructionsText !== undefined) {
|
||||
const instructionsText = params.instructionsText.trim();
|
||||
if (instructionsText.length > MAX_INSTRUCTIONS_TEXT_LENGTH) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: instructionsText exceeds ${MAX_INSTRUCTIONS_TEXT_LENGTH} character limit` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
updates.instructionsText = instructionsText;
|
||||
}
|
||||
|
||||
if (params.memory !== undefined) {
|
||||
const memory = params.memory.trim();
|
||||
if (memory.length > MAX_MEMORY_LENGTH) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: memory exceeds ${MAX_MEMORY_LENGTH} character limit` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
updates.memory = memory;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: Provide at least one field to update" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
await agentStore.updateAgent(agentId, updates);
|
||||
|
||||
const confirmations = Object.entries(updates).map(([key, value]) => `- ${key}: ${buildPreview(String(value))}`);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Updated identity fields:\n${confirmations.join("\n")}`,
|
||||
}],
|
||||
details: { updatedFields: Object.keys(updates) },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createListAgentsTool(agentStore: AgentStore): ToolDefinition {
|
||||
return {
|
||||
name: "fn_list_agents",
|
||||
|
||||
@@ -55,6 +55,7 @@ export {
|
||||
type SkillDiagnostic,
|
||||
} from "./skill-resolver.js";
|
||||
export { AgentReflectionService, type AgentReflectionServiceOptions } from "./agent-reflection.js";
|
||||
export { AgentSelfImproveService, type AgentSelfImproveServiceOptions } from "./agent-self-improve.js";
|
||||
export {
|
||||
buildAgentChatPrompt,
|
||||
resolveAgentInstructionsWithRatings,
|
||||
|
||||
@@ -358,6 +358,21 @@ export class InProcessRuntime
|
||||
}
|
||||
}
|
||||
|
||||
let selfImproveService: import("../agent-self-improve.js").AgentSelfImproveService | undefined;
|
||||
if (agentStoreForReflection && reflectionStoreForService) {
|
||||
try {
|
||||
const { AgentSelfImproveService: AgentSelfImproveServiceClass } = await import("../agent-self-improve.js");
|
||||
selfImproveService = new AgentSelfImproveServiceClass({
|
||||
agentStore: agentStoreForReflection,
|
||||
reflectionStore: reflectionStoreForService,
|
||||
rootDir: this.config.workingDirectory,
|
||||
});
|
||||
runtimeLog.log("AgentSelfImproveService initialized");
|
||||
} catch (selfImproveErr) {
|
||||
runtimeLog.warn(`AgentSelfImproveService initialization failed:`, selfImproveErr instanceof Error ? selfImproveErr.message : selfImproveErr);
|
||||
}
|
||||
}
|
||||
|
||||
const executorOptions: TaskExecutorOptions = {
|
||||
semaphore: this.globalSemaphore,
|
||||
pool: this.worktreePool,
|
||||
@@ -537,6 +552,9 @@ export class InProcessRuntime
|
||||
rootDir: this.config.workingDirectory,
|
||||
messageStore: this.messageStore,
|
||||
pluginRunner: this.pluginRunner,
|
||||
reflectionStore: reflectionStoreForService,
|
||||
reflectionService,
|
||||
selfImproveService,
|
||||
onMissed: (agentId, reason) => {
|
||||
runtimeLog.warn(`Agent ${agentId} missed heartbeat: ${reason}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user