Q-03: LangGraph Graph Specification — Outline & Ambiguities

Status: ACTIVE (specification) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-12T03:30:00Z Session: Mapping all gameplay concepts to LangGraph primitives. Flagging ambiguities.


Clean Mappings (No Ambiguity)

36-Node Hex Grid

36 graph.add_node("hex_00", node_function) calls. Each node is a function that calls Hermes, produces output, writes summary, and calls interrupt().

Hex Adjacency Edges

Static edges computed from grid position. Hex (row 2, col 3) connects to up to 6 neighbors. ~150-180 edges total. No conditional logic needed for movement — adjacency is geometry.

# Computed, not hand-crafted:
for hex_id in range(36):
    for neighbor in get_hex_neighbors(hex_id):
        graph.add_edge(f"hex_{hex_id:02d}", f"hex_{neighbor:02d}")

3 Units → Per-Unit Threads

Each unit gets its own thread_id. Independent invoke() calls. Unit A's node activates; Unit B's node doesn't know it happened.

graph.invoke(input, {"configurable": {"thread_id": "unit-rif"}})
graph.invoke(input, {"configurable": {"thread_id": "unit-echo"}})

Interrupt at Each Node

Every node function calls interrupt() after Hermes produces output. Player sees chain of thought + summary. Chooses: accept, correct, deepen, redirect, config, route.

Output Summaries

Node writes structured sections to state["nodes"][node_id]["output_summary"]. Section = {header, body, status}.

Access Lists

state["unit"]["context_access"] and context_block. Node reads them, calls fetch_context(). Only allowed sources are fetched.

Player Prompts (Corrections)

state["node_player_prompts"][node_id] — list with operator.add reducer. Accumulates across turns. Never overwrites.

Staged Configs (Poller → Unit)

state["staged_configs"][node_id] — poller writes, unit reads on next invoke. Clean handoff.

Routed Context (Curation → Target)

state["routed_context"][target][from_source] — curation agent writes. Target's access list includes it.


Ambiguities (Multiple Valid Approaches)

1. Node State Gating — How to Lock Nodes?

Problem: Design node (hex_17) should be locked until Research node (hex_06) reaches MVP status. Locked nodes: player can inspect but can't run work. Unlocked: player can move unit in and do work.

Approach A: Node function self-checks (runtime) Node function reads prerequisite state on entry. If locked, returns immediately with gating message. Player sees "Locked — requires Hex 06 MVP." Unit can inspect node properties, output, edges, but can't trigger agent work.

def hex_17(state):
    prereq = state["nodes"]["06"]["status"]
    if prereq not in ["completed", "deepened"]:
        return {"message": "Locked — requires Hex 06 MVP status"}
    # ... do work

Pro: Simple. Gating rules are state values, changeable at runtime. Inspection still works. Con: Unit "enters" the node but can't work. Wastes an invoke. Feels like a soft lock, not a hard block.

Approach B: Conditional edge at entry (compile-time) A conditional edge from START checks prerequisites and routes to a "locked" handler node or END.

graph.add_conditional_edges(START, route_by_prereq)
def route_by_prereq(state):
    target = state["action"]["target"]
    if is_locked(target, state):
        return "locked_handler"  # shows "Locked" message, returns to grid
    return target  # proceed to node

Pro: Hard block. Unit never enters locked node. Clean separation. Con: Gating rules compiled into the graph. Adding new rules requires recompilation. Player can't "inspect" a locked node through the normal flow.

Approach C: Hybrid — node function checks, edges prevent movement The node self-checks (Approach A) for inspection. But the RG client also checks gating state and greys out locked destinations during movement. The movement edges are static (all hexes reachable) but the RG UI prevents selection of locked hexes.

Pro: Best of both. Graph is simple (static edges). Gating is UI logic. Node can still be inspected. Con: Gating logic lives in both the RG (prevent move) and the node (prevent work). Two places to keep in sync.

Implication: Approach C is the most player-friendly. The RG shows a lock icon. The player can inspect ("Why is this locked?" → shows prerequisite). The player can't waste a move on a locked node. The gating rules are state values, not compiled edges. New rules added at runtime by writing to state.


2. Poller/Stager — Subgraph or Separate Thread?

