Tools and the Agent Loop: ToolNode, create_agent, and Structured Output (LangGraph in Production, Module 04)
Tools and the Agent Loop: ToolNode, create_agent, and Structured Output (LangGraph in Production, Module 04)
This is Module 04 of LangGraph in Production, a free 16-module course that takes you from your first StateGraph to a deployed, durable autonomous coding agent on LangGraph v1.x. Start at Module 1 or browse the full syllabus.
The problem: Forge edits code it has never read
Here is the issue you hand Forge at the end of Module 3:
Bug:
report.pycrashes withZeroDivisionErrorwhen there are no expenses for a month.
Forge routes it correctly — route="bug" — and propose_edit fires. But here is what propose_edit actually does: it asks the model to rewrite tally/report.py without ever opening it. The model has seen the filename in the issue text, so it hallucinates a plausible implementation and ships that. The sandbox catches the mismatch — the tests go red for a reason completely unrelated to the original bug — and the fix loop thrashes through three attempts, burning tokens on a phantom failure.
The root cause is structural: Forge has no way to read its environment. It cannot open tally/storage.py, cannot list which files exist, cannot search for where a function is defined. And even if it guessed correctly, its “plan” is free-form text — there is no machine-readable contract that later modules can consume. Module 5 needs a list of files to fan out across; Module 7 needs a plan to display for human approval; Module 12 needs a plan to route sub-agents against. A string of prose is useless for all of that.
This module gives Forge eyes. You wire three read-only tools (read_file, list_files, search_code) into a ReAct loop, introduce the v1.x way to build that loop with create_agent, and teach the Planner node to produce a structured Plan (Pydantic) that every downstream module will consume as a typed contract.
In this module
You’ll learn:
- How the tool-call cycle works at the superstep level: model proposes a call,
ToolNodeexecutes it,ToolMessagere-enters the model,tools_conditiondecides whether to loop or exit - Why
create_agent(fromlangchain.agents) is the v1.x idiom — and whycreate_react_agent(fromlanggraph.prebuilt) is deprecated and scheduled for removal in 2.0 - How to define tools with
@toolso the model gets the right spec and schema - How
response_format=Planturns a free-form agent output into a validated Pydantic object - What
ToolStrategyandProviderStrategymean, and when each applies - How
ToolNode(handle_tool_errors=True)keeps a crashing tool from killing the run
You’ll build: Forge gains three read-only tools and a real create_agent Planner that inspects Tally and emits a structured Plan; the Plan feeds directly into the test-fix loop from Module 3.
Concepts covered: CA4 — Tools & the agent loop (core)
Prerequisites: Modules 1–3, .env with ANTHROPIC_API_KEY (or the Ollama fallback from forge/config.py), forge/sandbox.py operational.
Where you are in the course
M1 ✅ StateGraph basics, Forge & Tally, init_chat_model
M2 ✅ ForgeState, intake node, messages + add_messages
M3 ✅ Triage router, test-fix loop, TestResult, sandbox
M4 👉 Tools, ReAct loop, create_agent, structured Plan output
M5 ⬜ The Send API, fan-out, parallel Editor nodes
M6–M16 ⬜ Persistence, HITL, time-travel, durable execution, streaming...
Before this module: Forge can classify an issue and loop test-fix, but it edits code it has never read.
After this module: Forge reads the Tally repo before acting and emits a typed Plan — including target_files — that Module 5 will fan out across in parallel.
The tool-call cycle, by hand
What changes when a model calls a tool
A plain LLM node returns a message and you parse the text. A tool-calling node returns an AIMessage that may contain tool_calls — structured requests for your code to run a named function and return the result. The model does not run the tool; your code does.
The cycle is:
- The model receives a message (the user’s issue, or the accumulated scratchpad) and decides to call a tool.
- It emits an
AIMessagewithtool_calls=[{"name": "read_file", "args": {"path": "report.py"}, "id": "..."}]. ToolNodepicks up those calls, invokes the matching Python functions, and appendsToolMessageobjects tomessages— one per tool call, keyed bytool_call_id.- The model sees the updated
messagesand either calls another tool or produces its final answer. tools_conditioninspects the last message: if it hastool_calls, route back to the tool node; otherwise, route toEND.
This is the ReAct (Reason-Act-Observe) loop. Each iteration is one LangGraph superstep.
Defining a tool with @tool
A @tool decorator turns a Python function into a tool the model can call. The model sees three things: the function name, the docstring (the spec — what the tool does and when to use it), and the type hints (the schema — what arguments to pass). All three matter. A vague docstring is a vague spec; the model will call the tool incorrectly or not at all.
Here is the actual forge/tools.py from the lab:
from pathlib import Path
from langchain_core.tools import tool
def make_read_tools(repo_path: str | Path) -> list:
root = Path(repo_path)
@tool
def read_file(path: str) -> str:
"""Read a file from the repository (path relative to the repo root)."""
return (root / path).read_text()
@tool
def list_files(subdir: str = ".") -> str:
"""List Python files under a subdirectory of the repository."""
base = root / subdir
return "\n".join(sorted(str(p.relative_to(root)) for p in base.rglob("*.py")))
@tool
def search_code(query: str) -> str:
"""Search the repository for a substring; return 'path:line: text' matches."""
hits = []
for p in root.rglob("*.py"):
for i, line in enumerate(p.read_text().splitlines(), 1):
if query in line:
hits.append(f"{p.relative_to(root)}:{i}: {line.strip()}")
return "\n".join(hits) or "(no matches)"
return [read_file, list_files, search_code]
Two things worth noting here. First, the tools are closures over root — the sandbox copy of the Tally repo. The agent always reads the sandbox, never the original. Second, make_read_tools is a factory: you call it with a repo_path and get back three bound tool objects. This makes offline testing straightforward — you can pass any tempdir and the tools work deterministically.
Wiring the loop by hand
Before you use create_agent, it pays to wire the ReAct loop manually once. Doing so makes the superstep model visible and helps you debug agent traces later.
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.messages import MessagesState
# build three read tools bound to the repo
read_tools = make_read_tools("/tmp/my-sandbox")
# a plain agent node: invokes the model and appends the response
def agent_node(state: MessagesState):
model_with_tools = get_model("smart").bind_tools(read_tools)
return {"messages": [model_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(read_tools, handle_tool_errors=True))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition) # loop-or-exit
builder.add_edge("tools", "agent") # always back to agent
graph = builder.compile()
The ToolNode comes from langgraph.prebuilt — this import is correct and stays in v1.x. The tools_condition is the conditional edge function that reads the last message: if it contains tool calls, return "tools"; if not, return END. That single conditional edge is the entire exit criterion of the ReAct loop.
The handle_tool_errors=True flag tells ToolNode to catch any exception raised inside a tool and return it as a ToolMessage with the error text instead of letting the exception bubble up and kill the run. We will come back to this in the reliability section.
Here is what the loop looks like as a diagram:
flowchart LR
START([START]) --> agent["agent node\n(model + bind_tools)"]
agent -->|tool_calls present| tools["ToolNode\n(executes tools)"]
tools -->|ToolMessage appended| agent
agent -->|no tool_calls| END([END])
Each pass through agent → tools → agent is one full superstep cycle. The model reasons (agent), acts (tools), and observes the result (the ToolMessage lands back in messages). When the model has enough information, it stops calling tools and produces its final response.
create_agent: the v1.x way to build the loop
What create_agent gives you
Wiring the loop by hand is educational. Doing it in production code is error-prone. create_agent wraps the loop into a reusable, tested prebuilt with additional features: automatic validation of tool schemas, configurable middleware hooks, and first-class support for structured output via response_format.
The import is:
from langchain.agents import create_agent
This is the v1.x canonical idiom. create_agent lives in langchain (the LangChain package), not in langgraph. ToolNode and tools_condition still come from langgraph.prebuilt — those are not deprecated.
The comparison table
create_agent | create_react_agent | |
|---|---|---|
| Package | langchain.agents | langgraph.prebuilt |
| Status in v1.x | Recommended | Deprecated |
| Removal | — | Scheduled for LangGraph 2.0 |
| Structured output | response_format= built in | Must wire manually |
| Middleware | middleware=[...] (pre/post-model hooks) | Hooks are fixed; no middleware |
AgentState / ValidationNode | Replaced by built-in validation | These types are removed/relocated |
| When to use | Always, in new code | Only when migrating legacy code |
Legacy note (do not use in new code)
create_react_agentfromlanggraph.prebuiltwas the standard idiom in LangGraph v0.x and all tutorials written before October 2025. It is deprecated in v1.0 and scheduled for removal in 2.0. Roughly 60–80% of the “LangGraph agent” tutorials you find online still use it. If you seefrom langgraph.prebuilt import create_react_agent, you are reading outdated material.Similarly,
AgentState,AgentStatePydantic, andValidationNode— which some older tutorials reference for output validation — have been removed or relocated. In v1.x, validation is integrated intocreate_agentitself.
⚠️ Common misconception
“
create_react_agentis the current LangGraph way to build an agent.”This is false in v1.x. The v1.x idiom is
from langchain.agents import create_agent. The mistake appears in most 2024–2025 tutorials and in the LangGraph Academy content that has not yet been updated. The tell is the import path:langgraph.prebuilt.create_react_agentis the legacy import;langchain.agents.create_agentis v1.x. A second common confusion: searching forAgentStateorValidationNodeto validate agent outputs — those are gone; useresponse_format=oncreate_agentinstead.
Building the Planner with create_agent
Here is forge/agents.py, the actual lab file:
from langchain.agents import create_agent
from forge import config, tools
from forge.models import Plan
PLANNER_SYSTEM = (
"You are Forge's planner. Use your tools to read the Tally repository, then return a "
"Plan: a one-line summary, the target_files you will change, ordered steps, and a risk "
"level. Prefer the smallest change that resolves the issue."
)
def build_planner(repo_path: str, model=None):
model = model or config.get_model("smart")
return create_agent(
model,
tools=tools.make_read_tools(repo_path),
system_prompt=PLANNER_SYSTEM,
response_format=Plan,
)
def plan_change(repo_path: str, issue: str, model=None) -> Plan:
"""Explore the repo and produce a structured Plan (LLM seam — monkeypatched offline)."""
agent = build_planner(repo_path, model)
result = agent.invoke({"messages": [{"role": "user", "content": issue}]})
plan = result.get("structured_response")
if plan is None:
raise RuntimeError("planner returned no structured_response")
return plan
build_planner calls create_agent with four arguments: the model (from config.get_model("smart") — the only place model IDs live), the three read-only tools, a system prompt, and response_format=Plan. The agent runs its tool loop — calling list_files, then read_file on relevant files, then search_code — and when it is done, it returns a validated Plan object in result["structured_response"].
The plan_change function is the LLM seam: the single point of contact between the Planner node in nodes.py and the real model. The offline test suite monkeypatches agents.plan_change to return a canned Plan, so the entire graph runs without any API key.
Structured output: the Planner emits a Plan
Why structured output matters
A free-form string from the Planner is not a contract. Module 5 needs plan.target_files to build the fan-out; Module 7 needs a Plan object to display for human approval; Module 12 needs plan.steps to route sub-agents. If the Planner returns a string, every consumer has to parse it — and parse it differently — and the mismatch is undetectable until runtime.
Structured output solves this. response_format=Plan tells create_agent to validate the model’s final output against the Plan schema and raise an error if it does not conform. The caller gets a Plan instance, not text.
The frozen Plan and PlanStep contracts
These schemas are introduced in Module 4 and frozen: they will not change in any later module. Every later module that reads state["plan"] relies on exactly these fields.
from typing import Literal
from pydantic import BaseModel
class PlanStep(BaseModel):
description: str
kind: Literal["edit", "create", "test", "docs"]
target_file: str | None = None
class Plan(BaseModel):
summary: str
target_files: list[str]
steps: list[PlanStep]
risk: Literal["low", "medium", "high"]
Fields not present here — score, approved, priority — are not part of the contract and must not be added. plan_approved (the human-approval flag) lives in ForgeState, not in Plan, and is introduced in Module 7.
ToolStrategy vs ProviderStrategy
When you pass response_format=Plan to create_agent, the agent chooses a strategy to enforce the schema:
ToolStrategy: forces structured output by wrappingPlanas a special tool call. The model is instructed to call this tool as its final action. This is universal — it works with any model that supports tool calling, regardless of whether the provider offers native structured output.ProviderStrategy: uses the provider’s native structured-output API (for example, Anthropic’stool_usewith schema-constrained output, or OpenAI’sresponse_format={"type": "json_schema", ...}). Faster and more reliable when available, but provider-dependent.
In practice, create_agent picks the strategy based on the model’s capabilities. If you need to override, you pass a ToolStrategy or ProviderStrategy instance directly. For the Planner, the default works correctly with both Claude and the Ollama fallback.
The low-level alternative — model.with_structured_output(Plan) — bypasses create_agent entirely and works at the model-binding level. It is useful when you do not need the full agent loop, just a single structured call. For the Planner, you want the loop (to call read_file, list_files, search_code before answering), so create_agent with response_format=Plan is the right choice.
The Planner node
The planner node in forge/nodes.py is the bridge between the create_agent agent and the graph state:
def planner(state: ForgeState) -> dict:
"""Explore the repo (create_agent + read tools) and produce a structured Plan."""
plan = agents.plan_change(state["repo_path"], state["issue"])
summary = AIMessage(
content=f"Planned change: {plan.summary} (targets: {', '.join(plan.target_files)})"
)
return {"plan": plan, "messages": [summary]}
The node calls agents.plan_change (the seam), gets back a Plan, appends a summary AIMessage to the scratchpad for observability, and writes {"plan": plan} into the state. Downstream nodes — propose_edit and fixer — read state["plan"].target_files[0] instead of guessing the filename.
The graph inserts the Planner between triage and the editor:
flowchart TD
START([START]) --> intake
intake --> triage
triage -->|bug / feature / chore| planner["Planner\n(create_agent + read_file/list_files/search_code\nresponse_format=Plan)"]
planner -->|state.plan populated| propose_edit
propose_edit --> tester
tester -->|done / give_up| END([END])
tester -->|fixer| fixer
fixer --> tester
style planner fill:#e8f4e8,stroke:#4a7c59
The target_file helper in nodes.py illustrates the graceful fallback pattern:
def target_file(state: ForgeState) -> str:
"""The file the editor should change — from the plan if present, else the fallback."""
plan = state.get("plan")
if plan and getattr(plan, "target_files", None):
return plan.target_files[0]
return TARGET_FILE # "report.py" — pre-M4 behaviour
When a Plan is present, the editor uses plan.target_files[0]. When it is not (for example, in tests that bypass the Planner), the editor falls back to the hardcoded filename. This keeps the M3 test suite passing without modification.
Reliable tools, middleware, and MCP
handle_tool_errors=True
A tool that raises an exception and kills the run is useless to an agent. The agent cannot observe what went wrong, cannot reason about the failure, and cannot try a corrective action. The run just dies.
ToolNode(tools, handle_tool_errors=True) changes this. When a tool raises, ToolNode catches the exception, formats the traceback as a ToolMessage with the error text, and returns it to the model. The model sees the error, reasons about it (“the file does not exist — I should call list_files first”), and continues the loop. The agent can recover; the run stays alive.
# In the hand-wired loop from the earlier section:
builder.add_node("tools", ToolNode(read_tools, handle_tool_errors=True))
The opinion here is unambiguous: a tool that crashes the run instead of returning an error message is a tool the agent cannot reason about. Always use handle_tool_errors=True in production code. The only exception is when you want a tool failure to be fatal — in which case you should handle the error explicitly inside the tool and raise a custom exception type that your graph catches.
Middleware (theory — not built in Forge yet)
CA4.6 — Advanced, not built in this module
create_agent has a middleware parameter: middleware=[...]. Middleware is the v1.x extension point that replaces the old AgentState/ValidationNode pattern. You can add hooks that run before or after the model call — for example, a summarization hook that compresses the scratchpad if it exceeds a token budget, a logging hook that records every tool call to LangSmith, or a rate-limiting hook that backs off when the provider returns a 429.
Middleware is where patterns like “summarize messages if the context exceeds 8 K tokens” live in v1.x. The old approach of subclassing AgentState and wiring a ValidationNode is gone. If you are migrating code that used those patterns, middleware is the replacement.
Module 12 (subgraphs and multi-agent) and Module 11 (streaming) introduce scenarios where middleware is useful. For now, the Planner works without it.
MCP — Model Context Protocol (theory — not built in Forge)
CA4.8 — Advanced, not built in this module
The Model Context Protocol (MCP) is a standard protocol (currently at spec version 2025-03-26) for exposing tools and resources from an external server to an agent client. Instead of defining your tools as Python functions in the same process, you run a separate MCP server that exposes them over HTTP or stdio; the agent discovers and calls them through the protocol.
In LangGraph, the integration point is langchain-mcp-adapters. You instantiate a MultiServerMCPClient, call .get_tools(), and pass the result to create_agent:
# Pseudocode — not built in Forge (MCP enclosure is local tools)
from langchain_mcp_adapters.client import MultiServerMCPClient
async with MultiServerMCPClient({"my_server": {"url": "http://localhost:8000/mcp"}}) as client:
tools = await client.get_tools()
agent = create_agent(model, tools=tools, response_format=Plan)
MCP is relevant when your tools live in a different process, a different machine, or a shared tool registry that multiple agents consume. For Forge, the tools are local Python functions bound to the sandbox path — MCP would add indirection without benefit. The pattern matters as soon as you expose tools as a service (a company-wide “code search” tool, for example) or consume external MCP-compliant servers.
Hands-on lab: Build Module 4
Objective: Add read-only tools and a create_agent Planner to Forge. The Planner reads Tally, emits a structured Plan, and the graph inserts it between triage and the test-fix loop.
Observable result (offline, no key): uv run pytest -q passes all tests including the full pipeline test where the Planner is monkeypatched, the Tester runs real pytest on Tally, and the final state has test_result.passed=True, attempts=2, and plan.target_files=["report.py"].
Step 1: Copy the Module 3 state
Copy the entire module-03/forge/ tree into module-04/forge/. Copy tally/ and tests/. No file is rewritten — you only add and modify.
Step 2: Add the Pydantic contracts (forge/models.py)
Add PlanStep and Plan below the existing TestResult. The schemas are frozen — do not add extra fields:
class PlanStep(BaseModel):
description: str
kind: Literal["edit", "create", "test", "docs"]
target_file: str | None = None
class Plan(BaseModel):
summary: str
target_files: list[str]
steps: list[PlanStep]
risk: Literal["low", "medium", "high"]
Step 3: Extend the state (forge/state.py)
Add one field after the Module 3 fields:
# --- Module 4 ---
plan: Plan | None # Planner's structured output
The fields targets, edits (Module 5), plan_approved, pull_request (Module 7), and review (Module 12) are absent from the TypedDict until their respective modules.
Step 4: Create the tools (forge/tools.py)
The make_read_tools(repo_path) factory shown earlier is the complete file. Three tools, read-only, names frozen: read_file, list_files, search_code. No write_file (that is Module 5).
The key design choice: each tool is a closure over root = Path(repo_path). The agent always reads the sandbox copy, never the original Tally repo. And each tool has a clear, specific docstring — the docstring is the spec the model uses to decide when and how to call the tool.
Step 5: Create the agent layer (forge/agents.py)
from langchain.agents import create_agent
from forge import config, tools
from forge.models import Plan
PLANNER_SYSTEM = (
"You are Forge's planner. Use your tools to read the Tally repository, then return a "
"Plan: a one-line summary, the target_files you will change, ordered steps, and a risk "
"level. Prefer the smallest change that resolves the issue."
)
def build_planner(repo_path: str, model=None):
model = model or config.get_model("smart")
return create_agent(
model,
tools=tools.make_read_tools(repo_path),
system_prompt=PLANNER_SYSTEM,
response_format=Plan,
)
def plan_change(repo_path: str, issue: str, model=None) -> Plan:
agent = build_planner(repo_path, model)
result = agent.invoke({"messages": [{"role": "user", "content": issue}]})
plan = result.get("structured_response")
if plan is None:
raise RuntimeError("planner returned no structured_response")
return plan
plan_change is the LLM seam — the boundary between the graph and the real model. Tests monkeypatch this function. If you later want to swap the Planner strategy (different model tier, different tools, different structured-output approach), this is the only function you change.
Step 6: Add the Planner node (forge/nodes.py)
Add the planner node and the target_file helper. Extend propose_edit and fixer to call target_file(state) instead of using the hardcoded filename:
def target_file(state: ForgeState) -> str:
plan = state.get("plan")
if plan and getattr(plan, "target_files", None):
return plan.target_files[0]
return TARGET_FILE # "report.py" fallback
def planner(state: ForgeState) -> dict:
plan = agents.plan_change(state["repo_path"], state["issue"])
summary = AIMessage(
content=f"Planned change: {plan.summary} (targets: {', '.join(plan.target_files)})"
)
return {"plan": plan, "messages": [summary]}
The rest of the nodes (triage, intake, tester, fixer) are unchanged from Module 3.
Step 7: Wire the Planner into the graph (forge/graph.py)
builder.add_node("planner", nodes.planner)
builder.add_conditional_edges(
"triage", nodes.route_by_type,
{"bug": "planner", "feature": "planner", "chore": "planner"},
)
builder.add_edge("planner", "propose_edit")
All three triage routes now go through the Planner before reaching propose_edit. The rest of the graph (the test-fix cycle) is identical to Module 3.
Step 8: Run the tests
uv run pytest -q
Expected output: all tests pass, including the integration test in tests/test_pipeline.py. Here is what the integration test asserts:
out = build_graph().invoke(
{"issue": "tally report --month crashes when a category has no expenses",
"repo_path": repo},
{"recursion_limit": 25},
)
assert out["test_result"].passed is True
assert out["attempts"] == 2
assert out["plan"].target_files == ["report.py"]
assert "totals.get(cat" in (Path(repo) / "report.py").read_text()
The Planner is monkeypatched to return a canned Plan. The fake chat model returns a buggy report.py on the first edit attempt (so the tests fail), then the corrected file on the second attempt (so the tests pass). The Tester runs real pytest in the sandbox — no mocking there. Two attempts, green tests, and the fix is in the file.
Step 9: Try a live run (optional, requires API key)
FORGE_LIVE_TESTS=1 uv run python -m forge.run \
"Bug: report.py crashes with ZeroDivisionError when there are no expenses for a month"
You should see the Planner’s tool loop in the trace: list_files(subdir="."), read_file(path="report.py"), search_code(query="ZeroDivision"), then the structured Plan output with target_files=["report.py"] and a risk classification.
Try it yourself
- Add a fourth read-only tool — for example,
grep_tests(pattern: str) -> strthat searches only undertests/. Observe whether the Planner discovers and uses it when reasoning about a test-related issue. - Try switching the structured-output strategy: instead of relying on the automatic default, pass
response_format=ToolStrategy(schema=Plan)explicitly. Compare the traces to the defaultProviderStrategybehavior. Which produces cleaner output with your model?
In production
Tool definitions are live API contracts. The docstring and type hints are what the model sees when it decides whether and how to call a tool. A vague docstring (“does file stuff”) leads to incorrect calls or no calls at all. In production, treat tool docstrings like you treat HTTP API documentation: precise, complete, with examples of edge cases.
Version your tool definitions. If you change a docstring or add an argument, the model’s behavior changes — even if the Python logic is identical. Track these changes in version control and test them against representative inputs before deploying.
Budget the agent loop. Every iteration of the ReAct cycle is one model call. A Planner that makes five tool calls before answering costs six model invocations (five tool-call rounds plus the final structured-output call). At claude-sonnet-4-5 pricing, that is non-trivial at scale. Set a max_iterations ceiling (if your create_agent version supports it) or monitor loop depth in production. Module 15 covers LangSmith tracing for this.
handle_tool_errors=True is not optional in production. A tool that crashes the run instead of returning a structured error message is a tool the agent cannot reason about. Pair it with logging inside the tool: when ToolNode catches an error, it wraps the traceback as a string, but you will want the original exception in your observability stack.
Structured output is a typed API contract. The Plan schema — summary, target_files, steps, risk — is consumed by four downstream modules. Any change to that schema is a breaking change across the codebase. Treat Plan the way you treat a database schema: frozen until a migration is planned and backward-compatible alternatives are in place.
When tool calls need human oversight — reviewing what the agent is about to read, or what file it is about to edit — the relevant pattern is human-in-the-loop review of tool calls. That is a Module 7 topic, but it is worth noting here: create_agent is designed to integrate with interrupt() middleware for exactly this use case.
Mastery corner
What to really understand here
The tool-call cycle (CA4.1) is the foundation of all agentic behavior in LangGraph: the model does not execute code, your runtime does. The model decides what to call; ToolNode executes it; the result re-enters as a ToolMessage; tools_condition decides whether to loop or exit. Understanding this at the superstep level makes you able to read agent traces and debug failures.
The v1.x API split (CA4.4) is a freshness trap that will catch you if you rely on tutorials from before October 2025. create_agent (from langchain.agents) is the current idiom. create_react_agent (from langgraph.prebuilt) is deprecated and gone in 2.0.
Structured output (CA4.5) is the difference between a text blob and a typed API contract. response_format=Plan + Pydantic means the caller always gets a Plan instance — no parsing, no silent mismatch. The ToolStrategy / ProviderStrategy distinction matters when you need to control how the schema is enforced.
Quiz
Q1. A colleague is starting a new LangGraph v1.x project and writes:
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(model, tools=[search])
What is the problem, and what is the correct alternative?
A. Nothing is wrong — create_react_agent is the current LangGraph idiom for building agents.
B. The import path is wrong; it should be from langgraph.agents import create_react_agent.
C. create_react_agent is deprecated in LangGraph v1.0 and scheduled for removal in 2.0; the v1.x idiom is from langchain.agents import create_agent.
D. create_react_agent only supports async; use create_react_agent_sync for sync code.
Q2. Your Forge Planner’s read_file tool raises a FileNotFoundError when the agent asks for a file that does not exist. The entire run crashes instead of letting the agent recover. What is the fix?
A. Catch the exception inside read_file and return "" so the agent thinks the file is empty.
B. Add handle_tool_errors=True to the ToolNode constructor; it will convert the exception into a ToolMessage with the error text that the model can reason about.
C. Use a try/except around the agent.invoke() call and restart the agent from scratch.
D. Set recursion_limit=50 to give the agent more retries before the exception propagates.
Q3. The Planner must produce a Plan that downstream modules (Module 5 fan-out, Module 7 HITL, Module 12 subgraph routing) consume programmatically. You have two options:
- Option A:
create_agent(..., response_format=Plan)with Pydantic validation. - Option B: Parse the model’s text output with a regex to extract the JSON.
Which is correct, and why?
A. Option B, because regex parsing gives you more control over the output format.
B. Both are equivalent; choose based on team preference.
C. Option A, because response_format=Plan validates the model output against the Pydantic schema and gives callers a typed Plan instance — a reliable machine-readable contract that option B cannot guarantee.
D. Option A, but only if you also pass ProviderStrategy explicitly; otherwise it falls back to text.
Q4. The Planner never calls search_code even when the issue text mentions a specific function name. You suspect the tool is being ignored. What is the most likely cause?
A. search_code is not compatible with create_agent; it only works with create_react_agent.
B. The search_code tool’s docstring is empty or ambiguous — the model cannot determine when to use it, so it skips it. A precise docstring (“Search the repository for a substring; return ‘path:line: text’ matches”) resolves the problem.
C. ToolNode is dropping search_code because it has more than two arguments.
D. The model needs to be given search_code as the first tool in the list to prioritize it.
Q5. Trace the flow of a single ReAct iteration: the Planner calls list_files, gets the result, then calls read_file. Which sequence of supersteps and message types is correct?
A. agent (AIMessage with tool_calls=[list_files]) → tools (ToolMessage) → agent (AIMessage with tool_calls=[read_file]) → tools (ToolMessage) → agent (AIMessage, no tool_calls) → END
B. agent (HumanMessage) → ToolNode (executes list_files, read_file in parallel) → agent (final answer)
C. tools (executes list_files) → agent (decides to call read_file) → tools (executes read_file) → END
D. agent (AIMessage with all tool_calls) → ToolNode (batch-executes both tools) → agent (AIMessage, no tool_calls) → END
Answers
Q1: C. create_react_agent from langgraph.prebuilt is the legacy idiom, deprecated in LangGraph 1.0, scheduled for removal in 2.0. The v1.x idiom is from langchain.agents import create_agent. The package changed from langgraph to langchain; ToolNode and tools_condition stay in langgraph.prebuilt (they are not deprecated). Option A is the most common misconception; roughly 60–80% of tutorials in the wild still use the deprecated form.
Q2: B. ToolNode(tools, handle_tool_errors=True) intercepts exceptions raised inside tools and returns them as ToolMessage objects with the error text. The model sees the error, reasons about it, and can take corrective action (e.g., call list_files first to confirm the file exists). Option A silently returns empty content — the model thinks the file is empty, not missing, and reasons incorrectly. Option C throws away all accumulated state. Option D is unrelated to exception handling.
Q3: C. response_format=Plan with Pydantic is the correct choice. It validates the model’s output against the schema before returning, gives callers a typed Plan instance, and raises ValidationError if the model produces malformed output. Regex parsing (option B) produces a string or dict that every consumer must parse independently, with no guarantee of schema conformance. Option D is incorrect — create_agent picks the strategy automatically based on model capabilities; manual override is optional, not required.
Q4: B. The model’s decision to call a tool is entirely based on the tool’s name and docstring. A missing or vague docstring gives the model no signal about when the tool is appropriate. The fix is a clear, specific docstring that describes what the tool does, what it returns, and when to use it. Option A is incorrect — search_code is a standard @tool compatible with create_agent. Options C and D are fabricated constraints that do not exist.
Q5: A. Each tool call is a separate superstep cycle: agent emits an AIMessage with tool_calls=[list_files], ToolNode executes list_files and appends a ToolMessage, agent sees the result and emits another AIMessage with tool_calls=[read_file], ToolNode executes read_file, and then agent produces its final response with no tool calls, and tools_condition routes to END. Option D describes a different pattern (batch parallel tool calls, where the model emits multiple tool calls in a single message) — this can happen in one superstep when the model decides to call several tools at once, but the question describes sequential reasoning where each observation informs the next call.
Traps to avoid
Trap 1: Importing from the wrong package. from langgraph.prebuilt import create_react_agent is the single most common v0→v1 mistake. It was correct before October 2025; it is deprecated now and will break in LangGraph 2.0. The v1.x import is from langchain.agents import create_agent (from the langchain package, not langgraph). ToolNode and tools_condition stay in langgraph.prebuilt — do not “fix” those imports.
Trap 2: Confusing tool calling with code execution. The model does not execute your tools. The model requests a tool call; your LangGraph runtime executes it. This distinction matters for security (sandboxing write operations), for debugging (the model cannot lie about what a tool returned — the ToolMessage is your record), and for testing (you can verify tool behavior without a model by calling the function directly). When Forge eventually gains a write_file tool (Module 5), the sandbox matters precisely because your code is writing files, and that must be bounded.
Trap 3: Treating a Plan string as equivalent to a Plan object. If you skip response_format=Plan and just ask the model to “return a JSON object”, you get a string. Your consumer has to json.loads() it, handle malformed JSON, and validate field types manually — and every consumer does this differently. With response_format=Plan, create_agent does the validation for you at the agent boundary. The Plan object reaching Module 5 is guaranteed to have target_files: list[str] — not "target_files" as a JSON string in an unvalidated dict.
Key takeaways
- A tool (
@tool) is defined by three things the model sees: its name, its docstring (the spec — what it does), and its type hints (the schema — what arguments to pass). All three must be precise. ToolNodeexecutes tool calls requested by the model;tools_conditionroutes back to the agent if there are more tool calls or toENDif the model is done. Both come fromlanggraph.prebuiltand are not deprecated.create_agentfromlangchain.agentsis the v1.x idiom for building a ReAct agent loop.create_react_agentfromlanggraph.prebuiltis deprecated in v1.0 and scheduled for removal in 2.0 — do not use it in new code.- Structured output (
response_format=Plan) turns a model response into a validated Pydantic instance. It is a typed API contract that every downstream module can rely on. Free-form text is not. ToolStrategyenforces structure by wrapping the schema as a tool call (universal);ProviderStrategyuses native provider structured output (faster, provider-dependent).create_agentpicks the appropriate strategy automatically.ToolNode(tools, handle_tool_errors=True)converts tool exceptions into recoverableToolMessageobjects instead of crashing the run. Use it.- Middleware (
middleware=[...]oncreate_agent) is the v1.x extension point that replaces the removedAgentState/ValidationNodepattern. MCP (Model Context Protocol) is the standard for exposing external tools to an agent — relevant when tools live outside the process. Both are advanced topics, not built in Forge this module.
What’s next
Want the full LangGraph mastery question bank (interview-style) and the next module in your inbox? Subscribe here — it’s free, like everything in this series.
Forge can now read Tally and produce a Plan with a list of target files. But it still edits them one at a time. Module 5 fans those targets out with the Send API — from langgraph.types import Send — and edits every file in parallel, then reduces the results back into one branch via operator.add.
Links:
- Lab code — Module 04
- Module 03 — Control Flow: Conditional Edges, Command, and Self-Correcting Cycles
- Module 05 — Parallel Work: The Send API, Map-Reduce, and Fan-out/Fan-in
- Course index — LangGraph in Production
References
-
LangChain
create_agentdocumentation — python.langchain.com/docs/concepts/agents — the v1.x agent API, includingresponse_format,middleware, and the migration fromcreate_react_agent. -
LangGraph prebuilt:
ToolNodeandtools_condition— langchain-ai.github.io/langgraph/reference/prebuilt —ToolNode,tools_condition, andhandle_tool_errors— not deprecated in v1.x. -
LangGraph structured output guide — langchain-ai.github.io/langgraph/how-tos/react-agent-structured-output —
response_format,ToolStrategy,ProviderStrategy, andwith_structured_outputas a lower-level alternative. -
langchain-mcp-adapters— github.com/langchain-ai/langchain-mcp-adapters —MultiServerMCPClientand tool integration for the Model Context Protocol (spec 2025-03-26: modelcontextprotocol.io/specification). -
ReAct: Synergizing Reasoning and Acting in Language Models — Yao et al., arXiv 2210.03629 (2022) — the foundational paper for the reason-act-observe loop that
create_agentimplements. -
LangGraph How-to: tool calling — langchain-ai.github.io/langgraph/how-tos/tool-calling — wiring
ToolNodeandtools_conditioninto a custom graph.