feat(FN-2979): add droid CLI probe module for node diagnostics
Added a new `droid-cli-probe` module to the dashboard package with test coverage, implementing a CLI probe capability for the droid system. Fusion-Task-Id: FN-2979
This commit is contained in:
@@ -1329,6 +1329,7 @@ describe("HeartbeatMonitor", () => {
|
||||
getLastBlockedState: vi.fn().mockResolvedValue(null),
|
||||
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
appendRunLog: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
@@ -2831,8 +2832,8 @@ describe("HeartbeatMonitor", () => {
|
||||
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_memory_search, fn_memory_get, fn_memory_append, fn_identity, fn_heartbeat_done
|
||||
expect(callArgs.customTools).toHaveLength(11);
|
||||
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");
|
||||
@@ -2842,8 +2843,10 @@ describe("HeartbeatMonitor", () => {
|
||||
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");
|
||||
// fn_identity appears before fn_heartbeat_done
|
||||
expect(callArgs.customTools![9]!.name).toBe("fn_identity");
|
||||
// fn_heartbeat_done is last (terminal tool)
|
||||
expect(callArgs.customTools![9]!.name).toBe("fn_heartbeat_done");
|
||||
expect(callArgs.customTools![10]!.name).toBe("fn_heartbeat_done");
|
||||
});
|
||||
|
||||
it("includes memory instructions even when agent has no custom instructions", async () => {
|
||||
@@ -5612,6 +5615,7 @@ describe("executeHeartbeat — skill selection resolver contract (FN-1510/FN-151
|
||||
getLastBlockedState: vi.fn().mockResolvedValue(null),
|
||||
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
appendRunLog: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
@@ -5799,6 +5803,7 @@ describe("executeHeartbeat — skill selection non-fatal (FN-1510/FN-1511)", ()
|
||||
getLastBlockedState: vi.fn().mockResolvedValue(null),
|
||||
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
appendRunLog: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
@@ -5860,3 +5865,228 @@ describe("executeHeartbeat — skill selection non-fatal (FN-1510/FN-1511)", ()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// New observability tests (FN-3xxx sweep)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HeartbeatMonitor observability — prompt persistence + run-scoped logs", () => {
|
||||
// These tests use the same mock infrastructure as the main executeHeartbeat suite.
|
||||
let mockTaskStore: TaskStore;
|
||||
let mockAgent: Agent;
|
||||
|
||||
function createMockAgentSession() {
|
||||
return {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
model: { provider: "mock", id: "mock-model" },
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-001",
|
||||
title: "Test Task",
|
||||
description: "Test task description",
|
||||
prompt: "# Test PROMPT.md\nSome content",
|
||||
steps: [],
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail),
|
||||
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
|
||||
createTask: vi.fn().mockResolvedValue({ id: "FN-002", description: "Created task", dependencies: [], column: "triage" }),
|
||||
logEntry: vi.fn().mockResolvedValue({}),
|
||||
addComment: vi.fn().mockResolvedValue({}),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
upsertTaskDocument: vi.fn().mockResolvedValue({}),
|
||||
getTaskDocument: vi.fn().mockResolvedValue(null),
|
||||
getTaskDocuments: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createStoreWithAgent(agentData: Partial<Agent> = {}): AgentStore {
|
||||
mockAgent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-001",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
...agentData,
|
||||
} as Agent;
|
||||
|
||||
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
|
||||
|
||||
return {
|
||||
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgentState: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgent: vi.fn().mockResolvedValue(undefined),
|
||||
getAgent: vi.fn().mockResolvedValue(mockAgent),
|
||||
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
|
||||
mockAgent.taskId = taskId;
|
||||
return mockAgent;
|
||||
}),
|
||||
startHeartbeatRun: vi.fn().mockResolvedValue({
|
||||
id: "run-obs-001",
|
||||
agentId: "agent-001",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
} as AgentHeartbeatRun),
|
||||
saveRun: vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
|
||||
savedRuns.set(run.id, run);
|
||||
}),
|
||||
getRunDetail: vi.fn().mockImplementation(async (_agentId: string, runId: string) => {
|
||||
return savedRuns.get(runId) ?? {
|
||||
id: runId,
|
||||
agentId: "agent-001",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: new Date().toISOString(),
|
||||
status: "completed" as const,
|
||||
};
|
||||
}),
|
||||
getRatingSummary: vi.fn().mockResolvedValue(undefined),
|
||||
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
|
||||
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
|
||||
getCachedAgent: vi.fn().mockReturnValue(null),
|
||||
getLastBlockedState: vi.fn().mockResolvedValue(null),
|
||||
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
|
||||
appendRunLog: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockTaskStore = createMockTaskStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("no-task heartbeat run persists systemPrompt and executionPrompt on the run record", async () => {
|
||||
// Identity agent (has soul) so a no-task run is triggered
|
||||
const store = createStoreWithAgent({ taskId: undefined, soul: "I am the ambient coordinator." });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
// saveRun should have been called with both prompt fields populated
|
||||
const saveRunCalls = (store.saveRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
// Find the call that includes systemPrompt (the prompt-persistence saveRun)
|
||||
const promptRunCall = saveRunCalls.find(
|
||||
(args: unknown[]) => typeof (args[0] as AgentHeartbeatRun).systemPrompt === "string" && ((args[0] as AgentHeartbeatRun).systemPrompt?.length ?? 0) > 0
|
||||
);
|
||||
expect(promptRunCall).toBeDefined();
|
||||
const savedRun = promptRunCall![0] as AgentHeartbeatRun;
|
||||
expect(savedRun.systemPrompt).toBeDefined();
|
||||
expect(typeof savedRun.systemPrompt).toBe("string");
|
||||
expect(savedRun.executionPrompt).toBeDefined();
|
||||
expect(typeof savedRun.executionPrompt).toBe("string");
|
||||
// heartbeatProcedureSource should be "default" (no custom procedure file)
|
||||
expect(savedRun.heartbeatProcedureSource).toBe("default");
|
||||
|
||||
// The execution prompt should contain the procedure text before the no-task action menu
|
||||
expect(savedRun.executionPrompt).toContain("fn_identity");
|
||||
expect(savedRun.executionPrompt).toContain("Heartbeat Procedure");
|
||||
// The wake delta header should appear before the action menu items
|
||||
const procedureIdx = savedRun.executionPrompt!.indexOf("Heartbeat Procedure");
|
||||
const actionMenuIdx = savedRun.executionPrompt!.indexOf("No assigned task");
|
||||
expect(procedureIdx).toBeLessThan(actionMenuIdx);
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("no-task heartbeat run-scoped logs receive at least one entry after a simulated tick", async () => {
|
||||
const store = createStoreWithAgent({ taskId: undefined, soul: "I observe the project." });
|
||||
const mockSession = createMockAgentSession();
|
||||
let capturedOnText: ((delta: string) => void) | undefined;
|
||||
|
||||
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
||||
capturedOnText = opts.onText;
|
||||
return { session: mockSession as any };
|
||||
});
|
||||
|
||||
// Simulate the session emitting a text delta during prompt
|
||||
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
||||
capturedOnText?.("I am reviewing the project state.");
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
// appendRunLog should have been called on the AgentStore at least once
|
||||
const appendRunLogCalls = (store.appendRunLog as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(appendRunLogCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify the entry shape: agentId, runId, entry
|
||||
const [callAgentId, callRunId, callEntry] = appendRunLogCalls[0] as [string, string, unknown];
|
||||
expect(callAgentId).toBe("agent-001");
|
||||
expect(callRunId).toBe("run-obs-001");
|
||||
expect(callEntry).toMatchObject({ type: expect.stringMatching(/^(text|thinking|tool|tool_result|tool_error)$/) });
|
||||
});
|
||||
|
||||
it("task-scoped heartbeat persists systemPrompt and executionPrompt with procedure before task content", async () => {
|
||||
const store = createStoreWithAgent({ taskId: "FN-001" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
const saveRunCalls = (store.saveRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const promptRunCall = saveRunCalls.find(
|
||||
(args: unknown[]) => typeof (args[0] as AgentHeartbeatRun).systemPrompt === "string" && ((args[0] as AgentHeartbeatRun).systemPrompt?.length ?? 0) > 0
|
||||
);
|
||||
expect(promptRunCall).toBeDefined();
|
||||
const savedRun = promptRunCall![0] as AgentHeartbeatRun;
|
||||
|
||||
// The execution prompt should have procedure before task description
|
||||
const procedureIdx = savedRun.executionPrompt!.indexOf("Heartbeat Procedure");
|
||||
const taskDescIdx = savedRun.executionPrompt!.indexOf("Task description:");
|
||||
expect(procedureIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(taskDescIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(procedureIdx).toBeLessThan(taskDescIdx);
|
||||
|
||||
// fn_identity instruction should appear in the execution prompt
|
||||
expect(savedRun.executionPrompt).toContain("fn_identity");
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("fn_identity tool returns correct agent identity information", async () => {
|
||||
const store = createStoreWithAgent({ soul: "I am a senior executor.", memory: "Always log blockers." });
|
||||
let capturedIdentityTool: any;
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
||||
capturedIdentityTool = opts.customTools?.find((t: any) => t.name === "fn_identity");
|
||||
return { session: mockSession as any };
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
expect(capturedIdentityTool).toBeDefined();
|
||||
expect(capturedIdentityTool.name).toBe("fn_identity");
|
||||
|
||||
// Call the tool and verify output structure
|
||||
const toolResult = await capturedIdentityTool.execute("call-1", {});
|
||||
expect(toolResult.content[0].text).toContain("agentId: agent-001");
|
||||
expect(toolResult.content[0].text).toContain("name: Test Agent");
|
||||
expect(toolResult.details.soulPresent).toBe(true);
|
||||
expect(toolResult.details.memoryPresent).toBe(true);
|
||||
expect(toolResult.details.soulPreview).toContain("I am a senior executor.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHea
|
||||
import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity } from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createIdentityTool, 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";
|
||||
@@ -292,9 +292,9 @@ export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
|
||||
*/
|
||||
export const HEARTBEAT_PROCEDURE = `## Heartbeat Procedure (run every tick, in order)
|
||||
|
||||
1. **Identity & context** — review your soul, instructions, and memory (already
|
||||
loaded in the system prompt). Confirm who you are and what you're responsible
|
||||
for before continuing prior work.
|
||||
1. **Identity & context** — call fn_identity FIRST to confirm which soul,
|
||||
instructions, and memory loaded for this tick. Echo your role and any
|
||||
anomalies in your first text output before doing anything else.
|
||||
2. **Inbox** — when fn_read_messages is available, call it. Process any pending
|
||||
messages first; reply with reply_to_message_id when answering.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
@@ -321,6 +321,15 @@ const heartbeatDoneParams = Type.Object({
|
||||
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
|
||||
});
|
||||
|
||||
/**
|
||||
* Truncate a string to `maxChars`, appending a marker so callers can see
|
||||
* content was clipped. Returns the original string unchanged when it fits.
|
||||
*/
|
||||
function truncatePrompt(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}\n\n... (truncated, ${text.length} chars)`;
|
||||
}
|
||||
|
||||
async function getHeartbeatMemorySettings(taskStore: TaskStore): Promise<Settings | undefined> {
|
||||
const maybeGetSettings = (taskStore as { getSettings?: () => Promise<Settings> }).getSettings;
|
||||
if (!maybeGetSettings) {
|
||||
@@ -1297,17 +1306,6 @@ export class HeartbeatMonitor {
|
||||
const message = memorySettingsError instanceof Error ? memorySettingsError.message : String(memorySettingsError);
|
||||
heartbeatLog.warn(`Failed to configure heartbeat memory tools for ${agentId}: ${message}`);
|
||||
}
|
||||
heartbeatTools.push(heartbeatDoneTool);
|
||||
|
||||
// AgentLogger requires a taskId — only create for task-scoped runs
|
||||
if (!isNoTaskRun && taskId) {
|
||||
agentLogger = new AgentLogger({
|
||||
store: taskStore,
|
||||
taskId,
|
||||
agent: agent.role as AgentRole,
|
||||
});
|
||||
}
|
||||
|
||||
// Build skill selection context for heartbeat session (uses waking agent's skills, no role fallback)
|
||||
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir);
|
||||
|
||||
@@ -1315,8 +1313,10 @@ export class HeartbeatMonitor {
|
||||
? HEARTBEAT_NO_TASK_SYSTEM_PROMPT
|
||||
: HEARTBEAT_SYSTEM_PROMPT;
|
||||
const baseHeartbeatSystemPrompt = systemPrompt;
|
||||
let resolvedInstructionsForIdentity = "";
|
||||
try {
|
||||
const agentInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
|
||||
resolvedInstructionsForIdentity = agentInstructions;
|
||||
const memoryInstructions = memorySettings?.memoryEnabled === false
|
||||
? ""
|
||||
: buildExecutionMemoryInstructions(rootDir, memorySettings);
|
||||
@@ -1330,6 +1330,28 @@ export class HeartbeatMonitor {
|
||||
heartbeatLog.warn(`Failed to enrich heartbeat system prompt for ${agentId}: ${message}`);
|
||||
}
|
||||
|
||||
// Register fn_identity tool before fn_heartbeat_done (which must stay last)
|
||||
heartbeatTools.push(createIdentityTool({ agent, resolvedInstructions: resolvedInstructionsForIdentity }));
|
||||
|
||||
// fn_heartbeat_done must be the last tool in the array (stable terminal signal)
|
||||
heartbeatTools.push(heartbeatDoneTool);
|
||||
|
||||
// Always-on AgentLogger: no-task runs use the callback sink wired to run-scoped JSONL;
|
||||
// task-scoped runs write to both the task store AND the run-scoped JSONL.
|
||||
if (isNoTaskRun) {
|
||||
agentLogger = new AgentLogger({
|
||||
appendLog: (entry) => this.store.appendRunLog(agentId, run.id, entry),
|
||||
agent: agent.role as AgentRole,
|
||||
});
|
||||
} else if (taskId) {
|
||||
agentLogger = new AgentLogger({
|
||||
store: taskStore,
|
||||
taskId,
|
||||
agent: agent.role as AgentRole,
|
||||
appendLog: (entry) => this.store.appendRunLog(agentId, run.id, entry),
|
||||
});
|
||||
}
|
||||
|
||||
// Create agent session
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "heartbeat",
|
||||
@@ -1430,6 +1452,10 @@ export class HeartbeatMonitor {
|
||||
"Run the Heartbeat Procedure (below) before doing anything else — even a",
|
||||
"timer-only wake should re-check messages, memory, and project state.",
|
||||
"",
|
||||
"You MUST call fn_identity as your first tool action this tick before reading any task content or calling any other tool.",
|
||||
"",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"**No assigned task** — This heartbeat run has no task assignment.",
|
||||
"",
|
||||
"You have identity (soul, instructions, and/or memory) loaded, which means you can perform",
|
||||
@@ -1454,8 +1480,6 @@ export class HeartbeatMonitor {
|
||||
"Your soul, instructions, and memory are already loaded in the system prompt.",
|
||||
"Focus on work that benefits the project without requiring a specific task context.",
|
||||
"",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"Call fn_heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
} else {
|
||||
@@ -1530,6 +1554,10 @@ export class HeartbeatMonitor {
|
||||
"decide what action this delta requires. Your assigned task is one input",
|
||||
"to the procedure — not the only thing to consider.",
|
||||
"",
|
||||
"You MUST call fn_identity as your first tool action this tick before reading any task content or calling any other tool.",
|
||||
"",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"Task description:",
|
||||
taskDetail!.description,
|
||||
"",
|
||||
@@ -1537,12 +1565,26 @@ export class HeartbeatMonitor {
|
||||
...triggeringCommentLines,
|
||||
...pendingMessagesLines,
|
||||
"",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Persist prompts on the run record before executing so they are
|
||||
// observable in the dashboard even if execution fails partway through.
|
||||
try {
|
||||
const runWithPrompts: AgentHeartbeatRun = {
|
||||
...run,
|
||||
systemPrompt: truncatePrompt(systemPrompt, 100_000),
|
||||
executionPrompt: truncatePrompt(executionPrompt, 100_000),
|
||||
heartbeatProcedureSource: customProcedure ? "custom" : "default",
|
||||
};
|
||||
await this.store.saveRun(runWithPrompts);
|
||||
// Update local run reference so completeRun merges correctly
|
||||
Object.assign(run, { systemPrompt: runWithPrompts.systemPrompt, executionPrompt: runWithPrompts.executionPrompt, heartbeatProcedureSource: runWithPrompts.heartbeatProcedureSource });
|
||||
} catch (promptPersistErr) {
|
||||
heartbeatLog.warn(`Failed to persist prompts for ${agentId}/${run.id}: ${promptPersistErr instanceof Error ? promptPersistErr.message : String(promptPersistErr)}`);
|
||||
}
|
||||
|
||||
// Execute
|
||||
await promptWithFallback(session, executionPrompt);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TaskStore, AgentRole } from "@fusion/core";
|
||||
import type { TaskStore, AgentLogEntry, AgentRole } from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/** Default byte threshold before an automatic flush. */
|
||||
@@ -35,12 +35,24 @@ export function summarizeToolArgs(name: string, args?: Record<string, unknown>):
|
||||
|
||||
/**
|
||||
* Options for creating an {@link AgentLogger}.
|
||||
*
|
||||
* Two sink modes are supported:
|
||||
* 1. **Task-store mode** (original): provide `store` + `taskId`. Writes go to
|
||||
* `store.appendAgentLog(taskId, ...)`.
|
||||
* 2. **Callback mode**: provide `appendLog`. Writes go to the callback instead.
|
||||
* When both are provided, both sinks receive every entry.
|
||||
*/
|
||||
export interface AgentLoggerOptions {
|
||||
/** The task store used to persist agent log entries. */
|
||||
store: TaskStore;
|
||||
/** The task ID this logger is associated with. */
|
||||
taskId: string;
|
||||
/** The task store used to persist agent log entries (task-store mode). */
|
||||
store?: TaskStore;
|
||||
/** The task ID this logger is associated with (task-store mode). */
|
||||
taskId?: string;
|
||||
/**
|
||||
* Optional alternative sink callback. When provided, every flushed entry is
|
||||
* forwarded here in addition to (or instead of) `store.appendAgentLog`.
|
||||
* Use this for run-scoped logging where there is no task.
|
||||
*/
|
||||
appendLog?: (entry: AgentLogEntry) => Promise<void>;
|
||||
/** Which agent role is producing log entries (persisted on every entry). */
|
||||
agent?: AgentRole;
|
||||
/** Optional callback invoked alongside text logging (e.g. for SSE streaming). */
|
||||
@@ -85,8 +97,9 @@ export class AgentLogger {
|
||||
private thinkingFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly flushSizeBytes: number;
|
||||
private readonly flushIntervalMs: number;
|
||||
private readonly store: TaskStore;
|
||||
private readonly store?: TaskStore;
|
||||
private readonly taskId: string;
|
||||
private readonly appendLogCb?: (entry: AgentLogEntry) => Promise<void>;
|
||||
private readonly agent?: AgentRole;
|
||||
private readonly externalTextCb?: (taskId: string, delta: string) => void;
|
||||
private readonly externalToolCb?: (taskId: string, toolName: string) => void;
|
||||
@@ -94,7 +107,8 @@ export class AgentLogger {
|
||||
|
||||
constructor(options: AgentLoggerOptions) {
|
||||
this.store = options.store;
|
||||
this.taskId = options.taskId;
|
||||
this.taskId = options.taskId ?? "";
|
||||
this.appendLogCb = options.appendLog;
|
||||
this.agent = options.agent;
|
||||
this.externalTextCb = options.onAgentText;
|
||||
this.externalToolCb = options.onAgentTool;
|
||||
@@ -149,9 +163,7 @@ export class AgentLogger {
|
||||
if (this.thinkingFlushTimer) { clearTimeout(this.thinkingFlushTimer); this.thinkingFlushTimer = null; }
|
||||
this.flushThinkingBuffer();
|
||||
const detail = summarizeToolArgs(name, args);
|
||||
this.store.appendAgentLog(this.taskId, name, "tool", detail, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to log tool start "${name}" for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
this.writeEntry(name, "tool", detail, `Failed to log tool start "${name}" for ${this.taskId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,9 +180,7 @@ export class AgentLogger {
|
||||
if (result !== undefined && result !== null) {
|
||||
detail = typeof result === "string" ? result : JSON.stringify(result);
|
||||
}
|
||||
this.store.appendAgentLog(this.taskId, name, type, detail, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to log tool end "${name}" (${type}) for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
this.writeEntry(name, type, detail, `Failed to log tool end "${name}" (${type}) for ${this.taskId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,22 +196,99 @@ export class AgentLogger {
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a single structured entry through whichever sink(s) are configured.
|
||||
* When both `store`+`taskId` and `appendLogCb` are set, both receive the entry.
|
||||
* When only `appendLogCb` is set (no store/taskId), only the callback is used.
|
||||
* @param storeWarnMsg - Warning message prefix used when the task-store write fails.
|
||||
*/
|
||||
private writeEntry(text: string, type: AgentLogEntry["type"], detail: string | undefined, storeWarnMsg: string): void {
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: this.taskId,
|
||||
text,
|
||||
type,
|
||||
...(detail !== undefined && { detail }),
|
||||
...(this.agent !== undefined && { agent: this.agent }),
|
||||
};
|
||||
|
||||
if (this.store && this.taskId) {
|
||||
this.store.appendAgentLog(this.taskId, text, type, detail, this.agent).catch((err) => {
|
||||
this.log.warn(`${storeWarnMsg}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.appendLogCb) {
|
||||
this.appendLogCb(entry).catch((err) => {
|
||||
this.log.warn(`appendLog callback failed for entry (${type}): ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private flushTextBuffer(): Promise<void> {
|
||||
if (this.textBuffer.length === 0) return Promise.resolve();
|
||||
const chunk = this.textBuffer;
|
||||
this.textBuffer = "";
|
||||
return this.store.appendAgentLog(this.taskId, chunk, "text", undefined, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to flush text buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: this.taskId,
|
||||
text: chunk,
|
||||
type: "text",
|
||||
...(this.agent !== undefined && { agent: this.agent }),
|
||||
};
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (this.store && this.taskId) {
|
||||
promises.push(
|
||||
this.store.appendAgentLog(this.taskId, chunk, "text", undefined, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to flush text buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.appendLogCb) {
|
||||
promises.push(
|
||||
this.appendLogCb(entry).catch((err) => {
|
||||
this.log.warn(`appendLog callback failed for text flush: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => undefined);
|
||||
}
|
||||
|
||||
private flushThinkingBuffer(): Promise<void> {
|
||||
if (this.thinkingBuffer.length === 0) return Promise.resolve();
|
||||
const chunk = this.thinkingBuffer;
|
||||
this.thinkingBuffer = "";
|
||||
return this.store.appendAgentLog(this.taskId, chunk, "thinking", undefined, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to flush thinking buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: this.taskId,
|
||||
text: chunk,
|
||||
type: "thinking",
|
||||
...(this.agent !== undefined && { agent: this.agent }),
|
||||
};
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (this.store && this.taskId) {
|
||||
promises.push(
|
||||
this.store.appendAgentLog(this.taskId, chunk, "thinking", undefined, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to flush thinking buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.appendLogCb) {
|
||||
promises.push(
|
||||
this.appendLogCb(entry).catch((err) => {
|
||||
this.log.warn(`appendLog callback failed for thinking flush: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => undefined);
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
|
||||
@@ -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 } from "@fusion/core";
|
||||
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, Agent } from "@fusion/core";
|
||||
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, resolveResearchSettings, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
||||
import { ResearchOrchestrator } from "./research-orchestrator.js";
|
||||
import { ResearchProviderRegistry } from "./research/provider-registry.js";
|
||||
@@ -1328,3 +1328,79 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Arguments for {@link createIdentityTool}. */
|
||||
export interface CreateIdentityToolArgs {
|
||||
/** The agent record for this heartbeat run. */
|
||||
agent: Agent;
|
||||
/** The resolved instructions string (from resolveAgentInstructionsWithRatings). */
|
||||
resolvedInstructions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `fn_identity` tool for heartbeat sessions.
|
||||
*
|
||||
* When called, it returns a structured summary of which soul, instructions, and
|
||||
* memory are currently loaded for this tick. The agent is expected to call this
|
||||
* as its FIRST tool action so operators (via dashboard run logs) can verify
|
||||
* correct identity was applied.
|
||||
*/
|
||||
export function createIdentityTool({ agent, resolvedInstructions }: CreateIdentityToolArgs): ToolDefinition {
|
||||
const identityParams = Type.Object({});
|
||||
return {
|
||||
name: "fn_identity",
|
||||
label: "Identity Check",
|
||||
description: "Return a structured summary of which soul, instructions, and memory are loaded for this heartbeat tick. Call this FIRST before any other tool.",
|
||||
parameters: identityParams,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
execute: async (_id: string, _params: Static<typeof identityParams>, _signal?: any, _onUpdate?: any, _ctx?: any) => {
|
||||
const PREVIEW_CHARS = 500;
|
||||
const INSTRUCTIONS_PREVIEW_CHARS = 1000;
|
||||
const MEMORY_PREVIEW_CHARS = 1000;
|
||||
|
||||
const soulPresent = typeof agent.soul === "string" && agent.soul.trim().length > 0;
|
||||
const instructionsPresent = resolvedInstructions.trim().length > 0;
|
||||
const memoryPresent = typeof agent.memory === "string" && agent.memory.trim().length > 0;
|
||||
|
||||
const soulPreview = soulPresent ? (agent.soul as string).slice(0, PREVIEW_CHARS) : "";
|
||||
const instructionsPreview = instructionsPresent ? resolvedInstructions.slice(0, INSTRUCTIONS_PREVIEW_CHARS) : "";
|
||||
const memoryPreview = memoryPresent ? (agent.memory as string).slice(0, MEMORY_PREVIEW_CHARS) : "";
|
||||
|
||||
const result = {
|
||||
agentId: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
soulPresent,
|
||||
instructionsPresent,
|
||||
memoryPresent,
|
||||
soulPreview,
|
||||
instructionsPreview,
|
||||
memoryPreview,
|
||||
};
|
||||
|
||||
const lines = [
|
||||
`agentId: ${result.agentId}`,
|
||||
`name: ${result.name}`,
|
||||
`role: ${result.role}`,
|
||||
`soul: ${result.soulPresent ? "loaded" : "absent"}`,
|
||||
`instructions: ${result.instructionsPresent ? "loaded" : "absent"}`,
|
||||
`memory: ${result.memoryPresent ? "loaded" : "absent"}`,
|
||||
];
|
||||
|
||||
if (result.soulPresent && result.soulPreview) {
|
||||
lines.push(`\nSoul preview (first ${PREVIEW_CHARS} chars):\n${result.soulPreview}`);
|
||||
}
|
||||
if (result.instructionsPresent && result.instructionsPreview) {
|
||||
lines.push(`\nInstructions preview (first ${INSTRUCTIONS_PREVIEW_CHARS} chars):\n${result.instructionsPreview}`);
|
||||
}
|
||||
if (result.memoryPresent && result.memoryPreview) {
|
||||
lines.push(`\nMemory preview (first ${MEMORY_PREVIEW_CHARS} chars):\n${result.memoryPreview}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: lines.join("\n") }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user