docs(adr): ADR-012 memory propagation + event-payload injection - #276
Conversation
…s + payload injection Builds on ADR-003 (Memory as Kernel Primitive). Closes the cross-session-context gap that landed when agent-dm shipped (d5b7198): a session in agent-dm pod-A doesn't see the sibling session's work in team pod-B. Two halves, both kernel-side, both runtime-agnostic: 1. New typed `system_exchanges` section in the ADR-003 envelope — structured entries (kind, surfacePodId, peers, takeaway) instead of a markdown blob. Section is read-only from the agent's perspective; the section name isn't in `commonly_save_my_memory` writable enum, so write-rejection is structural rather than markdown-parsing-based. 2. Two new fields on the existing CAP event payload schema — `memoryRevision` + `memoryDigest`. No new HTTP verbs. Every runtime that reads its event payload gets memory propagation for free. Self-review pass via code-reviewer agent caught two architectural issues, both addressed before this draft: - v0 was building on the deprecated `content` blob from ADR-003. Retargeted to the typed `sections` envelope that ADR-003 established as the v2 schema. - v0 claimed ack-time `lastSeenRevision` bumps were idempotent, but `agentEventService.acknowledge` has no idempotency guard today. Replaced the claim with a real spec: status-gated `findOneAndUpdate` + monotone `$max` bump, so dup acks fire the bump at most once and out-of-order acks still converge. Hard decisions made (not punted to Open Questions): - Cross-pod-mention trigger DROPPED for v1 — too noisy in multi-agent team pods. v1.x reconsider with strict non-member filter. - Eviction is COUNT-bounded (50 entries), not time-bounded. Time-based loses important low-frequency entries. v1.x may layer hybrid. - NO admin/operator viewer for `system_exchanges` in v1 without an explicit privacy-vs-audit ADR addendum. Dual-write-to-both-peers preserves agent isolation only if no one bypasses the rule. - Driver-side adoption is SHOULD not MUST. Agents on un-adopted runtimes operate in degraded mode (lazy reads via tool call) — degraded, not broken. - ADR-003 invariant 8 (cross-writer dedup invalidation) gets a carve-out (8a) for system writes — they don't touch `lastSyncKey`, so driver-side sync dedup stays correct. Phase-1 estimate honest at 3-4 days (was 2 in v0; structural section migration adds work). Total v1 ETA 5.5-6.5 days for Phases 1-3, plus per-runtime adoption (parallelizable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
samxu01
left a comment
There was a problem hiding this comment.
Verified the load-bearing technical claims against the codebase — acknowledge lacks idempotency as described (agentEventService.ts:828–890), the sections envelope shape composes cleanly, and the enqueueDmEvent / bot_loop_guard / tasksApi complete-handler callsites all exist. Spec is in good shape.
Six inline suggestions, ordered roughly by leverage:
- Phase pointer for the new tool-section row is wrong (line 85) — needs to land in Phase 1, not Phase 4, or the lazy-read fallback doesn't work day one.
- Writable-section enum is asserted as if pre-existing (line 91) — it isn't; Phase 1 needs to add it.
memoryRevisionAtDelivery"captured at fetch-time" is underspecified (line 124) — the polling path is non-mutating today; persisting this field requires a real semantic change tolist(). Pick a model.- Sizing math is internally inconsistent (line 110) — 5 fully-populated entries blow the 1.5KB cap.
takeawayderivation is hand-wavy (line 161) — make the rule concrete for multi-turn / multi-paragraph cases.- Cap enforcement needs the atomic Mongo idiom (line 167) — concurrent triggers will lost-update under read-modify-write.
None block merging this as Proposed; (1)–(3) are worth tightening before Phase 1 starts.
Generated by Claude Code
| - **Typed reads** for the digest builder — no string slicing. | ||
| - **Future LLM condensation** can rewrite `takeaway` per-entry without touching the rest of the envelope. | ||
|
|
||
| ADR-003 §"Tool surface" gets one row added (Phase 4 of this ADR; see §Phasing): |
There was a problem hiding this comment.
Phasing pointer is wrong. This says the new commonly_read_my_memory(section: 'system_exchanges') row lands in "Phase 4 of this ADR," but Phase 4 in §Phasing is "Driver-side adoption" — it never mentions extending the tool's section enum. The lazy-read fallback you describe in §3 ("agents that do neither — bump fires lazily when they read memory") doesn't function until the enum accepts 'system_exchanges', so this needs to land in Phase 1 alongside the schema field. Either move the row to Phase 1 here, or add an "enum extension" bullet to Phase 4.
Generated by Claude Code
| |---|---| | ||
| | `commonly_read_my_memory(section: 'system_exchanges')` | Returns the structured entries. Read-only; agent cannot write. | | ||
|
|
||
| The existing `commonly_save_my_memory(section, content, ...)` tool **rejects** writes to `system_exchanges` at the API layer — section name is not in the writable enum. |
There was a problem hiding this comment.
Writable-section enum is proposed, not pre-existing. Phrasing reads as if commonly_save_my_memory already enforces a section enum at the API layer — I couldn't find one in the current backend (no writableSections / explicit allow-list in agentMemoryService.ts). Phase 1 needs to add this enforcement explicitly, not rely on the description as if the structural rejection is already in place. Suggest reword: "...is rejected at the API layer; Phase 1 adds the writable-section allow-list (long_term, dedup_state, shared, daily, relationships) that excludes system_exchanges."
Generated by Claude Code
| Backend tracks `AgentMemory.lastSeenRevision: number`. Bumping is **idempotent by construction** (see §Acknowledgement semantics below). `memoryDigest` returns entries with `revision > lastSeenRevision`, capped at 1.5KB total serialized JSON. | ||
|
|
||
| Sizing notes: | ||
| - Default cap: **1.5KB serialized digest, OR 5 entries, whichever hits first**. Byte cap dominates for noisy bursts; entry cap dominates for steady state. |
There was a problem hiding this comment.
Sizing math is tight, possibly inconsistent. Each entry: ts (~24B) + kind (~25B) + surfacePodId (24B) + surfaceLabel (~30B) + peers[] (~40B) + takeaway (≤280B) + JSON overhead ≈ 420–450 bytes. 5 fully-populated entries ≈ 2.1KB+, well above the 1.5KB cap. Either bump the byte cap to ~2.5KB or drop the "5 entries" half — claiming both is misleading because the byte cap will dominate every time the takeaway is non-trivial. Same applies to the "~600 tokens per event" estimate one line down — that's the cap, not steady state.
Generated by Claude Code
| { | ||
| ..., | ||
| status: 'pending' | 'delivered' | 'acked', // existing field; new terminal state | ||
| memoryRevisionAtDelivery: number | null, // captured at fetch-time |
There was a problem hiding this comment.
"Captured at fetch-time" is underspecified — does the doc get mutated on list? The current polling path is agentEventService.list() (agentEventService.ts:796), which is a non-mutating find over status: 'pending'. To persist memoryRevisionAtDelivery on the doc you'd have to convert the list path to either a findAndModify-style atomic update or follow-up bulk write — that's a real semantic change to the polling contract (e.g., what happens when two pollers race the same event?).
Alternative: compute memoryRevisionAtDelivery as a per-response payload field that is NOT persisted on the doc — but then the ack path can't read it back from the doc, so the agent ack would need to echo the revision number, opening a tampering surface.
Pick one and spell out the implication in this section. This is the load-bearing detail for Phase 2.
Generated by Claude Code
|
|
||
| | Trigger | Source | Recipients | Entry shape | | ||
| |---|---|---|---| | ||
| | `agent-dm-conclusion` | `agentMessageService.postMessage` when `content === 'NO_REPLY'` in an `agent-dm` pod | both peers | `kind: 'agent-dm-conclusion'`, takeaway = last non-NO_REPLY content from sender (truncated 280) | |
There was a problem hiding this comment.
takeaway derivation is hand-wavy. "Last non-NO_REPLY content from sender" — for a 5-turn DM concluding on turn 5, is the takeaway turn-4 alone, or some condensation of turns 1–4? Multi-paragraph turn N — truncate from start, middle-elide, or take the first sentence? Suggest making it concrete in v1: "the immediately-preceding non-NO_REPLY message from the same sender, head-truncated to 280 chars (with … suffix on truncation)." Anything fancier waits for v2 LLM-condense.
Generated by Claude Code
|
|
||
| **Cross-pod-mention is dropped as a v1 trigger** (per reviewer §Important). Recording every mention in a multi-agent team pod fills the entry cap with structural noise faster than real DM exchanges. v1.x will reconsider it with a strict filter (only when the *target* agent is NOT in the source pod, i.e. genuinely new context). v1 ships without it. | ||
|
|
||
| Writes are append-only via `AgentMemoryService.appendSystemExchange(agent, instance, entry)`. Cap enforcement is on write — when adding entry N+1 past the cap, drop the oldest. |
There was a problem hiding this comment.
Cap enforcement on write needs the Mongo idiom spelled out. Two concurrent appendSystemExchange calls in the same tick (e.g., a DM-conclusion firing while a task-completed lands for the same agent) will lost-update each other under naïve read-modify-write. Specify the atomic shape:
await AgentMemory.updateOne(
{ agentName, instanceId },
{
$push: {
'sections.system_exchanges.entries': {
$each: [entry],
$position: 0,
$slice: 50,
},
},
$inc: { revision: 1 },
$currentDate: { 'sections.system_exchanges.updatedAt': true },
},
);$push+$position: 0+$slice: 50 keeps the most-recent-first invariant atomically; $inc: { revision: 1 } is the monotone bump consumed by memoryDigest.
Generated by Claude Code
Six concrete findings from the inline pass, all addressed: 1. Phasing pointer for the `commonly_read_my_memory(section: 'system_exchanges')` tool row was wrong — said "Phase 4" but Phase 4 is driver adoption, doesn't touch the tool surface. The lazy-read fallback in §3 depends on this enum row; moved to Phase 1 so it lands with the schema. 2. Writable-section enum was phrased as if it pre-exists in agentMemoryService. It doesn't. Reworded to make Phase 1 add the allow-list explicitly (long_term, dedup_state, shared, daily, relationships, soul, runtime_meta — excluding system_exchanges) with a 403 + structured reason on rejection. 3. Sizing math was inconsistent — "1.5KB OR 5 entries whichever first" admits at most ~3 worst-case entries before hitting the byte cap. Replaced with hard 2.5KB byte cap, dropped the entry-count half. Per-entry footprint spelled out (~420-450B worst case). "~600 tokens" replaced with "up to ~750 at the cap, far less in steady state, gated on revision change." 4. "Captured at fetch-time" for memoryRevisionAtDelivery was underspecified — today's polling is non-mutating list(). Phase 2 converts the polling path to atomic findOneAndUpdate that transitions pending→delivered AND captures memoryRevisionAtDelivery in the same operation. Two pollers race the same event, only one wins the transition + capture (matches existing at-most-once delivery contract). Documented the rejected alternative (response-only field + agent echoes on ack) and why — tampering surface. 5. takeaway derivation was hand-wavy. Specified concretely: "immediately-preceding non-NO_REPLY message from the same sender, head-truncated to 280 chars with `…` suffix." No multi-turn condensation in v1. 6. Cap enforcement on write needed the atomic Mongo idiom spelled out — concurrent appendSystemExchange calls would lost-update under naïve RMW. Spelled out the $push + $position:0 + $slice:50 + $inc shape so single-document atomicity serializes concurrent triggers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Builds on ADR-003. Closes the cross-session-context gap that surfaced when agent-dm shipped (#275 /
d5b7198e3c): an agent's session in agent-dm pod-A doesn't see what its sibling session in team pod-B did.Two halves, both kernel-side, both runtime-agnostic:
1. New typed
system_exchangessection in the ADR-003 envelope. Structured entries (kind,surfacePodId,peers,takeaway), not a markdown blob. Section is read-only from the agent's perspective — the name isn't incommonly_save_my_memory's writable enum, so write-rejection is structural rather than markdown-parsing-based. Visibility is hard-coded'private'at the schema level, preserving ADR-003's agent-isolation rule even though the platform dual-writes to both peers in an exchange.2. Two new fields on the existing CAP event payload schema —
memoryRevision+memoryDigest. No new HTTP verbs, no new tool surface. Every runtime that reads its event payload gets memory propagation for free.What's NOT in the ADR (deliberate)
commonly_ask_agent, not memory peeks).system_exchanges(would bypass agent-isolation; needs a privacy-vs-audit ADR addendum first).system_exchangeswould re-open the privacy hole).Self-review pass
Ran the code-reviewer agent on a v0 draft. Verdict was "request changes." Two critical architectural issues, four important ones, three real questions. All addressed before this PR:
contentblob; ADR-003 moved to typedsectionssectionsenvelope. Section-level ACL replaces markdown parsing.agentEventService.acknowledgedoesn't actually havesystem_exchanges.Test plan
agentEventService.ts.🤖 Generated with Claude Code