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
| Piece | What it does | The lesson |
|---|---|---|
Tool | name + description + pure function | the description is the model's tool-selection interface; the name is the dispatch key |
AgentAction / AgentFinish | the two decisions a model step can make | real LangChain's exact vocabulary — every agent step is one or the other |
the injected Policy | pure f(question, scratchpad, memory) → decision | the model is a pure function of the trace — the seam that makes agents testable |
AgentExecutor.invoke | the loop: decide → dispatch → observe → repeat | the whole "agent" is ~30 lines of while-loop; everything else is guard rails |
max_iterations guard | forced stop: "Agent stopped due to iteration limit." | the 0.95^n reliability budget from Phase 00, enforced |
unknown-tool / handle_tool_error | a structured error observation, fed back to the model | the observation channel is the repair loop — errors are data, not crashes |
return_intermediate_steps | the (action, observation) trace in the output | the trace is the audit/debug surface |
memory | chat history across .invoke calls | scratchpad = within-run memory; chat history = across-run memory — different lifetimes |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference + a main() that chains two tools, recalls from memory, trips the iteration guard, and survives a hallucinated tool |
test_lab.py | 21 tests: single/multi-step tasks, trace recording, iteration guard, unknown/crashing tools, memory, determinism |
requirements.txt | pytest 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=Truereturns the exact(AgentAction, observation)trace; off by default. -
A looping policy is stopped at exactly
max_iterationswith 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=Falselets the crash propagate. -
Memory carries context between
.invokecalls: the second question can answer from the first answer. -
Same question ⇒ same trace (the policy is pure). All 21 tests pass under both
labandsolution.
How this maps to the real stack
AgentExecutor,AgentAction,AgentFinishare the real names —langchain.agents. AgentExecutorand theAgentAction/AgentFinishtypes inlangchain_core.agents. The real executor's loop is this loop: call the agent, branch on the decision type, run the tool, append toagent_scratchpad, repeat, guarded bymax_iterations(default 15) andearly_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/Observationtext; tool-calling agents render it asAIMessage(tool_calls=...)+ToolMessagepairs. 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, andhandle_tool_error/handle_parsing_errorsturn 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 modernRunnableWithMessageHistory/ LangGraph checkpointer) injects prior turns aschat_history— yourmemorylist is that mechanism, minimized. - And it is deprecated. LangChain's own docs now say it plainly: new agent work should use
LangGraph;
create_react_agentinlanggraph.prebuiltis the successor, and legacyAgentExecutorcode migrates one-to-one onto theagent ↔ toolsgraph 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 intoAgentAction— plushandle_parsing_errorswhen 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_timewith an injected tick-counter clock (nevertime.time()). - Migrate this exact executor onto your Phase 18 Lab 01
StateGraphengine —agentnode,toolsnode, 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_iterationsforced 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."