Skip to main content

Why a Graph, Not a ReAct Loop

OpenCouch uses a compiled directed graph (LangGraph) instead of a ReAct-style agent loop. This isn't a framework preference — it's a safety and correctness decision driven by the domain.


The core difference

ReAct loopCompiled graph
Execution orderLLM chooses what runs nextTopology enforces what runs next
Safety guaranteePrompt says "check safety first"Graph makes it impossible to skip
Tool usageLLM decides when/if to call toolsNodes invoke tools at fixed positions
ExtractionOptional step the LLM might skipHard edge — always runs after response
EnforcementRuntime guideline (can drift)Compile-time invariant (cannot drift)

A ReAct loop is a programming pattern where an LLM picks actions. Those choices can be shaped by prompting, but they aren't architecturally guaranteed. A compiled graph is a declarative topology where ordering is enforced by the executor, not by the model's compliance.


Why this matters for mental health

In a support agent, two failure classes are unacceptable:

  1. Skipped safety check — a user in crisis gets a therapeutic response instead of crisis routing and resources
  2. Corrupted memory — extraction runs before the response exists, or doesn't run at all, breaking cross-session continuity

Both failures are possible in a ReAct loop through prompt drift, model updates, cost-optimization shortcuts, or adversarial input. Both are impossible in a compiled graph because the topology doesn't contain an edge that permits them.


Structural guarantees

1. Safety always runs first

workflow.add_edge(START, "crisis_gate_node")

Every message enters the graph at START and immediately hits the crisis gate. There is no edge from START to any other node. The gate returns a Command(goto=...) that routes to either crisis response or the therapeutic branch.

In a ReAct loop, the LLM could decide to load memory first, or skip directly to response generation. Prompt hardening reduces this risk but cannot eliminate it — especially under adversarial input or model version changes.

2. Extraction always follows the response

workflow.add_edge("finalize_turn_node", "extract_semantic_facts_node")
workflow.add_edge("finalize_turn_node", "extract_procedural_rules_node")

The response must be finalized (appended to transcript) before extractors see it. Extractors run as guaranteed side-effect nodes that cannot be skipped or reordered.

In a ReAct loop, extraction is typically a cleanup step that could be skipped for latency, or run before the response is complete, corrupting the memory with in-progress reasoning.

3. Tool invocation is positional, not emergent

The web search for crisis resources is called inside crisis_response_node — deterministically, at a fixed point in the code. It's not an LLM-generated tool call that might be hallucinated, omitted, or sequenced incorrectly.

# Inside crisis_response_node — always runs on the crisis branch
resources, entries = await find_local_crisis_resources(state, llm_client=llm_client)

In a ReAct loop, the LLM generates {"tool": "web_search", ...} as text, which gets parsed. The agent might hallucinate a tool, forget to call it, or call it at the wrong time.

4. Routing is atomic with state updates

The crisis gate returns a Command that bundles the routing decision and the state delta (assessment, diagnostics, audit metadata) into a single atomic operation:

return Command(update=delta, goto=next_node)

In a ReAct loop, routing and state updates are separate steps — the LLM outputs an action string, it gets parsed, then state is updated. Parsing failures, malformed JSON, or partial writes can leave state inconsistent.

5. Transcript continuity via reducers

State fields like history and transcript use operator.add reducers. When finalize_turn_node returns {"transcript": [assistant_turn]}, LangGraph's reducer appends it to the checkpointed prior turns automatically.

In a ReAct loop, manual state["transcript"].append(...) calls are error-prone across checkpoint/restore cycles — duplicate turns, missing history, ordering bugs.


What a ReAct loop would look like here

# Pseudocode — the ReAct alternative
while True:
action = llm.decide_next_action(state) # LLM chooses

if action == "check_crisis":
assessment = check_crisis(state)
elif action == "load_memory":
# BUG: agent skipped crisis check
memory = load_memory(state)
elif action == "respond":
# BUG: agent responded without safety or memory
response = generate_response(state)
elif action == "extract":
# BUG: agent might never reach this
extract_facts(state)

Every elif branch is a potential skip. The LLM should follow the right order, but "should" is not "must."


When a ReAct loop is better

ReAct loops excel when:

  • The task is open-ended and the agent needs to explore
  • Tool sequences are unpredictable and context-dependent
  • The cost of a wrong action is low (web browsing, code search)
  • Autonomy and creativity matter more than determinism

OpenCouch's pipeline is the opposite: the sequence is known, safety ordering is critical, and the cost of a wrong action (missed crisis) is high. A graph encodes these constraints once and enforces them forever, regardless of prompt changes, model updates, or adversarial inputs.


Summary

Compiled graphOpenCouch
Execution orderTopology enforces order
Safety gateCannot be skipped
Memory extractionAlways runs after response
Tool invocationFixed positions in graph
Routing decisionsAtomic Command + state update
EnforcementCompile-time invariant
vs
ReAct loopalternative
Execution orderLLM chooses order
Safety gateCan drift with prompt changes
Memory extractionOptional — LLM may skip
Tool invocationEmergent from LLM output
Routing decisionsParsed from LLM text
EnforcementRuntime guideline
The graph doesn't make OpenCouch less capable — it makes it incapable of the specific failures that matter most in a mental health context.