Lab 03 — The Classic AgentExecutor (ReAct Tool-Calling Loop)

Phase 29 · Lab 03 · Phase README · Warmup

The problem

Before LangGraph existed, this was how LangChain ran agents: AgentExecutor, a while-loop wrapped around a model that decides "call a tool" or "finish," with the growing agent scratchpad of (action, observation) pairs as its working memory. It is the ReAct pattern (Phase 01), framework-ified — and it is still everywhere in production codebases and interview questions, precisely because millions of lines of LangChain 0.x agent code were written against it. You need to know it cold twice over: once to maintain/migrate it, and once to explain why LangGraph superseded it — the loop is a black box you cannot pause, checkpoint, branch, or edit mid-flight.

What you build

PieceWhat it doesThe lesson
Toolname + description + pure functionthe description is the model's tool-selection interface; the name is the dispatch key
AgentAction / AgentFinishthe two decisions a model step can makereal LangChain's exact vocabulary — every agent step is one or the other
the injected Policypure f(question, scratchpad, memory) → decisionthe model is a pure function of the trace — the seam that makes agents testable
AgentExecutor.invokethe loop: decide → dispatch → observe → repeatthe whole "agent" is ~30 lines of while-loop; everything else is guard rails
max_iterations guardforced stop: "Agent stopped due to iteration limit."the 0.95^n reliability budget from Phase 00, enforced
unknown-tool / handle_tool_errora structured error observation, fed back to the modelthe observation channel is the repair loop — errors are data, not crashes
return_intermediate_stepsthe (action, observation) trace in the outputthe trace is the audit/debug surface
memorychat history across .invoke callsscratchpad = within-run memory; chat history = across-run memory — different lifetimes

File map

FileRole
lab.pyyour implementation (fill the # TODOs)
solution.pyreference + a main() that chains two tools, recalls from memory, trips the iteration guard, and survives a hallucinated tool
test_lab.py21 tests: single/multi-step tasks, trace recording, iteration guard, unknown/crashing tools, memory, determinism
requirements.txtpytest only

Run it

pytest test_lab.py -v                       # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v   # the reference (green)
python solution.py                          # worked example

Success criteria

  • A single-tool task completes with the correct answer; a multi-step task chains two tools, with the second tool consuming the first observation.
  • return_intermediate_steps=True returns the exact (AgentAction, observation) trace; off by default.
  • A looping policy is stopped at exactly max_iterations with the forced early-stop answer — the trace never exceeds the budget.
  • An unknown tool and a crashing tool become structured error observations the model sees (and can recover from); handle_tool_error=False lets the crash propagate.
  • Memory carries context between .invoke calls: the second question can answer from the first answer.
  • Same question ⇒ same trace (the policy is pure). All 21 tests pass under both lab and solution.

How this maps to the real stack

  • AgentExecutor, AgentAction, AgentFinish are the real nameslangchain.agents. AgentExecutor and the AgentAction/AgentFinish types in langchain_core.agents. The real executor's loop is this loop: call the agent, branch on the decision type, run the tool, append to agent_scratchpad, repeat, guarded by max_iterations (default 15) and early_stopping_method="force" — the source of the literal string "Agent stopped due to iteration limit.".
  • The scratchpad is real. ReAct-style agents render it as interleaved Thought/Action/Observation text; tool-calling agents render it as AIMessage(tool_calls=...) + ToolMessage pairs. Either way it is exactly your list of (action, observation) pairs, re-serialized into the prompt each iteration.
  • The error handling is real. Real AgentExecutor feeds "<name> is not a valid tool, try one of [...]" back as an observation, and handle_tool_error/handle_parsing_errors turn crashes into observations — the model gets a chance to repair instead of the process dying (Phase 02's validate-and-repair loop, inside an agent).
  • Memory is real. Classic ConversationBufferMemory (and the modern RunnableWithMessageHistory / LangGraph checkpointer) injects prior turns as chat_history — your memory list is that mechanism, minimized.
  • And it is deprecated. LangChain's own docs now say it plainly: new agent work should use LangGraph; create_react_agent in langgraph.prebuilt is the successor, and legacy AgentExecutor code migrates one-to-one onto the agent ↔ tools graph you built in Phase 18, Lab 01. The reason is the lesson: this loop is opaque — no pause for human approval mid-flight, no checkpoint to resume from, no way to insert a guardrail node between "decide" and "act." LangGraph explodes the loop into an explicit graph where every step is addressable state (Phase 18).

Limits of the miniature. The real executor parses the model's decision out of raw LLM output (ReAct text or native tool-call JSON) — our policy returns typed objects directly, skipping the parsing-failure class (handle_parsing_errors). Real tool-calling models can request parallel tool calls in one step; ours is one action per iteration. Real memory has eviction/summarization policies (Phase 04); ours is an unbounded buffer.

Extensions (your own machine)

  • Add a text-protocol policy: the "model" returns a ReAct string (Action: add\nAction Input: 2,3) and you write the output parser that turns it into AgentAction — plus handle_parsing_errors when the string is malformed.
  • Support parallel tool calls: let the policy return a list of AgentActions and dispatch all of them in one iteration.
  • Add max_execution_time with an injected tick-counter clock (never time.time()).
  • Migrate this exact executor onto your Phase 18 Lab 01 StateGraph engine — agent node, tools node, conditional edge — and diff the traces (they should match step for step).

Interview / resume signal

"Reimplemented LangChain's classic AgentExecutor — the ReAct decide→dispatch→observe loop with AgentAction/AgentFinish, scratchpad, max_iterations forced stop, structured unknown-tool/error observations, return_intermediate_steps, and cross-invoke message memory — and can explain precisely why LangGraph superseded it (an opaque loop vs an explicit, checkpointable, interruptible graph) and how to migrate one to the other."