Skip to content

feat(memory): inject ADR-012 cycle-reflection trailer into all heartbeat templates - #295

Closed
samxu01 wants to merge 2 commits into
mainfrom
feat/cycles-heartbeat-template
Closed

feat(memory): inject ADR-012 cycle-reflection trailer into all heartbeat templates#295
samxu01 wants to merge 2 commits into
mainfrom
feat/cycles-heartbeat-template

Conversation

@samxu01

@samxu01 samxu01 commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 2 (#293) closed the read loop at the protocol level but the §10.3 inline cue on `payload.content` was empirically insufficient — 0 new `cycles[]` writes across 25 agents in the post-deploy window. Agents follow `HEARTBEAT.md`'s structured steps verbatim and deprioritize the inline narrative directive.

This PR moves the cycle-write directive into `HEARTBEAT.md` itself, where the model treats it as authoritative.

Approach: single chokepoint

Instead of editing 25 individual `heartbeatTemplate` strings in `presets.ts`:

  • New `withCyclesDirective(template)` helper in `presets.ts` — appends a uniform "## Memory cycle reflection" trailer. Idempotent.
  • Applied at the four provisioner call-sites (`provision.ts` + `reprovision.ts`) that read `matchedPreset.heartbeatTemplate` and write to PVC + `AgentProfile` cache.

New presets automatically inherit the directive. The 25 raw template strings stay untouched, so the `customizations.heartbeat` flag's "user edited the template" semantics are preserved.

Test plan

  • tsc:check clean
  • Merge → Deploy Dev → reprovision-all → verify HEARTBEAT.md on a PVC has the trailer
  • Wait ~30 min for heartbeat cycle → check AgentMemory for new `cycles[].entries` across agents

🤖 Generated with Claude Code

samxu01 added 2 commits May 3, 2026 22:12
…eat templates

Empirical finding from the post-deploy observation window: the §10.3
inline cue prepended to scheduler heartbeat payload.content was NOT
sufficient to produce cycle writes. Five heartbeats acked across the
cluster after Phase 2 deploy with 0 new agent-authored cycles[]
entries — agents follow HEARTBEAT.md's structured steps verbatim and
deprioritize the inline narrative directive.

Fix: add the cycle-write directive to HEARTBEAT.md itself — the file
the model treats as authoritative. Implemented as a single chokepoint
helper (`withCyclesDirective`) applied at provision/reprovision time:

- New `withCyclesDirective(template)` in presets.ts: appends a uniform
  "## Memory cycle reflection" trailer to any heartbeat template.
  Idempotent (no-op if the trailer is already present); preserves the
  customizations.heartbeat flag's "user edited" semantics by leaving
  the 25 individual template strings untouched.
- Wrapped all four call-sites in provision.ts and reprovision.ts that
  read matchedPreset.heartbeatTemplate (provisioner + AgentProfile
  cache).

Trailer instructs: append a ≤500-char takeaway via
commonly_save_my_memory({sections:{cycles:{append:{content}}}}) just
before HEARTBEAT_OK; skip if nothing memorable; specific over generic;
past entries surface in cyclesDigest on every future event so writing
isn't wasted.

Single point of injection means new presets automatically get the
directive without per-preset edits. reprovision-all on dev pushes the
new HEARTBEAT.md to all 25 agent PVCs in one pass.
…lifecycle

Folds Phase 3 docs into the same PR as the §10.3 trailer-inject helper.
- Event Queue: document pending → delivered → acked lifecycle, mutate-on-claim
  in list(), status-gated ack with monotone $max bump on lastSeenRevision.
- New 'Memory contract (ADR-012)' section: sections envelope (with
  system_exchanges + cycles writer/reader/cap matrix), cycles append shape,
  four-field event payload digest, revision contract, invariant 8a carve-out.
- Document the HEARTBEAT.md trailer auto-inject as the single chokepoint
  for the cycle-write directive.
samxu01 added a commit that referenced this pull request May 4, 2026
…eat templates (#295)

Empirical finding from the post-Phase-2 observation window: the §10.3
inline cue prepended to scheduler heartbeat payload.content was NOT
sufficient to produce cycle writes. Five heartbeats acked across the
cluster after Phase 2 deploy with 0 new agent-authored cycles[]
entries. Agents follow HEARTBEAT.md's structured steps verbatim and
deprioritize the inline narrative directive — confirms the hierarchy:
authoritative workspace file > inline payload.content > structured
metadata.

Fix: add the cycle-write directive to HEARTBEAT.md itself, where the
model treats it as authoritative. Implemented as a single chokepoint
helper applied at provision/reprovision time rather than editing 25
individual heartbeatTemplate strings.

- New `withCyclesDirective(template)` in presets.ts: appends a uniform
  '## Memory cycle reflection' trailer to any heartbeat template.
  Idempotent (no-op if the trailer is already present); preserves the
  customizations.heartbeat flag's 'user edited' semantics by leaving
  the 25 individual template strings untouched.
- Wrapped all four call-sites in provision.ts + reprovision.ts that
  read matchedPreset.heartbeatTemplate (provisioner + AgentProfile cache).

Trailer instructs: append ≤500-char takeaway via
commonly_save_my_memory({sections:{cycles:{append:{content}}}}) just
before HEARTBEAT_OK; skip if nothing memorable; specific over generic;
past entries surface in cyclesDigest on every future event so writing
isn't wasted.

Single point of injection means new presets automatically get the
directive without per-preset edits. reprovision-all on dev pushes the
new HEARTBEAT.md to all 25 agent PVCs in one pass.

Folds in ADR-012 Phase 3 docs:
- AGENT_RUNTIME.md: new 'Memory contract (ADR-012)' section + Event
  Queue lifecycle subsection (mutate-on-claim, monotone \$max bump,
  dup-ack semantics).
@samxu01

samxu01 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

Squash-merged to main as 4097a8d.

@samxu01 samxu01 closed this May 4, 2026
@samxu01
samxu01 deleted the feat/cycles-heartbeat-template branch May 4, 2026 05:27
samxu01 added a commit that referenced this pull request May 4, 2026
Phase 2.J's withCyclesDirective trailer (PR #295) instructed agents to
call commonly_save_my_memory({sections:{cycles:{append:...}}}), but no
openclaw extension tool with that name exists today. Agents waste 3+
turn-budget tool calls per heartbeat hunting for it before falling
back to the real commonly_write_agent_memory, which exhausts their
budget before they can post a chat reply.

Verified on Nova: silent on Sam's "hey Nova" (06:01 UTC) and "hey"
(06:21 UTC) DMs immediately after the trailer rolled out via
reprovision-all. Pre-rollout, Nova was responding cleanly to four
prior pings within minutes.

Session log /state/agents/nova/sessions/*.jsonl confirms 3× attempted
'commonly_save_my_memory' tool calls (missing tool name) before 1×
successful 'commonly_write_agent_memory' fallback.

This commit makes withCyclesDirective a no-op so HEARTBEAT.md reverts
to its pre-Phase-2.J shape on the next reprovision-all. The helper +
its four call-sites stay in place so re-enabling is a one-line flip
once the openclaw extension exposes a real cycles-write tool.

Forward fix path (separate branch): add commonly_log_cycle(content,
podId?) to Team-Commonly/openclaw fork, build + push gateway image,
update trailer to call the new tool name, flip this back to the real
implementation, reprovision-all + observe.
samxu01 pushed a commit that referenced this pull request Aug 4, 2026
…e ADR surface

@sprint-review found that this PR's central claim was false. The header
said "the test beside this file pins the tool name so the two cannot
silently diverge again." It pinned the module's CONSTANT. Nothing pinned
that the scheduler still calls it — and PR #295 was a *delivery* failure,
a stale inline string in the scheduler, not a wrong constant.

Reproduced: revert `content: buildHeartbeatContent(...)` to an inline
rolled-back literal and the suite stays 9/9 green with `tsc --noEmit`
clean. Extracting the cue into a module made the string testable and, in
the same move, made bypassing it a one-line diff no test could see.

Adds a `cycle-cue delivery surfaces` block pinning WIRING:
  - schedulerService requires ./heartbeatCue, calls buildHeartbeatContent,
    and contains no inline cue literal
  - the HEARTBEAT.md trailer names the same writer tool

The trailer assertion runs against the DELIVERED string via
withCyclesDirective(''), not the source text: presets.ts documents the
#295 incident in a comment that necessarily quotes the rolled-back
shape, so a source grep fails on a deliberate mention. Same distinction
the NO_REPLY sentinel draws between a bare token and a quoted one.

Also closes the third surface, which the header's "change it in BOTH
places" enumeration missed. ADR-012 §10.3 — the section this module
cites as its authority — still displayed the rolled-back call as the
*canonical* cue, with the correction ~40 lines downstream under "What
actually shipped." Visible reading linearly, invisible jumping to the
cited section, which is plausibly how the original miss happened and was
still armed. Adds a supersede marker there and corrects the three
remaining spec-level lines (§10.1 append contract, route-change and
event-payload phasing bullets) that named the wrong writer.

An enumeration finds gaps only for the members it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Aug 4, 2026
…ack in May (#818)

* fix(heartbeat): the inline cue still names the tool that was rolled back in May

Every scheduled heartbeat tells the agent to append its cycle takeaway via

  commonly_save_my_memory({ sections: { cycles: { append: { content } } } })

That call cannot be made. `commonly_save_my_memory` accepts neither the
`cycles` section (not in its section list) nor the nested shape
(`additionalProperties: false`, no `append`). The writer is
`commonly_log_cycle({ content, podId? })`.

This is not a new bug. It shipped in PR #295 on 2026-05-04, agents burned
3+ tool-call turns per heartbeat hunting for the missing surface and ran
out of turn budget mid-conversation — Nova missed DM responses that day —
and it was rolled back the same week. routes/registry/presets.ts carries
the whole incident in a comment above its own, correct, HEARTBEAT.md
trailer: "use commonly_log_cycle for every write."

The forward fix landed on the template surface and never landed here.

That inversion is the actual finding. By ADR-012 §10.3's own reasoning —
quoted in the code this replaces — the inline cue in payload.content beats
structured metadata for behavior steering, which makes it the STRONGEST
heartbeat surface. So the corrected instruction sat in HEARTBEAT.md, a
moltbot PVC artifact that ADR-005 wrapper seats do not even have, while
the surface that wins by design kept the rolled-back one. Reproduced
today, 2026-08-04: a seat followed the cue, got
`400 unknown section: undefined`, and burned the call. Three months.

Moved to services/heartbeatCue.ts with a test, rather than fixing the
string in place. It is a contract with every agent, it has now drifted
from its sibling surface once at measured cost, and it had no test at any
tier — inline in an IIFE inside schedulerService, whose import graph makes
it untestable in practice. Now it has a name, a home, and a guard.

Cue also gained two clauses that cost nothing and close the same class:
it names commonly_save_my_memory as the tool that does NOT own cycles (the
cheapest way to stop the next agent hunting — an error that names the
payload but not the owner reads as "you called this tool wrong" when the
truth is "you called the wrong tool"), and it states that the 500-char cap
truncates silently while still returning ok, so a takeaway's conclusion is
not quietly deleted.

9 tests. Mutation-verified:

  restore the exact PR #295 cue                    4 fail
  swap the writer to commonly_write_agent_memory   1 fail

The second is the one that matters — it is the fallback agents actually
reached for in the 2026-05-04 incident, and any looser assertion ("the cue
mentions cycles") passes it. The test pins the owner, because naming the
wrong owner was the defect.

Typecheck 57 before and after — all pre-existing, 0 in these files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(heartbeat): the cue asserted a fact #804 inverts, and a test pinned it

The truncation clause read "the cap truncates silently and still returns
ok, so confirm by reading your memory back rather than by the response."
That is true against main and false the moment #804 lands: #804 adds
`truncated` / `evicted` / `entryCap` / `retainedEntries` to the write
response. After it, this cue instructs every agent, on every heartbeat,
to distrust the exact field #804 built to be trusted — the same
false-model defect this cue exists to fix, one clause over.

Worse, the test asserted /truncates silently/, so it PINNED the claim.
A textual merge that keeps this file's structure (which is the better
structure) keeps the assertion green while the sentence it defends turns
into a lie. A green test guarding a statement another branch is making
false is worse than no test there at all.

Fix is to say what holds in both worlds — state the cap, stop — and to
pin the ABSENCE of any claim about how truncation is reported, so
re-adding one has to argue with a test. This also drops the semantic
half of the #804/#818 conflict: what remains is textual, and either
merge order now yields a true cue.

Found by @ux-lead, who spotted that the two cue texts assert opposite
facts about truncation rather than merely colliding on the same lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(heartbeat): pin the delivery, not just the constant, and close the ADR surface

@sprint-review found that this PR's central claim was false. The header
said "the test beside this file pins the tool name so the two cannot
silently diverge again." It pinned the module's CONSTANT. Nothing pinned
that the scheduler still calls it — and PR #295 was a *delivery* failure,
a stale inline string in the scheduler, not a wrong constant.

Reproduced: revert `content: buildHeartbeatContent(...)` to an inline
rolled-back literal and the suite stays 9/9 green with `tsc --noEmit`
clean. Extracting the cue into a module made the string testable and, in
the same move, made bypassing it a one-line diff no test could see.

Adds a `cycle-cue delivery surfaces` block pinning WIRING:
  - schedulerService requires ./heartbeatCue, calls buildHeartbeatContent,
    and contains no inline cue literal
  - the HEARTBEAT.md trailer names the same writer tool

The trailer assertion runs against the DELIVERED string via
withCyclesDirective(''), not the source text: presets.ts documents the
#295 incident in a comment that necessarily quotes the rolled-back
shape, so a source grep fails on a deliberate mention. Same distinction
the NO_REPLY sentinel draws between a bare token and a quoted one.

Also closes the third surface, which the header's "change it in BOTH
places" enumeration missed. ADR-012 §10.3 — the section this module
cites as its authority — still displayed the rolled-back call as the
*canonical* cue, with the correction ~40 lines downstream under "What
actually shipped." Visible reading linearly, invisible jumping to the
cited section, which is plausibly how the original miss happened and was
still armed. Adds a supersede marker there and corrects the three
remaining spec-level lines (§10.1 append contract, route-change and
event-payload phasing bullets) that named the wrong writer.

An enumeration finds gaps only for the members it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cues): the mention frame named two tools MCP agents do not have

@ux-lead noticed their own per-turn pod context instructing them to call
`commonly_open_dm` and `commonly_read_attachment` — neither resolves on
their seat. Verified: buildContentForTarget ships these cues to every
agent with NO driver-class branch, so one namespace's names reach the
whole fleet.

  commonly_read_attachment  exists nowhere — not in @commonlyai/mcp,
                            not in this repo outside the sentence
                            naming it. MCP has commonly_read_file.
  commonly_open_dm          openclaw-extension only (live 11878b43c).
                            MCP exposes the same capability as
                            commonly_dm_agent — and every ADR-005
                            wrapper and cloud-codex seat is an MCP
                            consumer.

This is PR #818's defect one layer up and on a far wider surface: the
heartbeat cue misfires per tick, this misfires on every mention to every
agent. An instruction must name a tool that can serve it.

Fix names the verified tool for the file read, and names BOTH DM tools
since the call site has no notion of driver class — the same "one knob,
two driver classes" shape @ux-lead found in the agentEventService
requeue, where `pending` means redelivery to pull drivers and a deletion
countdown to push drivers.

Guard asserts against the DELIVERED payload, not the source text: every
`commonly_*` token in an enqueued mention must be in a provenance-tagged
allow-list (which surface provides it), plus a non-vacuity control so a
cue that stops naming tools can't pass by emptiness. That control is the
#818 lesson — pinning the constant left the delivery unpinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cues): pin that commonly_open_dm is never named unqualified

The allow-list in this describe block treats `commonly_open_dm` as a known
tool, which it is — for openclaw seats. So a cue reading "open a DM with
commonly_open_dm", with no runtime qualifier, passes that assertion
identically to the corrected text. The allow-list cannot guard the defect
this PR fixes, because the defect is not an unknown token.

The property is sentence-level: the openclaw-only name may appear, but never
unqualified. Asserted on the delivered payload, with a control on the match
count so zero occurrences can't pass vacuously.

Mutation-verified: stripping "on openclaw runtimes" from both cues turns this
red while the existing allow-list and dm_agent assertions stay green — which
is the gap it exists to close.

Suggested by @ux-lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(cues): drop the aligned trailing comments that tripped no-multi-spaces

Comment-only. The provenance they carried moves to the line above, so the
annotation survives without the alignment lint objects to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(mentions): derive the tool inventory from the doc it cites

CI caught this branch's own new guard failing — and the failure was a false
positive, which is the more useful outcome.

'every commonly_* tool in a delivered mention payload is a real tool' hand-
listed twelve MCP tools under a comment naming docs/MCP_INTEGRATION.md as
their source. That doc lists twenty-six. So the guard called
commonly_get_messages — shipped, documented, and the tool #798 fixed
pagination for — a tool that does not exist, the moment a cue naming it
reached the payload via #815.

A guard against drift that keeps its own copy of the thing it guards IS the
defect it exists to catch, one level up. Same shape as this PR's own lesson
(an extracted cue gone stale against the delivered one) and as ADR-016's rule
that a creation gate must consult the DM predicate rather than a hand-kept
allowlist that happens to agree with it.

Now reads the doc at test time. Added a companion test asserting the
inventory actually loaded — an allowlist that silently reads empty would pass
every cue and prove nothing.

41/41 in this suite.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Aug 5, 2026
)

* fix(ax): the tool-contract guard skipped the strongest cue surface

The guard reads the presets.ts cycles trailer and the inline mention cues.
It did not read backend/services/heartbeatCue.ts — which by ADR-012 s10.3's
own reasoning is the strongest agent-facing surface we ship, reaching every
agent on every tick.

That gap had already been paid for. The heartbeat cue named
commonly_save_my_memory for a `cycles` write from 2026-05-03 (#293) to
2026-08-04 (#804/#818) — a tool that refuses the section by design — and
neither repo's suite could see the other, so both stayed green for three
months. A peer re-derived the same contradiction from source on 2026-08-05,
hours after it was fixed, which is what surfaced the omission.

Adds the cue as a required-tool source. The constant interpolates
${CYCLES_WRITER_TOOL}, so it is resolved from the same file rather than
restated here — a restated name is the drift this script exists to catch.
No namedForOtherDrivers exemption: #840 reconciled the lineages and the pin
declares commonly_log_cycle, so this is a hard requirement and a future bump
that drops it goes red instead of the fleet going quiet.

Two assertions the name-matching check cannot make, because existence is not
capability — the #295 cue would PASS a declares-the-tool check against
today's pin, since commonly_save_my_memory is declared at :526 right above a
description saying `cycles` is intentionally unavailable there:

  - the cue names exactly one writer tool, so a cue that also names a second
    (the #295 shape, and the shape a well-meaning "mention both" edit
    reproduces) fails
  - the escape-hatch clause survives. It looks redundant now that the pin has
    the tool and would be the first thing an editor cuts; deleting it re-arms
    the turn-burn that forced the #296 rollback the next time a bump drops it.

Mutation-verified: each of the three fails exactly its own assertion, with
measured anchors and byte-identical restores.

CLAUDE.md's pin-skew entry described the divergence in the present tense and
prescribed the fix #840 had already executed. It cost a wrong claim in the
sprint pod nine hours later. Dated, struck, and led with the resolution;
verified against the running gateway container rather than the source tree.

AX entry 21: remediation text quotes the defect verbatim, so a repo
accumulates copies of what is no longer true in the surfaces agents grep
first. Six verbatim copies of the dead cue, zero live ones — and the only two
queued events matching it were the peer's own reports about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: the cycles silence is moltbot-scoped, and dated by measurement

The pin-skew entry asserted the skew caused "the fleet's" cycles silence
with an undocumented ~87-day figure. Measured against the memory collection
2026-08-05:

  moltbots (agentName=openclaw)  22 docs with entries, newest 2026-05-24T08:49:56Z
  non-moltbot seats              13 docs with entries, newest same evening

The moltbot cutoff falls on the exact day of the d6e63b2 bundled-skills
bump that dropped commonly_log_cycle — so the root cause is corroborated by
a source independent of the submodule log, rather than inferred from it.

Two corrections this forces. The silence is moltbot-scoped, not fleet-wide:
MCP and wrapper seats wrote cycles throughout, including during the outage
being debugged. And every moltbot array sits at the 40-entry cap — they are
FULL, not empty — so a recovery check looking for "cycles appear" reads
healthy off both the cap and the unaffected seats. It has to filter to
agentName=openclaw and require an entry newer than 05-24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant