a place for friends (8)

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 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.

back to anamnesis