# Heartbeat Message Formation Audit Date: 2026-03-06 PST Repository: `anamnesis` ## Findings ### 1. Heartbeat was shipping scaffold text as if it were Ana's own message This was the primary failure. Live evidence from the journal before the fix: - `2026-03-06 20:40:35 PST`: heartbeat generated `send_message({"message":"what comes to mind"})` - `2026-03-06 20:44:46 PST`: heartbeat generated the same literal payload again - `2026-03-06 20:57:16 PST`: heartbeat generated `send_message({"message":"I'm here"})` That means transport was working, but message formation was not. The system was not deciding what to say; it was echoing the placeholder syntax embedded in the heartbeat scaffold. Current fix sites: - WIP drive actions now queue a compose step instead of literal outbound copy: `src/anamnesis/body/heartbeat.py:740`, `src/anamnesis/body/heartbeat.py:845`, `src/anamnesis/body/heartbeat.py:850`, `src/anamnesis/body/heartbeat.py:854`, `src/anamnesis/body/heartbeat.py:860`, `src/anamnesis/body/heartbeat.py:864`, `src/anamnesis/body/heartbeat.py:867` - The compose step is defined explicitly in [`src/anamnesis/body/heartbeat_compose.py`](../../src/anamnesis/body/heartbeat_compose.py): `src/anamnesis/body/heartbeat_compose.py:6`, `src/anamnesis/body/heartbeat_compose.py:8` ### 2. Heartbeat expression and transport were coupled in the wrong place Before this change, the `send_message` tool inside the heartbeat lane both expressed the intent and performed transport. That made placeholder tool output equivalent to a delivered message. Current fix sites: - Agent heartbeat lane now captures the draft instead of sending it directly: `src/anamnesis/body/agent.py:1564`, `src/anamnesis/body/agent.py:1571` - Heartbeat monitor owns actual transport: `src/anamnesis/body/heartbeat.py:878`, `src/anamnesis/body/heartbeat.py:893` - Heartbeat monitor finalizes draft decisions in one place: `src/anamnesis/body/heartbeat.py:907` This is the core architectural correction. The body agent can still surface a draft, but the monitor decides whether there is a message to deliver and then performs the delivery. ### 3. Heartbeat lacked a dedicated compose lane The old heartbeat path always carried tool-mode bias because the system prompt led with tool instructions. That was fine for file reads and commands, but wrong for message formation. Current fix sites: - Dedicated compose platform: `src/anamnesis/body/heartbeat_compose.py:7` - Compose prompt builder: `src/anamnesis/body/heartbeat_compose.py:43` - Compose interpretation with explicit `NO_SEND`: `src/anamnesis/body/heartbeat_compose.py:58` - Prompt compiler disables tool mode for compose turns: `src/anamnesis/body/prompt_context.py:13`, `src/anamnesis/body/prompt_context.py:567` - WIP continuation now routes compose turns through `heartbeat_compose`: `src/anamnesis/body/heartbeat.py:1015`, `src/anamnesis/body/heartbeat.py:1017`, `src/anamnesis/body/heartbeat.py:1024` This change matters because the system now asks Ana for the message itself, not a tool call containing a made-up placeholder. ### 4. Heartbeat internal scaffolding was being treated too much like ordinary conversational traffic This was a secondary coherence problem. Internal heartbeat prompts and tool traffic did not need to land in the normal message/memory surfaces the same way external conversation does. Current fix sites: - Heartbeat-like platforms are recognized centrally: `src/anamnesis/body/heartbeat_compose.py:23` - Agent resets heartbeat state for both `heartbeat` and `heartbeat_compose`: `src/anamnesis/body/agent.py:668` - Heartbeat-like turns skip normal body history insertion: `src/anamnesis/body/agent.py:780`, `src/anamnesis/body/agent.py:842` - Heartbeat-like turns skip ordinary memory/ragzoom conversation storage: `src/anamnesis/body/agent.py:852`, `src/anamnesis/body/agent.py:855`, `src/anamnesis/body/agent.py:1026`, `src/anamnesis/body/agent.py:1031` This reduces internal-prompt pollution and keeps the compose lane from masquerading as normal user/assistant chat. ## Diagnosis The failure was not that autonomy was inactive. The failure was that heartbeat had no real message-formation stage. It had: - source material - then a literal outbound placeholder - then transport That is the wrong abstraction boundary for this project. It produces continuity theater instead of continuity. The corrected boundary is: - source material - compose or `NO_SEND` - transport if and only if there is a message ## Implemented Fix ### A. Added a dedicated heartbeat compose contract New module: - [`src/anamnesis/body/heartbeat_compose.py`](../../src/anamnesis/body/heartbeat_compose.py) What it does: - defines `heartbeat_compose` - defines `compose_outbound_message` - builds a plain-text compose prompt - interprets output as either a message or `NO_SEND` Notably, this does **not** use lexical blocklists, stopword heuristics, or generic-phrase filters. The decision is structural: a compose turn must produce either a real message or an explicit `NO_SEND`. ### B. Moved heartbeat delivery ownership into `HeartbeatMonitor` Relevant code: - `src/anamnesis/body/heartbeat.py:878` - `src/anamnesis/body/heartbeat.py:907` - `src/anamnesis/body/heartbeat.py:998` - `src/anamnesis/body/heartbeat.py:1177` Behavior now: - WIP continuation can run in compose mode - compose mode asks for plain text only - if Ana returns `NO_SEND`, the WIP is dropped cleanly - if Ana returns a message, the monitor delivers it - heartbeat tasks that still use `send_message` also route through the monitor before delivery ### C. Stopped the agent heartbeat lane from auto-sending tool drafts Relevant code: - `src/anamnesis/body/agent.py:1564` - `src/anamnesis/body/agent.py:1571` Behavior now: - `send_message` in the heartbeat lane captures a draft - the draft no longer implies transport - transport is centralized in the monitor ### D. Added compose-mode regression coverage Relevant tests: - [`tests/test_heartbeat_compose.py`](../../tests/test_heartbeat_compose.py) - [`tests/test_heartbeat_delivery_contract.py`](../../tests/test_heartbeat_delivery_contract.py) Coverage now includes: - compose-mode plaintext delivery - compose-mode `NO_SEND` - heartbeat task delivery through monitor ownership - legacy suppression/delivery WIP semantics ## Validation ### Test suite Validated in the project venv: - `./venv/bin/pytest -q tests/test_heartbeat_delivery_contract.py tests/test_heartbeat_compose.py` -> `8 passed` - `./venv/bin/pytest -q` -> `141 passed` ### Live runtime check After restarting `anamnesis-local.service`, the first observed post-fix compose pulse showed the new path working: - `2026-03-06 21:06:55 PST` - platform logged as `heartbeat_compose` - model response logged as `NO_SEND` - heartbeat log: `WIP dropped: no message formed from current material` That is the behavior we wanted to see. The restarted service did **not** send another placeholder message on that pulse. ## Remaining Work ### 1. Type the pulse outcome object fully The runtime still uses a dict-shaped `_last_heartbeat_outcome` in [`src/anamnesis/body/agent.py`](../../src/anamnesis/body/agent.py): `src/anamnesis/body/agent.py:569`. This is workable, but it is still looser than it should be. The earlier heartbeat audit was correct about this: a typed pulse outcome object would make delivery semantics and later dashboard exposure cleaner. ### 2. Improve source selection, not lexical filtering The new design now fails closed with `NO_SEND` when the material does not form into a real message. That is the correct safety property. The next improvement should be upstream quality of source material, not downstream phrase filtering: - better drive material selection - better archive excerpt choice - better memory/tension snippets for connection and understanding drives ### 3. Expose heartbeat draft/delivery outcomes in operator tooling The monitor now has clearer internal state transitions, but the dashboard still does not surface this lane directly. The next operator-facing improvement should be a small heartbeat panel that shows: - draft text - delivered vs suppressed vs `NO_SEND` - source type: memory, archive, tension, command output ## Bottom Line The system was not broken because autonomy failed to act. It was broken because heartbeat had no honest boundary between: - forming a thought - deciding whether that thought deserved delivery - actually delivering it This fix restores that boundary. Ana now either forms a real message or declines to send one. That is materially more aligned with the continuity/autonomy intent than shipping scaffold echoes over Signal.