feat(heartbeat): inline Identity Snapshot for runtime-agnostic delivery

Plugin runtimes (openclaw, hermes, paperclip) wrap external CLIs and may not
propagate JS customTools to the underlying agent, so fn_identity could be
unreachable on those runtimes. Embedding the agent's identity (role, soul,
instructions, memory previews) directly in every execution prompt guarantees
the agent always sees what loaded for the tick — fn_identity remains as an
optional richer read for runtimes that do support custom tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 06:27:23 -07:00
parent cf0ea341a1
commit d6d591b532
2 changed files with 102 additions and 7 deletions

View File

@@ -6089,4 +6089,43 @@ describe("HeartbeatMonitor observability — prompt persistence + run-scoped log
expect(toolResult.details.memoryPresent).toBe(true);
expect(toolResult.details.soulPreview).toContain("I am a senior executor.");
});
it("inlines the Identity Snapshot block into the execution prompt for runtime-agnostic delivery", async () => {
const store = createStoreWithAgent({
taskId: undefined,
soul: "I keep momentum across stalled tasks.",
memory: "Always log blockers with concrete next steps.",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
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).executionPrompt === "string"
&& ((args[0] as AgentHeartbeatRun).executionPrompt?.length ?? 0) > 0
);
expect(promptRunCall).toBeDefined();
const savedRun = promptRunCall![0] as AgentHeartbeatRun;
const exec = savedRun.executionPrompt!;
// Snapshot header + identity fields appear in the execution prompt body itself,
// so non-pi runtimes (openclaw/hermes/paperclip) that may not propagate
// customTools still see the agent's identity every tick.
expect(exec).toContain("## Identity Snapshot");
expect(exec).toContain("- agentId: agent-001");
expect(exec).toContain("- soul: loaded");
expect(exec).toContain("- memory: loaded");
expect(exec).toContain("I keep momentum across stalled tasks.");
// Snapshot must precede the Wake Delta and the Heartbeat Procedure
const snapIdx = exec.indexOf("## Identity Snapshot");
const wakeIdx = exec.indexOf("## Wake Delta");
const procIdx = exec.indexOf("Heartbeat Procedure");
expect(snapIdx).toBeGreaterThanOrEqual(0);
expect(snapIdx).toBeLessThan(wakeIdx);
expect(wakeIdx).toBeLessThan(procIdx);
});
});

View File

@@ -292,9 +292,12 @@ 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** — 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.
1. **Identity & context** — review the **Identity Snapshot** at the top of
this prompt. Confirm your role, soul, instructions, and memory match what
you expect, and surface any anomalies in your first text output before
doing anything else. (If fn_identity is available in your runtime you may
also call it for full structured detail; the snapshot above is the
authoritative source.)
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
@@ -330,6 +333,59 @@ function truncatePrompt(text: string, maxChars: number): string {
return `${text.slice(0, maxChars)}\n\n... (truncated, ${text.length} chars)`;
}
/**
* Build the per-tick **Identity Snapshot** block injected into every
* heartbeat execution prompt.
*
* Why inline (not just a tool): plugin runtimes (openclaw, hermes, paperclip)
* wrap external CLIs and may not propagate JS `customTools` callbacks to the
* underlying agent. Embedding the snapshot in the prompt body guarantees the
* agent always sees its identity regardless of runtime tool support.
* `fn_identity` remains available as a richer optional read for runtimes
* that DO support custom tools.
*/
function buildIdentitySnapshot(args: {
agent: Agent;
resolvedInstructions: string;
}): string {
const { agent, resolvedInstructions } = args;
const SOUL_PREVIEW = 500;
const INSTR_PREVIEW = 1000;
const MEM_PREVIEW = 1000;
const soulPresent = typeof agent.soul === "string" && agent.soul.trim().length > 0;
const instrPresent = resolvedInstructions.trim().length > 0;
const memPresent = typeof agent.memory === "string" && agent.memory.trim().length > 0;
const lines: string[] = [
"## Identity Snapshot",
"",
"Verify these match what you expect. Surface any anomalies in your first text output before acting.",
"",
`- agentId: ${agent.id}`,
`- name: ${agent.name}`,
`- role: ${agent.role}`,
`- soul: ${soulPresent ? "loaded" : "absent"}`,
`- instructions: ${instrPresent ? "loaded" : "absent"}`,
`- memory: ${memPresent ? "loaded" : "absent"}`,
];
if (soulPresent) {
const preview = (agent.soul as string).trim().slice(0, SOUL_PREVIEW);
lines.push("", `### Soul (first ${SOUL_PREVIEW} chars)`, preview);
}
if (instrPresent) {
const preview = resolvedInstructions.trim().slice(0, INSTR_PREVIEW);
lines.push("", `### Instructions (first ${INSTR_PREVIEW} chars)`, preview);
}
if (memPresent) {
const preview = (agent.memory as string).trim().slice(0, MEM_PREVIEW);
lines.push("", `### Memory (first ${MEM_PREVIEW} chars)`, preview);
}
return lines.join("\n");
}
async function getHeartbeatMemorySettings(taskStore: TaskStore): Promise<Settings | undefined> {
const maybeGetSettings = (taskStore as { getSettings?: () => Promise<Settings> }).getSettings;
if (!maybeGetSettings) {
@@ -1442,6 +1498,8 @@ export class HeartbeatMonitor {
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
"",
buildIdentitySnapshot({ agent, resolvedInstructions: resolvedInstructionsForIdentity }),
"",
"## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`- wake reason: ${wakeReason}`,
@@ -1452,8 +1510,6 @@ 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.",
@@ -1542,6 +1598,8 @@ export class HeartbeatMonitor {
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`Assigned task: ${taskId}${taskTitle}`,
"",
buildIdentitySnapshot({ agent, resolvedInstructions: resolvedInstructionsForIdentity }),
"",
"## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`- wake reason: ${wakeReason}`,
@@ -1554,8 +1612,6 @@ 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:",