State and memory
What the runtime stores per key, how it is bounded, when it is collected — and the durable long-term tier behind it.
StableImplemented, specified, and covered by tests in the repository.
What one key holds
Every agent runs as a single Beam stateful DoFn, and everything it remembers
hangs off one entity key: five state cells and two timers, scoped to that key
alone.
The two timers are worth reading twice. TTL_TIMER is an event-time timer and
HITL_TIMER is a real-time one, so both are set from the same commit but
measured against clocks that can drift apart — which is the seam the
garbage collection section below is about.
Working memory
Working memory is per-key, durable, and race-free by construction — Beam serializes elements per key, so an activation never contends with another activation for the same entity.
async def triage(ctx: ActivationContext) -> Complete:
"""Decide what to do about one event for one entity.
Module-level, not a closure: the DoFn holding the agent is serialized for
the runner, so the agent has to pickle by reference.
"""
ctx.memory.append("recent", ctx.event, max_items=32)
seen = len(ctx.memory.ring("recent"))
response = await ctx.call_model(
LlmRequest(
model_id="fake-1",
messages=[f"event={ctx.event.decode()} seen={seen}"],
tools_schema=None,
sampling_params=None,
)
)
return Complete(output=b"%s:%d" % (response.response, seen))The facade offers scalars and bounded rings:
| Operation | Behavior |
|---|---|
memory.set(name, value) | Write a scalar. |
memory.get(name) | Read a scalar. |
memory.append(name, value, max_items=N) | Append to a ring, evicting oldest past N. |
memory.ring(name) | Read the ring as a list. |
Writes are staged, not applied. They land only when the activation commits — which is why a raising agent leaves no trace of its scratch writes.
The caps, and why they exist
| Limit | Value |
|---|---|
| Blob size | 100 KiB |
| Working memory per key | 1 MiB soft cap, with a compaction hook |
| Replay cache | 64 entries, 6-hour TTL |
The Python SDK provides no MapState and no OrderedListState in user state,
so bounded maps live inside single-value protobuf blobs with explicit LRU
eviction. That design choice is what makes the caps necessary: the whole blob
is read and written per access, so an unbounded blob would turn every
activation into an unbounded read.
Exceeding the soft cap increments a beam_agents.memory/soft_cap_warnings
counter rather than failing the activation.
Garbage collection
TTL_TIMER is an event-time timer. When it fires for a key, that key's
working memory is wiped. This is what bounds state growth: without it, every
key ever seen would keep its memory forever.
A wipe that lands on a key with a live suspension is unrecoverable — the
continuation is gone and nothing can answer it. That case is reported on
.errors with reason ttl_wiped_suspension rather than silently dropped.
Set the TTL through AgentConfig.ttl_ms. The default is one hour.
Staging, committing, and wiping are the three things that can happen to a write, and they are easier to hold together in one picture than in three sections:
State schema and pipeline updates
All keyed state is protobuf with deterministic encoding, never pickle. The
practical consequence is the upgrade rule: additive proto changes only. A
breaking change requires a state_schema_version bump, lazy migration, and a
golden-blob compatibility test.
Long-term memory
Working memory is not the only memory. Behind the facade sits a second,
durable tier — the long-term MemoryStore — reached explicitly through
ctx.memory.longterm and enabled only when AgentConfig.longterm_memory
carries a backend URI. Four backends ship in the memory-stores extra —
Bigtable, Redis, Firestore, and any SQLAlchemy async URL — plus an in-process
memory:// reference store for tests, and every backend passes one shared
conformance suite (tests/memory/stores/_conformance.py).
The two tiers stay deliberately separate — nothing is promoted, demoted, or hydrated between them automatically:
- Working memory is one key in one pipeline, capped and TTL-wiped as above. Long-term rows are per entity, durable across pipelines, and never touched by the runtime's GC — retention is an operator concern, handled with each backend's native mechanism.
savestages an upsert and performs no I/O; staged rows flush only after the agent returns successfully, on the commit tail.searchis an entity-scoped, ordered, bounded key-prefix scan — deliberately not vector or semantic search.- The documented exception to invariant 5 — "idempotent upserts to the
long-term
MemoryStorekeyed by(key, seq)" — is this tier, implemented: each backend applies a write iff the incomingseqis>=the stored one, enforced by that backend's own atomic primitive, so a replayed activation converges on byte-identical rows (tests/semantics/test_longterm_retry_determinism.pyforces exactly that sequence).
Compaction is what keeps a long-lived key under the working-tier caps:
DropOldestCompactor (the default) evicts LRU entries synchronously inside a
memory write, and the opt-in SummarizeCompactor folds a ring's older items
into a summary entry through ctx.call_model, so even compaction's model
calls stay replay-cached.
The full contract — the blind-upsert discipline, provisioning, and retention —
is on the memory-stores page and in
docs/memory.md.
Next
- Correctness invariants — why staging exists.
- The memory-stores page — the durable tier in full.
- The memory-facade spec — the requirements verbatim.
What backs this page
- Symbol
- beam_agents.memory.LongtermMemory
- Symbol
- beam_agents.memory.stores.MemoryStore
- Source
- src/beam_agents/memory/facade.py
- Source
- src/beam_agents/memory/compaction.py
- Source
- src/beam_agents/memory/stores/base.py
- Source
- src/beam_agents/memory/stores/bigtable.py
- Source
- src/beam_agents/memory/stores/firestore.py
- Source
- src/beam_agents/memory/stores/redis.py
- Source
- src/beam_agents/memory/stores/sql.py
- Source
- src/beam_agents/core/dofn.py
- Source
- docs/memory.md
- Specification
- openspec/specs/memory-facade/spec.md
- Specification
- openspec/specs/memory-stores/spec.md
- Test
- tests/memory/test_facade_caps.py
- Test
- tests/memory/test_facade_ring.py
- Test
- tests/memory/test_facade_longterm.py
- Test
- tests/memory/stores/test_inmemory.py
- Test
- tests/core/test_dofn_ttl.py
- Example
- fast_path.py