Q-03: Memory Scopes โ€” LangGraph Persistence Mapping

Status: ACTIVE (specification) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-12T03:45:00Z Session: Mapping long-term/global, unit-specific, and node-local memory to LangGraph persistence primitives

The Key Insight: State Is Thread-Scoped

LangGraph state is tied to thread_id. Each thread has its own independent state. If Rif (thread "unit-rif") completes hex_06, Echo (thread "unit-echo") doesn't see it โ€” they have different state copies.

This forces a decision: one shared thread or per-unit threads?

Recommendation: Single Thread, Differentiated State

One thread_id for the entire game. All 3 units share the same state. Each unit writes to its own sub-keys. Global state is visible to all units automatically.

config = {"configurable": {"thread_id": "game-session-001"}}

# Rif moves โ€” writes to state
graph.invoke({"unit": "rif", "action": "move", "target": "17"}, config)

# Echo moves โ€” reads the same state (sees Rif's changes)
graph.invoke({"unit": "echo", "action": "move", "target": "22"}, config)

State is checkpointed after every invoke. SqliteSaver serializes writes (fine for turn-based). All units read the same state. No cross-thread sync needed.

Memory Scope Mapping

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  STATE (single thread, shared by all 3 units)               โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€ GLOBAL / LONG-TERM โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚  nodes[hex_id].status        โ† completed? deepened?    โ”‚ โ”‚
โ”‚  โ”‚  nodes[hex_id].phase         โ† planning/design/...     โ”‚ โ”‚
โ”‚  โ”‚  nodes[hex_id].prerequisites โ† what must complete first โ”‚ โ”‚
โ”‚  โ”‚  mission                     โ† objective, criteria     โ”‚ โ”‚
โ”‚  โ”‚  decisions                   โ† upstream choices made   โ”‚ โ”‚
โ”‚  โ”‚  routed_context              โ† curated cross-node ctx  โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€ UNIT-SPECIFIC (TRAILING) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚  units[rif].position          โ† which hex              โ”‚ โ”‚
โ”‚  โ”‚  units[rif].config            โ† model, harness, tools  โ”‚ โ”‚
โ”‚  โ”‚  units[rif].trail_context     โ† accumulated context    โ”‚ โ”‚
โ”‚  โ”‚  units[rif].context_access    โ† what it can see        โ”‚ โ”‚
โ”‚  โ”‚  units[rif].context_block     โ† what it can't see      โ”‚ โ”‚
โ”‚  โ”‚  units[rif].history           โ† where it's been        โ”‚ โ”‚
โ”‚  โ”‚  units[echo]...               โ† same keys, diff values โ”‚ โ”‚
โ”‚  โ”‚  units[sherpa]...             โ† same keys, diff values โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€ NODE-LOCAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚  nodes[17].output_summary    โ† sections + status       โ”‚ โ”‚
โ”‚  โ”‚  nodes[17].output_locale     โ† pointer to wiki page    โ”‚ โ”‚
โ”‚  โ”‚  nodes[17].player_prompts    โ† corrections accumulated โ”‚ โ”‚
โ”‚  โ”‚  staged_configs[17]          โ† poller โ†’ unit handoff   โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Why Single Thread Works

Concern Resolution
Concurrent writes SqliteSaver serializes. Turn-based = few writes/sec. Negligible.
State key collisions Each unit writes to units[unit_id]. No overlap.
Unit A sees Unit B's state By design โ€” that's the point. Unit A needs to see Unit B's completed nodes for gating.
State size 36 nodes + 3 units with full history โ‰ˆ 50KB. Trivial.
Checkpointer One SqliteSaver DB. One thread. Named checkpoints for save/load.

Reducer Design for Shared State

class GameState(TypedDict):
    # โ”€โ”€ Global: merge reducer โ€” each invoke only updates what changed โ”€โ”€
    nodes: Annotated[dict, merge_node_updates]
    # merge_node_updates: for each node_id in the update dict,
    # deep-merge the update into the existing node dict.
    # Rif completes hex_17 โ†’ only hex_17 changes. All other hexes unchanged.

    # โ”€โ”€ Unit-specific: default reducer (overwrite) โ”€โ”€
    units: dict
    # Each unit writes its full dict on every invoke.
    # Rif writes units["rif"] = {...}. Echo's entry untouched.
    # Default reducer overwrites the "rif" key but leaves "echo" alone.
    # Wait โ€” default reducer on `units` would overwrite the ENTIRE units dict.
    # Solution: units uses a merge reducer that only updates sub-keys.

    # โ”€โ”€ Accumulating keys: append reducers โ”€โ”€
    node_player_prompts: Annotated[dict, merge_prompts]
    # Appends new prompts to per-node lists. Never overwrites.
    # "17": ["prompt1", "prompt2"] โ†’ append "prompt3" โ†’ ["prompt1", "prompt2", "prompt3"]

    # โ”€โ”€ Simple keys: default reducer (overwrite) โ”€โ”€
    mission: dict       # overwritten when mission changes
    decisions: list     # overwritten when new decision added (or use append reducer)

Key reducer design rule: dicts at the top level (nodes, units) need merge reducers so that one unit's write doesn't wipe another unit's state. Lists that accumulate (player_prompts, history, trail_context) need append reducers. Simple scalars/strings overwrite.

Store (Cross-Thread) โ€” When to Use

LangGraph's Store (store.put, store.get, store.search) is for cross-thread, cross-session persistence. Not needed for the prototype since we're using a single thread. Would become relevant if:

For the prototype: Store is deferred. Everything lives in graph state.


Decision: Pre-Filtered Context (Approach A)

Locked for prototype.

fetch_context() is a deterministic list lookup. The unit's context_access list gates exactly what Hermes receives. Curation happens upstream (player via routing menu, or curation agent). The LLM receives only its allowed context โ€” no self-selection, no extra latency, no second Hermes call.

Why not interpretive (Approach B): Self-selecting context via an LLM call (ยซ Hermes, what do you need from this catalogue? ยป) adds a second agent call per node, increases latency, makes debugging harder (two calls to trace instead of one), and isn't necessary to prove the graph architecture works. Approach B can be swapped in later by replacing fetch_context() with llm_select_context() โ€” everything else in the graph stays identical.