LangChain Agent Loop — Legacy ReAct & Modern LangGraph

LangChain Agent Loop — Legacy ReAct & Modern LangGraph

Source: two code snapshots from commit 50febb79 of langchain-ai/langchain

  1. Legacylibs/langchain/langchain_classic/agents/react/base.py L31–36
  2. Modernlibs/langchain_v1/langchain/agents/factory.py L1771

Legacy: ReAct Prompt Templates (wiki_prompt.py, textworld_prompt.py)

Both ReAct prompts share the same two input variables:

Variable Role
{input} Task/question for this run — filled once at invocation
{agent_scratchpad} Accumulated Thought:/Action:/Observation: text — grows each iteration
SUFFIX = """\nQuestion: {input}
{agent_scratchpad}"""

WIKI_PROMPT = PromptTemplate.from_examples(EXAMPLES, SUFFIX, ["input", "agent_scratchpad"])

PromptTemplate.from_examples() concatenates static few-shot demonstrations + the dynamic SUFFIX. The scratchpad starts empty; the framework appends each round to it.

Action format — text the LLM writes, parser reads

The LLM outputs plain text; ReActOutputParser extracts it:

Signal Text form Who produces it
Tool call Action: ToolName[argument] LLM
Exit Action: Finish[answer] LLM
Generation stop stop token "\nObservation:" Framework (injected)
LLM turn prefix "Thought:" Framework (llm_prefix)
Result injection "Observation: <result>" Framework (appended to scratchpad)

Tool names appear directly in text: Search[query], Lookup[term] (wiki); Play[command] (TextWorld). The entire control protocol is a text convention shared between the prompt template and the output parser.

Contrast with modern tool-call loop

In LangGraph there are no {input}/{agent_scratchpad} template slots and no text parsing. The LLM emits a structured tool_calls array in its JSON response; the framework reads AIMessage.tool_calls directly and routes via typed conditional edges. Control moved from text contract (LLM writes Action: X, parser reads it) to structured JSON (LLM emits object, framework routes natively).


Lines 31–36 are the deprecation decorator + class declaration:

@deprecated(
    "0.1.0",
    message=AGENT_DEPRECATION_WARNING,
    removal="1.0",
)
class ReActDocstoreAgent(Agent):

What it implements

The ReAct paper (Reasoning + Acting) applied to a document store.

Class hierarchy: ReActDocstoreAgent → Agent → AgentExecutor (the outermost ReActChain is a thin AgentExecutor subclass that wires things up).

The loop (lives in AgentExecutor):

  1. Build prompt with accumulated Thought/Action/Observation history
  2. Call LLM with stop sequence ["\nObservation:"]; LLM prefix is "Thought:"
  3. Parse output via ReActOutputParser → extracts action + action_input
  4. Execute tool; append "Observation: <result>" to context
  5. Repeat until LLM outputs "Final Answer: <answer>"

Tool constraints: Exactly {"Lookup", "Search"} — validated at construction time. DocstoreExplorer bridges them to a Docstore, maintaining cursor state across lookup pagination.

Variants:

All classes deprecated at 0.1.0, removed at 1.0.


factory.py builds a LangGraph StateGraph and compiles it into a CompiledStateGraph. L1771 is the start of _make_tools_to_model_edge, one of four routing functions that control the loop.

Graph topology (minimal, no middleware)

START → model ──(tool calls?)──► tools ──► model   (loop)
              └──(no tool calls)──────────────────► END

Graph topology (full, with middleware)

START
  → before_agent* (once, runs before loop)
  → before_model* (once per iteration)
  → model
  → after_model*  (once per iteration)
  → tools         (parallel fan-out via Send)
  → [loop back to before_model / model]
  → after_agent*  (once, at end)
  → END

Four routing functions

_make_model_to_tools_edge (post-model / post-after_model):

Priority Condition Destination
1 state["jump_to"] set _resolve_jump()
2 No AIMessage in messages END
3 AIMessage.tool_calls == [] END (classic exit)
4 Pending tool calls exist Send("tools", ...) per call (parallel)
5 structured_response in state END
6 All calls already have ToolMessages (artificial injection) model

_make_tools_to_model_edge (post-tools, L1771):

Priority Condition Destination
1 state["jump_to"] set _resolve_jump()
2 No AIMessage model
3 All client-side tools have return_direct=True END
4 A structured output tool was executed END
5 Default model (continue loop)

_make_model_to_model_edge (structured output retry loop):

Priority Condition Destination
1 state["jump_to"] set _resolve_jump()
2 structured_response in state END
3 Default model (retry)

_add_middleware_edge: wraps any middleware node — either a fixed edge to default_destination or a conditional edge that resolves jump_to before falling back to default_destination.

Key state fields

Field Purpose
messages Accumulates all AnyMessage (AI, Tool, System)
jump_to "model" | "end" | "tools" — middleware directive to skip ahead
structured_response Set when a structured output tool completes

Middleware lifecycle hooks

Each AgentMiddleware may implement up to four hook nodes:

Hook Runs Frequency
before_agent Before loop starts Once
before_model Before each model call Per iteration
after_model After each model call Per iteration
after_agent After loop exits Once

Hooks can short-circuit by setting jump_to in state.

Compilation options

graph.compile() accepts: checkpointer, store, interrupt_before, interrupt_after, debug, name, cache — enabling durable state, HITL interruption, and tracing.


Key differences: Legacy vs Modern

Aspect Legacy ReAct Modern LangGraph
Pattern Class inheritance (Agent subclass) Graph compilation (StateGraph)
Loop representation Implicit (AgentExecutor.run while-loop) Explicit DAG with conditional edges
Exit trigger LLM outputs "Final Answer:" No tool calls in AIMessage; or return_direct; or jump_to="end"
Parallel tools No Yes (fan-out via Send)
Tool constraints Hard-coded Any BaseTool
Middleware/hooks None 4-phase lifecycle (before/after agent/model)
HITL None interrupt_before / interrupt_after
State Implicit (prompt string) Typed AgentState dict
Structured output No OutputToolBinding with retry loop
Status Deprecated 0.1.0 / removed 1.0 Current (langchain_v1)