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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user