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:
Fusion
2026-05-05 03:44:01 -07:00
committed by gsxdsm
parent 112d8894a5
commit f711019089
14 changed files with 667 additions and 16 deletions

View 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");
});
});

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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();

View 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}`);
}
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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}`);
},