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:
- Harvesters run as separate threads writing intel to a shared knowledge base
- Multiple game sessions share a global knowledge depot
- Post-run metabolism writes from the game thread to the OmniWiki store
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.