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:
Fusion
2026-05-01 04:03:02 -07:00
committed by gsxdsm
parent 0ec5dfa3b1
commit 09551f3d3f
12 changed files with 784 additions and 43 deletions

View File

@@ -55,4 +55,5 @@ These tools are **not** part of the pi extension's user-invokable `extension.ts`
| Tool | Purpose | Parameters |
|---|---|---|
| `fn_identity` | Return loaded soul/instructions/memory summary for this heartbeat tick (must be called first) | none |
| `fn_heartbeat_done` | Signal end of heartbeat run with optional summary | `summary?` (string) |

View File

@@ -54,6 +54,11 @@ describe("TaskStore", () => {
});
afterEach(async () => {
// Ensure teardown always runs on real timers. Some tests in this file use
// timeout/retry-based fs cleanup paths that can stall indefinitely if fake
// timers were left enabled by a preceding test.
vi.useRealTimers();
// Some watcher/polling tests can leave an in-flight poll tick queued right
// before teardown. Stop watching first and yield once so pending callbacks
// settle before removing temp dirs.

View File

@@ -7,7 +7,7 @@
* edited as normal project files.
*/
import { mkdir, readFile, writeFile, readdir, unlink, rename, access } from "node:fs/promises";
import { mkdir, readFile, writeFile, readdir, unlink, rename, access, appendFile } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { randomUUID, randomBytes, createHash } from "node:crypto";
@@ -36,6 +36,7 @@ import type {
AgentRatingSummary,
AgentRatingInput,
Task,
AgentLogEntry,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath } from "./types.js";
import type { RunMutationContext } from "./types.js";
@@ -1790,6 +1791,70 @@ export class AgentStore extends EventEmitter {
.filter((run): run is AgentHeartbeatRun => run !== null);
}
// ─────────────────────────────────────────────────────────────────────────
// Run-scoped log storage (JSONL files alongside run JSON in agentsDir)
// ─────────────────────────────────────────────────────────────────────────
/** Maximum byte size for any single log entry field (64 KB) to bound disk growth. */
private static readonly RUN_LOG_ENTRY_MAX_BYTES = 64 * 1024;
/** Return the path to the JSONL run-log file for a given agent/run pair. */
private runLogPath(agentId: string, runId: string): string {
return join(this.agentsDir, `${agentId}-runlogs-${runId}.jsonl`);
}
/**
* Append a single {@link AgentLogEntry} to the JSONL run log for the given run.
* Individual `text` and `detail` fields are capped at 64 KB so one large tool
* result cannot grow the file unboundedly.
* @param agentId - The agent ID
* @param runId - The run ID
* @param entry - The log entry to append
*/
async appendRunLog(agentId: string, runId: string, entry: AgentLogEntry): Promise<void> {
const cap = AgentStore.RUN_LOG_ENTRY_MAX_BYTES;
const safeEntry: AgentLogEntry = {
...entry,
text: entry.text.length > cap ? `${entry.text.slice(0, cap)}\n\n... (truncated, ${entry.text.length} chars)` : entry.text,
...(entry.detail !== undefined && {
detail: entry.detail.length > cap ? `${entry.detail.slice(0, cap)}\n\n... (truncated, ${entry.detail.length} chars)` : entry.detail,
}),
};
const line = JSON.stringify(safeEntry) + "\n";
await appendFile(this.runLogPath(agentId, runId), line, "utf-8");
}
/**
* Read all log entries for a given run from its JSONL file.
* Returns an empty array when the file does not exist (e.g., the run had no
* logs or was recorded before this feature was added).
* @param agentId - The agent ID
* @param runId - The run ID
* @param opts.limit - Optional maximum number of entries to return (newest-first capped)
*/
async getRunLogs(agentId: string, runId: string, opts?: { limit?: number }): Promise<AgentLogEntry[]> {
const filePath = this.runLogPath(agentId, runId);
let raw: string;
try {
raw = await readFile(filePath, "utf-8");
} catch {
return [];
}
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
const entries: AgentLogEntry[] = [];
for (const line of lines) {
try {
entries.push(JSON.parse(line) as AgentLogEntry);
} catch {
// Skip malformed lines — append-only means partial writes can occur on crash
}
}
if (opts?.limit !== undefined && entries.length > opts.limit) {
return entries.slice(entries.length - opts.limit);
}
return entries;
}
/**
* Get the most recently persisted blocked-task dedup state for an agent.
*/

View File

@@ -2769,6 +2769,12 @@ export interface AgentHeartbeatRun {
stdoutExcerpt?: string;
/** Excerpt of stderr output */
stderrExcerpt?: string;
/** Full assembled system prompt sent to the LLM for this run (truncated to 100,000 chars). */
systemPrompt?: string;
/** Full per-tick execution prompt sent to the LLM for this run (truncated to 100,000 chars). */
executionPrompt?: string;
/** Whether a custom heartbeat procedure was loaded ("custom") or the built-in default was used ("default"). */
heartbeatProcedureSource?: "default" | "custom";
}
/** Capabilities/roles an agent can have */

View File

@@ -1288,6 +1288,11 @@ function RunsTab({
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
</span>
{run.heartbeatProcedureSource === "custom" && (
<span className="badge" style={{ fontSize: "10px", padding: "1px 6px" }}>
Heartbeat: custom
</span>
)}
</div>
</div>
<div className="run-details">
@@ -1312,6 +1317,30 @@ function RunsTab({
</div>
) : detailRun && (
<div className="run-output-sections">
{/* System Prompt */}
<div className="run-output-section">
<details>
<summary className="run-output-label" style={{ cursor: "pointer", userSelect: "none" }}>System Prompt</summary>
{detailRun.systemPrompt ? (
<pre className="run-output-panel">{detailRun.systemPrompt}</pre>
) : (
<div className="text-muted run-output-empty">System prompt not captured for this run</div>
)}
</details>
</div>
{/* Execution Prompt */}
<div className="run-output-section">
<details>
<summary className="run-output-label" style={{ cursor: "pointer", userSelect: "none" }}>Execution Prompt</summary>
{detailRun.executionPrompt ? (
<pre className="run-output-panel">{detailRun.executionPrompt}</pre>
) : (
<div className="text-muted run-output-empty">Execution prompt not captured for this run</div>
)}
</details>
</div>
{/* Token Usage */}
{detailRun.usageJson && (
<div className="run-output-section">

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { probeDroidCli } from "../droid-cli-probe.js";
/**
* These tests exercise the real probe — we deliberately do NOT mock
* `spawn`. The probe's job is to tell the truth about the local system,
* and mocking defeats that. Instead we:
*
* 1. Assert the shape is sound regardless of binary availability
* 2. Assert timeouts fire when the binary hangs (we don't have a
* hanging binary fixture, so we cover the structural case only)
* 3. Let the suite pass whether or not `droid` is installed on the
* test runner — both outcomes are legitimate.
*
* If you need mocked probe behavior for a higher-level route test, spy
* on `probeDroidCli` at the import boundary rather than shimming spawn.
*/
describe("probeDroidCli", () => {
it("returns a well-formed result whether or not droid is installed", async () => {
const result = await probeDroidCli();
expect(typeof result.available).toBe("boolean");
expect(typeof result.probeDurationMs).toBe("number");
if (result.available) {
// When available: version string should come back populated.
expect(typeof result.version).toBe("string");
} else {
// When unavailable: reason must be populated so the UI can render.
expect(typeof result.reason).toBe("string");
}
});
it("respects a short timeout", async () => {
// Not a true hang test (we don't have a hanging binary), but
// confirms the timeoutMs option wires through and probe completes
// in a bounded window when using a very small timeout.
const result = await probeDroidCli({ timeoutMs: 50 });
expect(result.probeDurationMs).toBeLessThan(5000);
// Either it completed fast enough or hit the timeout — both fine.
if (!result.available && result.reason?.includes("timed out")) {
expect(result.reason).toContain("50ms");
}
});
});

View File

@@ -0,0 +1,148 @@
/**
* Probe for the locally-installed Droid CLI binary.
*
* Used by GET /api/providers/droid-cli/status to power the "Factory AI —
* via Droid CLI" provider card. The card shows `authenticated=true` only
* when the binary is on PATH *and* the user has flipped on `useDroidCli`.
*
* Intentional design choices:
*
* - No caching. The user's PATH can change between requests (nvm switches,
* fresh terminal, etc.) — we'd rather pay one `spawn()` per poll than
* serve a stale "droid not installed" response. Droid's `--version`
* flag exits in ~40ms, so cost is negligible.
*
* - Short timeout. A misbehaving `droid` shim could hang indefinitely;
* we cap the probe at 2s and report `available: false` with a timeout
* reason rather than blocking the HTTP request.
*
* - No authorization. We never shell-interpolate PATH or user input.
* We spawn `droid --version` directly with argv, no shell.
*/
import { spawn } from "node:child_process";
/** Result shape returned to the dashboard status endpoint. */
export interface DroidCliBinaryStatus {
/** True if the `droid` binary was found on PATH and ran to completion. */
available: boolean;
/** Trimmed stdout from `droid --version`, if available. */
version?: string;
/** Absolute path, if we could resolve it via `which`. */
binaryPath?: string;
/** Human-readable failure reason when `available === false`. */
reason?: string;
/** Wall-clock duration of the probe, useful for debugging slow paths. */
probeDurationMs: number;
}
/** Default probe timeout. Droid's --version is fast; 2s is generous. */
const PROBE_TIMEOUT_MS = 2000;
/**
* Spawn `droid --version` and return a structured status result.
*
* Never throws — any failure is captured as `available: false` with a reason
* so the caller (an HTTP handler) can render the provider card without
* try/catch.
*/
export async function probeDroidCli(
options: { timeoutMs?: number } = {},
): Promise<DroidCliBinaryStatus> {
const startedAt = Date.now();
const timeoutMs = options.timeoutMs ?? PROBE_TIMEOUT_MS;
const binaryPath = await tryResolveBinaryPath("droid");
return new Promise<DroidCliBinaryStatus>((resolvePromise) => {
const finish = (result: Omit<DroidCliBinaryStatus, "probeDurationMs">): void => {
resolvePromise({ ...result, probeDurationMs: Date.now() - startedAt });
};
let settled = false;
const child = spawn(binaryPath ?? "droid", ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try {
child.kill("SIGKILL");
} catch {
// Process already gone — nothing to do.
}
finish({
available: false,
binaryPath,
reason: `Probe timed out after ${timeoutMs}ms`,
});
}, timeoutMs);
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const isNotFound = (err as NodeJS.ErrnoException).code === "ENOENT";
finish({
available: false,
binaryPath,
reason: isNotFound ? "`droid` not found on PATH" : err.message,
});
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) {
finish({
available: true,
version: stdout.trim() || undefined,
binaryPath,
});
} else {
finish({
available: false,
binaryPath,
reason:
stderr.trim() || `droid --version exited with code ${String(code)}`,
});
}
});
});
}
/**
* Best-effort `which droid`. We don't fail probe on inability to resolve
* the path — the spawn above is the actual authority. This is just for
* surfacing a friendly "found at /opt/homebrew/bin/droid" in the UI.
*/
async function tryResolveBinaryPath(binary: string): Promise<string | undefined> {
return new Promise((resolvePromise) => {
const which = process.platform === "win32" ? "where" : "which";
const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk) => {
out += chunk.toString("utf-8");
});
child.on("error", () => resolvePromise(undefined));
child.on("close", (code) => {
if (code === 0) {
const first = out.trim().split(/\r?\n/)[0];
resolvePromise(first?.length ? first : undefined);
} else {
resolvePromise(undefined);
}
});
});
}

View File

@@ -1114,6 +1114,15 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
throw notFound("Run not found");
}
// Prefer run-scoped JSONL logs (written by the always-on AgentLogger).
// These exist for both no-task and task-scoped runs from this version onward.
const runLogs = await agentStore.getRunLogs(req.params.id, req.params.runId);
if (runLogs.length > 0) {
res.json(runLogs);
return;
}
// Legacy fallback: use the run's context snapshot task ID to query task-scoped logs.
// Only use the run's context snapshot for task ID — do not fall back
// to agent.taskId since that represents the agent's *current* task,
// not the task active during a historical run.

View File

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

View File

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

View File

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

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