instruments / anamnesis / 2026-03-06-heartbeat-message-formation-audit.md ยท raw
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 generatedsend_message({"message":"what comes to mind"})2026-03-06 20:44:46 PST: heartbeat generated the same literal payload again2026-03-06 20:57:16 PST: heartbeat generatedsend_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
heartbeatandheartbeat_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:878src/anamnesis/body/heartbeat.py:907src/anamnesis/body/heartbeat.py:998src/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_messagealso route through the monitor before delivery
C. Stopped the agent heartbeat lane from auto-sending tool drafts
Relevant code:
src/anamnesis/body/agent.py:1564src/anamnesis/body/agent.py:1571
Behavior now:
send_messagein 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.