Problem: When player selects "Correct" from interrupt, a poller/stager interview runs. The poller asks MC questions, player responds, poller writes staged config. The unit then re-runs the node with corrections applied. Where does the poller live in the graph?

Approach A: Subgraph node Poller is a node (or subgraph) in the main graph. Unit's node returns Command(goto="poller"). Poller runs its interview loop (its own interrupts). Poller writes staged config, returns Command(goto=original_node).

hex_17 → Command(goto="poller") → poller_interview → staged_config → Command(goto="hex_17")

Pro: Single graph. Single checkpointer. Traceable — every poller interaction is a checkpointed state transition. Con: The main graph's state schema must include poller-specific keys. Graph compilation includes poller nodes.

Approach B: Separate invoke Poller is a separate graph with its own thread_id. The RG client handles the interaction: after selecting "Correct," the RG invokes the poller graph directly. When poller resolves, the RG invokes the main graph again for the unit to re-run.

RG: POST /invoke-poller (runs poller graph) → poller resolves → POST /invoke (unit re-runs)

Pro: Decoupled. Poller graph is simple and self-contained. Main graph stays clean. Con: Two graphs to manage. Two thread_ids. State handoff between them is RG-mediated, not LangGraph-native.

Approach C: Inline in node function (no subgraph) The correction interview is just a loop inside the node function using multiple interrupt() calls. The node doesn't route anywhere — it just loops until the player says "Correct."

def hex_17(state):
    # ... agent runs, produces output ...
    while True:
        action = interrupt("Review output")
        if action == "accept":
            break
        elif action == "correct":
            correction = interrupt({"type": "correction_interview", ...})
            state["node_player_prompts"]["17"].append(correction)
            # Re-run agent with corrections
            output = agent.run(correction=correction)
    return {"nodes": {"17": {"output": output, "status": "completed"}}}

Pro: Simplest. No subgraph. No separate graph. Single node, single thread. Con: Node function gets complex. Long-running interview loops inside a single function. Hard to checkpoint mid-interview.

Implication: Approach C is simplest for prototype. The correction interview is 3-4 interrupt() calls. Putting them inline keeps the graph topology simple. For production, Approach A (subgraph) would be cleaner — the poller deserves its own node for clarity and checkpointing.


3. Curation Agent — Node or Standalone?

Problem: When player routes context from hex_14 → hex_22 with "Curated" method, a curation agent reads source output, reads target prompt, filters for relevance. Where does this run?

Approach A: Curation node in graph

graph.add_node("curation_agent", run_curation)
# hex_14's interrupt triggers Command(goto="curation_agent") with source/target params
# curation_agent runs, writes to state["routed_context"], returns Command(goto=START)

Pro: Traceable. Checkpointed. Single graph. Con: Curation is a graph node that doesn't correspond to a hex tile. Breaks the "every node is a hex" metaphor.

Approach B: Standalone invoke (not in graph) RG calls a separate endpoint POST /curate with source and target. Curation agent runs independently. Writes to state. RG polls for completion.

Pro: Doesn't pollute the hex graph. Curation is a service, not a tile. Con: Separate process. State write may race with main graph.

Approach C: RG-side preprocessing The RG handles curation before invoking the graph. When player routes context, the RG fetches source output and target prompt, sends both to a curation call (could be Hermes one-shot), and writes the result to the invoke payload.

Pro: Graph stays simple. Curation is a pre-invoke operation. Con: Curation state not checkpointed. Can't resume mid-curation.

Implication: Approach B (standalone) is cleanest for the prototype. Curation is conceptually a service — it's not a hex on the map. A dedicated endpoint keeps the hex graph pure.


4. Unit Movement — Dedicated Router Node or Direct Targeting?

Problem: When player selects "Move Rif to hex_17," how does the graph know which node to activate?

Approach A: Router node A "router" node at START reads the invoke payload and routes to the target.

graph.add_node("router", route_to_target)
graph.add_edge(START, "router")
graph.add_conditional_edges("router", lambda state: state["action"]["target"])

Pro: Single entry point. Clean. Movement logic isolated. Con: Extra node. Extra super-step.

Approach B: Direct invoke with thread state The RG writes the target to state before invoking. The thread already has state["unit"]["position"]. The invoke just triggers the node at that position.

