LangGraph Unit-Node Interaction Model — Concept Exploration

Status: ACTIVE (conversation observation) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-11T19:10:00Z Claim: synthesis | 2026-05-11T19:05:00Z Session: MjF conceptual model — units as state cases moving through task nodes with access gating, config overrides, and passive intel harvesting

Prior Context

Core Model (As Observed)

Unit = Persistent State Case

A unit is an entry in LangGraph state. It carries: - Identity (unit_id, name) - Role ("researcher", "designer", "developer", "reviewer") - Default config (model, harness, temperature) - Access lists (context_access and context_block) - Per-node config overrides - Output log (what it's produced at each node visited)

As the unit moves through 4 nodes (research → design → development → review), each node is a separate LangGraph invocation — but the unit's state persists across all of them via the checkpointer. The unit's backpack stays on its back.

# State entry for a unit
{
    "unit_id": "rif",
    "name": "Rif",
    "role": "researcher",
    "default_config": {
        "model": "deepseek-v4",
        "harness": "opencode"
    },
    "node_overrides": {
        "design_node": {
            "model": "kimi-k2.6",
            "harness": "hermes"
        },
        "review_node": {
            "model": "claude-sonnet",
            "harness": "opencode"
        }
    },
    "context_access": [
        "wiki/research/langgraph-pi.md",
        "unit_sherpa/research_output",
        "unit_echo/design_output"
    ],
    "context_block": [
        "wiki/research/deprecated-approach.md"
    ],
    "output_locales": {
        "research_node": "wiki/research/feature-x-research.md",
        "design_node": "forgejo/issue/42",
        "development_node": "forgejo/pr/17",
        "review_node": "wiki/review/feature-x-review.md"
    },
    "history": [
        {"node": "research_node", "timestamp": "...", "output": "wiki/research/..."},
        {"node": "design_node", "timestamp": "...", "output": "forgejo/issue/42"}
    ]
}

Node = Task Space With Output Locale

Each node is a LangGraph node function. When activated: 1. It identifies which unit is present (from state) 2. It resolves the effective config (base + per-node override) 3. It resolves the access context (fetch only allowed sources, skip blocked ones) 4. It runs its task (LLM call, tool work, whatever) 5. It writes output to a locale (wiki page, Forgejo issue, file) 6. It returns state update with output pointer, any new intel, updated unit state

The node's output is not just the work product — it's a pointer to where the work lives:

def research_node(state: GameState, config, runtime):
    unit = state["units"][state["active_unit"]]
    effective_config = resolve_config(unit, "research_node")
    context = fetch_context(unit["context_access"], unit["context_block"])

    # Do the research work with resolved config and context
    agent = runtime.context.get_agent(effective_config["harness"])
    result = agent.research(
        task=state["task"],
        context=context,
        model=effective_config["model"]
    )

    # Write to output locale
    locale = write_to_wiki(result, "wiki/research/feature-x-research.md")

    return {
        "unit_output": {"research_node": locale},
        "unit_history": [{"node": "research_node", "output": locale, "summary": result.summary}]
    }

Access Gating = Context Fetch With Allow/Block Lists

Before a node runs its task, it resolves what context it can see:

def fetch_context(access: list[str], block: list[str]):
    """Only fetch sources in access list that are not in block list."""
    context = {}
    for source in access:
        if source in block:
            continue  # explicitly blocked
        context[source] = resolve_source(source)  # wiki page, other unit's output, etc.
    return context

Sources can be: - Wiki pages (wiki/research/langgraph-pi.md or wiki/research/langgraph-pi.md#section-3) - Other unit outputs (unit_sherpa/research_output — the research output of another unit named Sherpa) - Forgejo issues / PRs (forgejo/issue/42) - External URLs (raw docs links) - Named intel items (intel/pi-torch-001)

Access is configured per-unit. It persists across nodes by default — the unit's access lists travel with it. But can be overridden per-node (a node can have its own access list that merges with or replaces the unit's default).

Config Overrides = Per-Node Model/Harness Switching

The unit has a default config. Each node can override it:

def resolve_config(unit, node_name):
    base = unit["default_config"]  # {"model": "deepseek-v4", "harness": "opencode"}
    overrides = unit.get("node_overrides", {}).get(node_name, {})
    return {**base, **overrides}  # overrides win

So Unit Rif runs research on deepseek-v4/opencode by default, but at the design node it switches to kimi-k2.6/hermes because the override says so. At the next node (development), if there's no override, it falls back to deepseek-v4/opencode. The trailing config is the unit's default — overrides are per-node exceptions.

Trailing Context = State Is the Backpack

If you don't override access per-node, the unit's context_access and context_block lists persist from node to node. After moving through 4 nodes, the unit's history accumulates (output locales visited), its config travels (base + any per-node overrides), its access lists travel. State is the backpack — LangGraph's checkpointer ensures it survives between invocations.

Passive Intel Harvesting (Resource Queue)

Harvester Units

A harvester is a unit with role: "harvester". It runs in the background, prospecting for intel. It doesn't move through the production pipeline — it feeds it.

The harvester: 1. Receives a prospecting directive ("research LangGraph + Pi agent integration") 2. Pulls down documentation, scans repos, queries APIs 3. Stages findings into the wiki 4. During a review pass, flags valuable intel chunks

Flagged Intel Items

When a harvester identifies something valuable, it creates a named intel item:

{
    "intel_id": "pi-torch-001",
    "name": "pi torch",
    "description": "pattern for welding Pi agents into LangGraph nodes natively — subprocess bridge with JSON handoff",
    "source": "wiki/research/langgraph-pi-integration.md#section-3",
    "source_excerpt": "...",
    "confidence": 0.85,
    "harvested_by": "sherpa",
    "harvested_at": "2026-05-11T19:00:00Z",
    "suggested_for": [
        "unit_rif/design_node",
        "unit_echo/architecture_node"
    ],
    "tags": ["pi", "langgraph", "integration", "runtime-bridge"],
    "status": "pending"  # pending, accepted, rejected, consumed
}

The harvester doesn't just dump findings — it identifies what the finding is RELEVANT TO. "Pi torch" explains something essential to Pi+LangGraph integration. The harvester suggests it for Rif's design node and Echo's architecture node because those nodes are working on adjacent problems.

Routing Suggestions = Conditional Intel Delivery

When a node activates, before it runs its task, it checks: are there any pending intel items suggested for this unit+node combination?

def pre_node_intel_check(state, unit_id, node_name):
    pending = [i for i in state["intel_queue"] 
               if unit_id in i["suggested_for"] 
               and i["status"] == "pending"]

    if pending:
        # Present suggestions to human via interrupt
        choices = interrupt({
            "message": f"Intel available for {node_name}:",
            "items": [{"name": i["name"], "description": i["description"], "source": i["source"]} for i in pending]
        })
        # Human selects which to accept
        for item in choices:
            item["status"] = "accepted"
            # Add to unit's context_access for this and future nodes
            add_to_access(state, unit_id, item["source"])

This is not auto — it's a suggestion. The human sees: "Pi Torch intel is available. Include it?" They can accept (add to access), reject (mark consumed), or defer (leave pending). The enabling happens on either surface: the research lab (harvester management view) or the node (just-in-time suggestion).

Modular Intel Items = Named, Reusable, Routable

"Pi torch" is not a document. It's a named pointer to a chunk of intel. It can be: - Routed to multiple nodes - Accepted by some, rejected by others - Referenced by name in other intel items ("see also: pi torch") - Composed into larger briefs for other units - Deprecated when superseded

The name is a handle — it makes intel discussable, shareable, and trackable across the graph.

Mapping to LangGraph Primitives

Concept LangGraph Mapping How
Unit identity State entry with unit_id key Persists via checkpointer across invocations
Unit config State dict: default_config, node_overrides Resolved at node activation, merge with base
Node as task space LangGraph node function Each node is a function that does a type of work
Output locale State key: output_locales[node_name] Written by node, points to wiki/Forgejo/file
Access gating context_access + context_block lists in state Node's pre-processing reads these, fetches only allowed
Config override node_overrides[node_name] dict Resolved at activation, overrides base config
Trailing context State persistence Unit's lists travel in state, survive invocations
Harvester Separate LangGraph thread or node Runs independently, writes intel to Store or state
Intel item State entry with name, source, suggestions, status Created by harvester, consumed by production nodes
Auto-suggest routing interrupt() at node activation Human decides what intel to accept into access list
Enabling on either surface State mutation via Command Accept intel → update access list in state
Intel item as handle Named pointer in state Referenced by name, not embedded content

Observed Properties

The Unit IS the State

Everything about a unit — its role, config, access lists, node overrides, history, output pointers — lives in LangGraph state. This means: - It survives between invocations (checkpointer) - It can be inspected at any time (graph.get_state(config)) - It can be rewound (time-travel to previous state) - It can be modified mid-run (flip a config, add access, change role)

Nodes Are Stateless Functions Over Unit State

A node doesn't own the unit. It receives the unit from state, does work, and returns updates. The unit's identity is independent of any single node. This separation means nodes can be composed and recomposed — the unit stays the unit regardless of which nodes it visits.

Access Is Explicit and Gated

Every piece of context the unit sees is in its access list. If it's not in the list, the unit can't see it. This prevents context pollution (a design node flooded with irrelevant research) while allowing cross-pollination (a design node pulling from another unit's research output) — but only when explicitly allowed.

Intel Is Passively Harvested, Actively Routed

Harvesters work continuously in the background. They don't block the pipeline. They surface findings as suggestions. The human gates what enters the production pipeline. Nothing auto-injects into a unit's context without human approval.

Open Questions (Not Prescriptive)