Skip to content
beam-agents
GitHub

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.

What one entity key holdsThe runtime is a single Beam stateful DoFn. Each entity key owns five state cells: MEMORY, a read-modify-write cell holding working memory as one MemoryBlob; CONTINUATION, a read-modify-write cell holding where a suspended activation resumes; LLM_CACHE, a read-modify-write cell holding the bounded replay cache; PENDING, a bag of tool intents waiting for an answer; and SEQ, a combining sum counting activations committed on that key. Two timers hang off the same key, and they run on different clocks. TTL_TIMER is in the event-time (watermark) domain, is re-armed on every commit, fires when the watermark passes its mark, and wipes all five cells for that key. HITL_TIMER is in the real-time (processing-time) domain, is set when a key suspends, fires when real time passes the suspension deadline, and hands the wait to the HITL policy.FIVE STATE CELLS · ONE SET PER ENTITY KEYMEMORYREAD-MODIFY-WRITEworking memory, one MemoryBlobCONTINUATIONREAD-MODIFY-WRITEwhere a suspended activation resumesLLM_CACHEREAD-MODIFY-WRITEthe bounded replay cachePENDINGBAGtool intents waiting for an answerSEQCOMBINING SUMactivations committed on this keyTWO TIMERS · SET FROM THE COMMIT, FIRED OUTSIDE ANY ACTIVATIONEVENT TIMEWATERMARK DOMAINTTL_TIMERRE-ARMED ON EVERY COMMITTTL MARKfires when the watermark passes the markwipes every cell above, for that keyREAL TIMEPROCESSING-TIME DOMAINHITL_TIMERSET WHEN A KEY SUSPENDSSUSPENSION DEADLINEfires when real time passes the deadlinehands the wait to the HITL policy
Everything here is scoped to one entity key; Beam serializes activations per key, so no two of them contend for it. The two timers are the part worth staring at: both are decided in the same commit, and then measured against different clocks that nothing keeps in step.

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))
website/examples/fast_path.py (region: agent) — executed by the repository’s offline test tier.

The facade offers scalars and bounded rings:

OperationBehavior
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

LimitValue
Blob size100 KiB
Working memory per key1 MiB soft cap, with a compaction hook
Replay cache64 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:

The life of a staged writeAn agent's memory.set and memory.append calls are staged in the memory facade inside the worker process; nothing is in Beam state yet. If the activation returns, the runtime commits: MEMORY, LLM_CACHE, CONTINUATION, PENDING and SEQ are written and the timers are set or cleared, atomically with the bundle. If it raised or timed out, the staged writes vanish and a dead letter is emitted on the errors output with reason activation_error or activation_timeout; nothing reached Beam state, so there is nothing to roll back. Later, when the watermark passes the TTL mark, TTL_TIMER wipes every cell for the key unconditionally and the key holds nothing again. If a continuation was still live at that moment, the same firing also emits a record on the errors output with reason ttl_wiped_suspension: the continuation is gone, so nothing can ever answer that suspension.WHILE ONE ACTIVATION RUNSactivationAGENT CODE RUNSset / appendstaged writesNOT IN BEAM STATE YETRAISED OR TIMED OUTcommitACTIVATION RETURNEDdiscardSTAGED WRITES VANISHatomic with the bundlekeyed stateDURABLE, PER KEYMEMORY · LLM_CACHECONTINUATION · PENDING · SEQ+ TIMERS SET OR CLEARED.errorsDEAD LETTERreason=activation_erroror activation_timeoutnothing reached Beam state,so there is nothing to roll backLATER · WHEN THE TTL MARK PASSESTTL_TIMEREVENT-TIME MARKwipeEVERY CELL, UNCONDITIONALLYthe key holdsnothing againIF STILL SUSPENDED.errorsDEAD LETTERreason=ttl_wiped_suspensionthe continuation is gone;nothing can answer it now
Staged writes are not state. They become state at the commit, all at once, and a failed activation simply never gets there. The TTL wipe is the one path that destroys committed state, and it is unconditional — which is why it reports the suspension it destroyed on the way past.

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.
  • save stages an upsert and performs no I/O; staged rows flush only after the agent returns successfully, on the commit tail. search is 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 MemoryStore keyed by (key, seq)" — is this tier, implemented: each backend applies a write iff the incoming seq is >= 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.py forces 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

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