# RG sets position, then invokes:
state["units"]["rif"]["position"] = "17"
graph.invoke(state, config)
# Graph's entry: conditional edge from START routes to unit's position node

Pro: State-driven. No extra router. Unit position determines flow. Con: Invoke always routes to current position. Movement is: write new position → invoke.

Approach C: Move is an invoke with action payload The RG sends action payload. The graph's entry reads action, updates position, routes to target.

graph.invoke({"action": "move", "unit": "rif", "target": "17"}, config)
# START → router reads action["target"] → routes to hex_17

Pro: Explicit. Action-driven. One invoke per move. Con: Same as Approach A essentially.

Implication: Approach B is simplest. The unit's position is already in state. Moving means: update position → invoke → node at new position activates. No router node needed. The graph's entry conditionally routes to state["unit"]["position"].


5. State Schema — One Graph or Subgraphs?

Problem: Should game state (positions, statuses, edges) and agent state (configs, prompts, chain_of_thought, corrections, staged_configs) share the same graph, or should there be subgraphs?

Approach A: One graph, all state

class GameState(TypedDict):
    nodes: dict       # 36 hex entries
    units: dict       # 3 unit entries
    mission: dict     # objective, criteria
    turn: int         # global counter (informational)

Pro: Simple. One source of truth. All state visible everywhere. Con: Large state object. Every node sees everything.

Approach B: Game graph + Agent subgraph per node Main graph manages grid, movement, gating. Subgraph per node manages agent interaction (prompts, chain_of_thought, corrections).

Pro: Clean separation. Agent state doesn't leak into game state. Con: Complex. Subgraphs add compilation and routing overhead.

Implication: Approach A for prototype. Separation is premature optimization. The state is ~10KB at 36 nodes. Every node seeing everything is fine — the access list pattern already gates what context the agent receives.


6. Move Config — Inline or Separate Node?

Problem: When the player moves a unit, they configure: model override, context to bring, context to block. Where does this config happen?

Approach A: Pre-move invoke writes config, move invoke activates node Two invokes: first writes move_config to state, second moves unit.

Pro: Clean separation. Config is a state write. Move is a state read + invoke. Con: Two HTTP roundtrips. Feels like two turns.

Approach B: Single invoke with move_config in payload

graph.invoke({
    "action": "move",
    "unit": "rif",
    "target": "17",
    "move_config": {"model": "deepseek-v4", "bring": [...], "block": [...]}
}, config)

Node function reads move_config from the invoke payload (passed via state or config). Single invoke.

Pro: One invoke. Clean. Config travels with the move action. Con: Move config must be part of the state schema or passed via runtime context.

Implication: Approach B. Single invoke. move_config is written to state by the invoke payload, read by the node function. One HTTP call. One super-step.


Recommended Approaches for Prototype

Ambiguity Recommendation Why
Node gating C (hybrid) RG UI prevents move to locked hex. Node function checks on entry. Inspection always works.
Poller/stager C (inline) Simplest for prototype. 3-4 interrupts inside node function. No subgraph needed.
Curation agent B (standalone) Service, not a hex. Separate endpoint keeps graph pure.
Unit movement B (position-driven) Unit position determines entry routing. No extra router node.
State schema A (one graph) Simple. ~10KB. Access lists already gate context.
Move config B (single invoke) Config in payload. One HTTP call per move.

State Schema (Draft Omni Config)

class GameState(TypedDict):
    # ── Grid state ──
    nodes: Annotated[dict, merge_node_updates]     # 36 hex entries
    # nodes["17"] = {"phase": "design", "status": "open", 
    #                "prerequisites": ["06:completed"], "config": {...},
    #                "output_locale": None, "output_summary": None}

    # ── Unit state ──
    units: dict                                      # 3 unit entries
    # units["rif"] = {"role": "designer", "position": "16",
    #                 "config": {...}, "trail_context": [...],
    #                 "context_access": [...], "context_block": [...],
    #                 "history": [...]}

    # ── Interaction state ──
    node_player_prompts: Annotated[dict, merge_prompts]  # per-node corrections
    staged_configs: dict                                  # poller → unit handoff
    routed_context: dict                                  # curation → target handoff

    # ── Global state ──
    mission: dict        # objective, success criteria
    decisions: list      # upstream decisions (curation agent checks these)