Memory facade
Working memory: scalars, bounded rings, staging, and the caps that keep per-key state bounded.
StableImplemented, specified, and covered by tests in the repository.
Memory is the working-memory API an agent sees: get/set/delete for
scalars, and append/ring for bounded, ordered histories. It is constructed
once per activation over a MemoryBlob read from keyed state, and it hands back
a new blob at the end.
It stages, it does not write
The facade mutates in-memory data only. It performs no Beam state I/O and reads no wall-clock — the current time is passed in. Nothing an agent writes reaches durable state until the activation succeeds and the runtime commits the staged blob with the rest of the bundle.
That is what makes a failed activation leave no trace: an agent that appended
to a ring, called a model, and then raised has committed nothing. The blob it
built is discarded, and the key looks exactly as it did before. A dirty flag
stays False until the first real mutation, so an activation that only read
memory does not force a pointless state write.
Rings, and why they are bounded by default
append(key, item, max_items=64) maintains an ordered ring inside the entry's
value bytes. When an append would exceed the bound, the oldest items are
dropped. ring(key) returns oldest-to-newest, and an absent key is an empty
ring rather than an error.
The bound has a default because unbounded conversation history is the standard
way a stateful agent turns into an outage: state grows per key, per event,
forever, and the failure arrives long after the code that caused it shipped.
Kind mixing fails fast for the same reason — append on a scalar key, or
ring on a scalar, raises TypeError rather than reinterpreting bytes.
The caps
Size accounting is incremental and exact: size_bytes is maintained across
every mutation without rescanning, and always equals a from-scratch
recomputation.
- At 75% of the hard cap the facade logs a warning once, increments
beam_agents.memory:soft_cap_warnings, and invokes the compaction hook. The write still succeeds. - Above the 1 MiB hard cap it invokes compaction once and, if that did not
bring the total back under, raises
MemoryOverflownaming the key, the attempted size, and the cap — without applying the triggering write.
A Compactor receives the facade itself, so a compaction strategy mutates
memory only through the same guarded API; cap enforcement is suspended during
compact() so a compactor cannot re-enter its own trigger.
Related
- State and memory — how this fits the activation lifecycle.
- Wire schemas —
MemoryBlob, the persisted form. - Metrics — where
soft_cap_warningsshows up. - Memory stores — the durable long-term backends behind the facade.
Published verbatim from openspec/specs/memory-facade/spec.md — 11 requirements, 26 scenarios. Each scenario is the source a test is derived from and named after.
Purpose
TBD - created by archiving change add-memory-facade. Update Purpose after archive.
Requirements
Requirement: Facade stages mutations over an in-memory MemoryBlob
beam_agents.memory SHALL provide a Memory facade constructed per activation from an optional MemoryBlob and a caller-supplied now_ms clock value. The facade SHALL mutate only in-memory data — it MUST NOT perform any Beam state I/O or read wall-clock time — and SHALL expose to_blob() returning a MemoryBlob with state_schema_version set to 1, entries emitted in LRU order (least-recently-used first), and total_value_bytes populated. A dirty property SHALL be False until the first successful mutation or LRU-updating access.
Scenario: Blob round-trips through the facade
- WHEN a
Memoryis constructed from a blob containing existing entries andto_blob()is called without any access - THEN the returned blob has field-equal entries in the same order, the same
total_value_bytes,state_schema_version1, anddirtyisFalse
Scenario: Fresh facade produces a versioned empty blob
- WHEN a
Memoryis constructed with no blob andto_blob()is called - THEN the result has
state_schema_version1, no entries, andtotal_value_bytes0
Scenario: Rejected mutation leaves staged state unchanged
- WHEN any mutation raises (
MemoryOverfloworTypeError) - THEN subsequent reads and
to_blob()reflect the state as of before the failed call (except compaction effects, per the hard-cap requirement)
Requirement: Scalar get/set/delete with LRU stamping
Memory.set(key, value) SHALL store arbitrary bytes (including empty) under a string key, get(key) SHALL return the stored bytes or None if absent, and delete(key) SHALL remove the entry (idempotent on absent keys). Every get hit, set, and append SHALL stamp the entry's last_access_ms with the facade's now_ms and move it to most-recently-used position, setting dirty.
Scenario: Set then get round-trips bytes
- WHEN
set("k", b"v")is followed byget("k") - THEN
getreturnsb"v", andget("missing")returnsNone
Scenario: Access order is persisted for LRU
- WHEN keys
a,b,care set and thenais read viaget - THEN
to_blob()emits entries in orderb,c,awitha.last_access_msequal to the facade'snow_ms
Scenario: Delete removes and is idempotent
- WHEN
set("k", b"v"),delete("k"),delete("k")are called in sequence - THEN no error is raised,
get("k")returnsNone, andtotal_value_bytesreflects the removal
Requirement: Append maintains a bounded ring per key
Memory.append(key, item, max_items=64) SHALL maintain an ordered ring of bytes items under one key, encoded inside the entry's value bytes with no proto schema change. When an append would exceed max_items, the oldest items SHALL be dropped until the bound holds. ring(key) SHALL return the items in oldest-to-newest order (empty for absent keys). Kind mixing SHALL fail fast: append on a scalar key and get or ring on the wrong kind SHALL raise TypeError; set SHALL be permitted to overwrite a ring with a scalar.
Scenario: Appends preserve order and survive blob round-trip
- WHEN items
b"1",b"2",b"3"are appended to"log"and the facade'sto_blob()output is loaded into a newMemory - THEN
ring("log")on the new facade returns(b"1", b"2", b"3")
Scenario: Ring drops oldest at capacity
- WHEN four items are appended to a key with
max_items=3 - THEN
ringreturns the last three items in order andtotal_value_bytesno longer accounts for the dropped item
Scenario: Kind mixing raises
- WHEN
set("k", b"v")is followed byappend("k", b"x"), orappend("r", b"x")is followed byget("r") - THEN each wrong-kind call raises
TypeErrorand the stored entry is unchanged
Requirement: Size accounting is incremental and exact
The facade SHALL maintain total_value_bytes as the exact sum of stored entry value sizes (including ring encoding overhead) across every mutation, without rescanning all entries, and SHALL expose it via a size_bytes property that always equals a from-scratch recomputation over the current entries.
Scenario: Accounting matches recomputation under mixed operations
- WHEN an arbitrary (property-based) sequence of
set,delete, andappendoperations is applied - THEN after every operation
size_bytesequals the sum of stored value lengths, andto_blob().total_value_bytesequalssize_bytes
Requirement: Soft cap warns and triggers compaction at 75%
When a mutation lands with size_bytes at or above 75% of the 1 MiB hard cap (786 432 bytes), the facade SHALL log a WARNING, increment the Beam metrics counter beam_agents.memory:soft_cap_warnings, and invoke the configured compaction hook. The warning and counter SHALL fire at most once per facade instance; writes at or above the soft cap but under the hard cap SHALL still succeed.
Scenario: Crossing the soft cap warns once and compacts
- WHEN writes push
size_bytesfrom below to at or above 786 432 bytes, followed by further writes still above the threshold - THEN exactly one warning is logged, the counter is incremented once, the compactor's
compactis invoked, and every write under the hard cap succeeds
Scenario: No compactor configured is not an error
- WHEN the soft cap is crossed on a facade constructed without a compactor
- THEN the warning and counter still fire and the write succeeds
Requirement: Hard cap raises MemoryOverflow at 1 MiB
A mutation whose prospective size_bytes exceeds 1 048 576 bytes SHALL first invoke the compaction hook (if configured) once; if the prospective total still exceeds the cap, the facade SHALL raise MemoryOverflow — exported from beam_agents.memory and carrying the key, attempted size, and cap — without applying the triggering write. Compaction effects applied before rejection SHALL persist in the staged blob.
Scenario: Overflowing write is rejected atomically
- WHEN a
setwould pushsize_bytespast 1 048 576 bytes on a facade with no compactor - THEN
MemoryOverflowis raised,getof the target key returns its prior value, andsize_bytesis unchanged
Scenario: Compaction that frees space lets the write succeed
- WHEN an overflowing
setoccurs with a compactor configured that deletes enough entries to fit the write - THEN no exception is raised, the write is applied, and the compactor's deletions are reflected in
to_blob()
Scenario: Compaction that frees too little still rejects
- WHEN an overflowing
setoccurs and the compactor frees some but not enough space - THEN
MemoryOverflowis raised, the triggering write is not applied, and the compactor's deletions persist
Requirement: Compaction hook is a stable protocol with a safe default
beam_agents.memory SHALL export a Compactor protocol with a single method compact(memory: Memory) -> None that receives the facade itself, so compaction strategies mutate memory only through the guarded API. Cap enforcement SHALL be suspended during compact() to prevent re-entry, and a facade constructed without a compactor SHALL behave as if a no-op compactor were configured. An exception raised by a compactor SHALL propagate unmodified.
Scenario: Compactor mutates through the facade with correct accounting
- WHEN a compactor's
compactdeletes entries and rewrites one key viasetduring a soft-cap invocation - THEN
size_bytesafterwards equals a from-scratch recomputation and no nested compaction is triggered by the compactor's own writes
Scenario: Compactor exceptions propagate
- WHEN a compactor raises a custom exception during hard-cap handling
- THEN that exception (not
MemoryOverflow) propagates to the caller
Requirement: Long-term store access is explicit via memory.longterm
The Memory facade SHALL expose a longterm property returning an activation-scoped long-term handle when AgentConfig.longterm_memory is configured, and raising an actionable error naming that configuration field when it is not. Access SHALL be explicit only: no working-tier operation (get/set/delete/append/ring), no compaction path, and no runtime code path SHALL consult the long-term store implicitly, and the facade's own methods SHALL remain free of external I/O — the handle is the only surface that reaches a store. The handle SHALL be constructed per activation with the activation's frozen entity_key, seq, and now_ms, over a store client built once per DoFn instance in setup() and closed in teardown().
Scenario: Unconfigured pipelines behave exactly as today
- WHEN an agent touches only working memory on a pipeline with no
longterm_memoryconfigured - THEN the activation completes with no store constructed and no external I/O, and accessing
ctx.memory.longtermraises an error namingAgentConfig.longterm_memory
Scenario: Working-tier operations never reach the store
- WHEN an agent performs working-memory reads and writes, including a compaction-triggering write, on a pipeline with a long-term store configured
- THEN the store records no operation — only explicit
longtermcalls reach it
Requirement: Long-term saves stage in the activation and flush only on success
longterm.save(key, value) SHALL perform no store I/O when called; it SHALL stage an upsert record stamped with the activation's seq and now_ms. Staged upserts SHALL be flushed through the store only after the agent returns successfully, in the commit tail before the DoFn commits the bundle-atomic effects; a failed or timed-out activation SHALL flush nothing. A flush failure SHALL fail the activation closed (routed to the errors output, nothing committed). Because a replayed activation deterministically re-stages byte-identical upserts and the store's upsert is seq-guarded, duplicate flushes from bundle retries SHALL converge on identical rows — this is the sanctioned invariant-5 exception, and it is the only in-pipeline external write path.
Scenario: A failed activation flushes nothing
- WHEN the agent raises after staging a long-term save
- THEN the element is routed to errors, the store records no write, and keyed state is unchanged
Scenario: A bundle retry across a completed flush converges
- GIVEN an activation whose flush succeeded but whose bundle commit was forced to fail (chaos harness)
- WHEN the bundle retries and the activation replays
- THEN the retry stages byte-identical upserts, the re-flush applies via the equal-seq guard, the stored rows are byte-identical to the first attempt's, and the activation's intents are byte-identical
Scenario: A flush failure fails the activation closed
- WHEN the store raises during the commit-tail flush
- THEN the activation fails, the element is routed to errors with a typed error record, and no output, intent, or state mutation commits
Requirement: Long-term reads are point-in-time with a read-your-writes overlay
longterm.load and longterm.search SHALL execute inline within the activation and SHALL consult the activation's staged upserts first, merging them over store results, so an agent observes its own writes in program order before any flush. Reads SHALL be documented as point-in-time, and the replay discipline SHALL be normative: a long-term write MUST be computed from replay-stable inputs (the event, working memory, replay-cached model output) and MUST NOT be conditioned on a same-activation long-term read of the same key. A read failure SHALL propagate and fail the activation closed.
Scenario: Staged saves are visible to reads before any flush
- WHEN an agent stages
longterm.save("profile", v)and then callslongterm.load("profile")andlongterm.searchwith a matching prefix in the same activation - THEN both reads reflect the staged record, and the store has still performed no write
Scenario: Blind upserts keep replay path-stable
- GIVEN an agent following the discipline (its saves are computed from the event and working memory, never from a same-key long-term read)
- WHEN the retry-determinism chaos gate forces a bundle retry after a completed flush
- THEN the replayed activation emits byte-identical intents and stages byte-identical upserts whether or not its reads observe the first attempt's flushed rows
Requirement: Read-only LRU-order enumeration for compaction strategies
Memory SHALL expose keys() returning a tuple of stored key names in LRU order (least-recently-used first, matching the order to_blob() persists) and entry_size(key) returning the stored encoded value size in bytes for an existing key (raising KeyError for an absent one). Neither call SHALL re-stamp last_access_ms, reorder entries, or set dirty — a compaction strategy must be able to iterate candidates without perturbing the eviction order it is iterating. Both SHALL reflect staged (uncommitted) mutations, and entry_size values SHALL sum to size_bytes across all keys.
Scenario: keys() reports LRU order without dirtying the facade
- WHEN keys
a,b,care loaded from a blob,bis read viaget, andkeys()is then called on a freshly-loaded copy versus the mutated facade - THEN the fresh facade's
keys()returns(a, b, c)withdirtystillFalse, and the mutated facade's returns(a, c, b); callingkeys()itself changes neither ordering nordirty
Scenario: entry_size does not perturb eviction order
- WHEN
entry_sizeis called for the least-recently-used key and a subsequent LRU-order eviction pass runs - THEN that key is still evicted first, and the sum of
entry_sizeoverkeys()equalssize_bytes
What backs this page
- Specification
- openspec/specs/memory-facade/spec.md
- Test
- tests/memory/test_facade_ring.py