aiml
SearchMy learning
Menu

Lesson 1 of 9Module 1

Build a graph agent with tools

Build a code-review agent as a LangGraph StateGraph with a ToolNode and tools_condition, run it on Gemini, and handle tool errors and the step limit.

Prerequisites

Python 3.12 or later, a Gemini API key from Google AI Studio (or Ollama with gemma4:e2b), and the packages in requirements-lock.txt. You should know what a tool call and a tool result are in a chat model API.

By the end, you can

  • Build a LangGraph StateGraph whose model node calls tools through ToolNode and loops until the model stops calling them.
  • Explain what the graph state holds after each step, and how the add_messages reducer and recursion_limit shape a run.
  • Decide which tool errors become messages for the model and which stop the run, and test both without a model.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b)
Dependencies
langgraph 1.2.16, langchain-core 1.6.12, langchain-google-genai 4.4.2; langchain-ollama 1.1.0 for the local alternative; pytest 9.1.1 for the offline tests

Lab code, tests and fixtures (ZIP, 1.8 MB)Quiz check your understanding

Progress is saved in this browser.

A tool-using agent is a loop: the model reads the conversation, either answers or asks for tool calls, your code runs the calls, and the results go back to the model. We build that loop as a LangGraph StateGraph for a code-review assistant that answers questions about a team’s open pull requests with three read-only tools. LangGraph makes each part of the loop a named node and each decision an edge, so you can see, test and limit every step. The saved runs below use Gemini (gemini-3.8-flash).

When you need this. Use a graph agent when the model must choose which lookups to make before answering. A reviewer asks which open pull requests are blocked on CI: the assistant lists them, checks their status and replies. The graph exposes the model and tool turns so the application can route and limit them.

graph.py · StateGraph(MessagesState) · gemini-3.8-flash through ChatGoogleGenerativeAI
DeveloperquestionHumanMessagecompiled graph: graph.compile()agentllm.invoke(system,*messages)gemini-3.8-flashChatGoogle-GenerativeAItools boundmessages + 3 tool schemasAIMessage: text or tool_callsAIMessagetoolcalls?tools_conditionyestools: ToolNoderuns every call,one ToolMessage eachToolMessagescallreview_queue.pyPULL_REQUESTSnoENDlast message = answerUnknownPullRequestwithouthandle_tool_errors:the run stopsGraphRecursionErrorstep limit reacheddata flowreturnfailureDeveloperquestiongemini-3.8-flashHumanMessagerequestAIMessagecompiled graphagentllm.invoke(...)AIMessagetoolcalls?yesToolNoderuns eachcallToolMessagescallreview_queue.pyPULL_REQUESTSnoENDanswerUnknownPullRequestrun stopsGraphRecursionErrordata flowreturnfailure
Tool calls route to ToolNode, whose results return to the agent. A reply without tool calls ends the graph; the recursion limit bounds graph steps.

The LangGraph course compares self-contained graphs around the same pull-request review scenario. The first two lessons extend this tool loop; later labs isolate budgets, checkpoints, retries, verification, approval or memory. They do not claim one graph already combines every mechanism.

Define the tools

Download the complete lab, extract it and open a terminal in build-a-graph-agent-with-tools/. The README lists its runtime and recorded examples.

Prepare the local labTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

Follow the lab setup and run instructions before executing this lesson.

Each tool is a typed Python function. The @tool decorator from LangChain turns the function name, its type hints and its docstring into the JSON schema the model sees, so the docstring is part of the prompt: it tells the model what the tool returns.

review_queue.pySource · pythonDownload
"""A small pull-request queue and the three read-only tools the review agent uses."""
from langchain_core.tools import tool

PULL_REQUESTS = {
    412: {
        "title": "Retry webhook delivery on 503",
        "author": "mlee",
        "ci": "passed",
        "diff": "webhooks/sender.py: wrap post() in a loop of 3 attempts with 2s backoff on HTTP 503\n"
                "tests/test_sender.py: add test_retries_on_503",
    },
    415: {
        "title": "Raise the default HTTP timeout to 30s",
        "author": "dkim",
        "ci": "failed: tests/test_client.py::test_default_timeout expected 10, got 30",
        "diff": "client/http.py: DEFAULT_TIMEOUT = 10 -> DEFAULT_TIMEOUT = 30",
    },
    418: {
        "title": "Remove the unused legacy CSV export",
        "author": "apatel",
        "ci": "passed",
        "diff": "export/csv_legacy.py: deleted (212 lines)\nexport/__init__.py: drop csv_legacy import",
    },
}


class UnknownPullRequest(LookupError):
    """The model asked for a pull request that is not in the queue."""


def _pull_request(number: int) -> dict:
    if number not in PULL_REQUESTS:
        raise UnknownPullRequest(f"There is no open pull request #{number}.")
    return PULL_REQUESTS[number]


@tool
def list_open_prs() -> list[dict]:
    """List the open pull requests with their number, title and author."""
    return [{"number": n, "title": p["title"], "author": p["author"]} for n, p in PULL_REQUESTS.items()]


@tool
def get_diff(number: int) -> str:
    """Get the changed files and a one-line summary of each change for a pull request."""
    return _pull_request(number)["diff"]


@tool
def get_ci_status(number: int) -> str:
    """Get the CI result for a pull request: 'passed', or 'failed:' and the failing test."""
    return _pull_request(number)["ci"]

The queue is an in-memory dictionary, so the lesson runs without a code host. A lookup for a missing pull request raises UnknownPullRequest, its own exception type. The type matters in the next step: it is how the graph tells a mistake the model made from a bug in your code.

Build the graph

The graph has two nodes and one conditional edge:

graph.pySource · pythonDownload
"""A code-review agent as a LangGraph StateGraph: the model calls tools until it can answer."""
import os
import sys

from langchain_core.messages import HumanMessage, SystemMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_ollama import ChatOllama
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition

from review_queue import UnknownPullRequest, get_ci_status, get_diff, list_open_prs

TOOLS = [list_open_prs, get_diff, get_ci_status]
SYSTEM = """You help a developer work through the team's code-review queue.
Use the tools to look up pull requests; never guess a diff or a CI result.
Answer in at most three sentences and name each pull request by its number."""


# Gemini by default; REVIEW_MODEL=gemma4:e2b runs the same graph on a local Ollama model.
MODEL = os.environ.get("REVIEW_MODEL", "gemini-3.8-flash")


def chat_model():
    if MODEL.startswith("gemini"):
        return ChatGoogleGenerativeAI(model=MODEL)  # reads GOOGLE_API_KEY from the environment
    return ChatOllama(model=MODEL, temperature=0, reasoning=False)


def build_graph(model=None, *, catch_unknown_pr=True):
    llm = (model or chat_model()).bind_tools(TOOLS)

    def agent(state: MessagesState) -> dict:
        return {"messages": [llm.invoke([SystemMessage(SYSTEM), *state["messages"]])]}

    graph = StateGraph(MessagesState)
    graph.add_node("agent", agent)
    # ToolNode runs every tool call in the last AIMessage and returns one ToolMessage per call.
    # By default it re-raises errors raised inside a tool; this one becomes a ToolMessage instead.
    tools = ToolNode(TOOLS, handle_tool_errors=UnknownPullRequest) if catch_unknown_pr else ToolNode(TOOLS)
    graph.add_node("tools", tools)
    graph.add_edge(START, "agent")
    # tools_condition: "tools" if the last message has tool calls, otherwise END. This is the stop condition.
    graph.add_conditional_edges("agent", tools_condition)
    graph.add_edge("tools", "agent")
    return graph.compile()


if __name__ == "__main__":
    question = " ".join(sys.argv[1:]) or "Which open pull request has failing CI, and why?"
    result = build_graph().invoke({"messages": [HumanMessage(question)]}, {"recursion_limit": 12})
    print(result["messages"][-1].text)
  • State. MessagesState is a state with one key, messages, whose reducer is add_messages. A node returns only what it changes, {"messages": [reply]}, and the reducer appends messages with new IDs instead of replacing the whole list. An update with an existing message ID replaces that message, which the later approval lesson uses when editing a proposal.
  • agent node. It sends the system prompt and the whole message list to the model. bind_tools attaches the three tool schemas to every request.
  • tools node. ToolNode reads the tool calls in the last AIMessage, runs each one, and returns one ToolMessage per call with the call’s ID, so the model can match each result to its request.
  • Stop condition. tools_condition routes to the node named tools when the last message has tool calls, and to END when it has none. A reply without tool calls is the answer.
  • Loop. The plain edge from tools back to agent closes the loop.

You could write the tool node yourself; the LangGraph agents example does, with a function that looks each tool up by name. ToolNode adds the parts that are easy to get wrong: it runs the calls from one reply concurrently, keeps the call IDs, injects runtime values into tools that ask for them, and applies an error policy. LangChain’s create_agent builds a similar loop for you. Writing the graph by hand costs a few lines and gives you the nodes and edges that later lessons change.

An unknown pull request. By default, ToolNode turns only argument errors into tool messages; an exception raised inside a tool propagates, and invoke raises it. With ToolNode(TOOLS), the question about pull request 999 ends with UnknownPullRequest: There is no open pull request #999., and the caller gets no answer. With handle_tool_errors=UnknownPullRequest, the same exception becomes an error ToolMessage, Error: UnknownPullRequest('There is no open pull request #999.'), and the model answers that no such pull request is open. Catch only exception types that describe a model mistake. handle_tool_errors=True would also hand a KeyError from a bug in your code to the model as an error message. The caller would receive state containing that message instead of the exception propagating from invoke.

Run it on Gemini

Install the pinned packages, export your Gemini API key as GOOGLE_API_KEY, and ask a question. ChatGoogleGenerativeAI reads the key from the environment, so this example reads it without embedding it in the source:

Terminal commandsTerminal · bash
python3 -m pip install -r requirements-lock.txt
python3 graph.py "Which open pull request has failing CI, and why?"

Tip: To run the same graph on a local model, start Ollama with gemma4:e2b and set REVIEW_MODEL=gemma4:e2b. chat_model() then returns ChatOllama instead of ChatGoogleGenerativeAI; the graph, tools and tests do not change.

record.py runs three questions and two failure variants, saves completed node updates to runs.json, and prints them. A node that raises an exception produces no update, so its failed attempt is not included in the completed-step count:

Inspect record.py
record.pySource · pythonDownload
"""Run the review agent and save completed graph steps, proposed tool calls and answers."""
import json

from langchain_core.messages import HumanMessage

from graph import MODEL, build_graph

QUESTIONS = [
    "Which open pull request has failing CI, and why?",
    "What does pull request 418 change, and did its CI pass?",
    "Is CI green on pull request 999?",
]


def compact(m) -> str:
    """One line per message in the graph state, in the order the add_messages reducer kept them."""
    if m.type == "ai" and m.tool_calls:
        return "ai: calls " + ", ".join(c["name"] for c in m.tool_calls)
    return f"{m.type}: {m.name}" if m.type == "tool" else m.type


def run(graph, question: str, limit: int = 12) -> dict:
    steps, answer, error, state = [], None, None, []
    try:
        for kind, update in graph.stream({"messages": [HumanMessage(question)]}, {"recursion_limit": limit},
                                         stream_mode=["updates", "values"]):
            if kind == "values":
                state = [compact(m) for m in update["messages"]]
                continue
            # One update per completed node run; a failed node emits no update. Only one node runs at a time in this graph, so each update is one graph
            # step: ToolNode runs all the calls of one AIMessage in one step, however many ToolMessages it returns.
            for node, delta in update.items():
                if node == "tools":
                    steps.append({"node": node, "results": [{"tool": m.name, "status": m.status, "content": m.content}
                                                            for m in delta["messages"]]})
                    continue
                m = delta["messages"][-1]
                if m.tool_calls:
                    steps.append({"node": node, "tool_calls": [{"name": c["name"], "args": c["args"]} for c in m.tool_calls]})
                else:
                    steps.append({"node": node, "answer": m.text})
                    answer = m.text
    except Exception as e:  # recorded, not hidden: the run stops with this exception
        error = f"{type(e).__name__}: {e}"
    return {"question": question, "recursion_limit": limit, "state": state, "path": [s["node"] for s in steps],
            "graph_steps": len(steps), "model_calls": sum(s["node"] == "agent" for s in steps),
            "tool_calls": sum(len(s.get("tool_calls", [])) for s in steps), "messages": len(state),
            "steps": steps, "answer": answer, "error": error}


def main() -> None:
    runs = [run(build_graph(), q) for q in QUESTIONS]
    # The unknown pull request again, with ToolNode's default error handling.
    runs.append({**run(build_graph(catch_unknown_pr=False), QUESTIONS[2]), "catch_unknown_pr": False})
    # The first question again with too few steps for the tool calls it makes.
    runs.append(run(build_graph(), QUESTIONS[0], limit=4))
    with open("runs.json", "w") as f:
        json.dump({"model": MODEL, "runs": runs}, f, indent=1)
    for r in runs:
        note = "  [default tool errors]" if r.get("catch_unknown_pr") is False else ""
        print(f"== {r['question']}{note}  (recursion_limit={r['recursion_limit']})")
        for s in r["steps"]:
            if "tool_calls" in s:
                print("  agent  ->", ", ".join(f"{c['name']}({json.dumps(c['args'])})" for c in s["tool_calls"]))
            elif "results" in s:
                for i, t in enumerate(s["results"]):
                    print("  tools  ->" if i == 0 else "           ", f"{t['tool']} [{t['status']}]: {t['content']}")
        print("  answer:", r["answer"]) if r["answer"] else print("  error: ", r["error"].splitlines()[0])
        print("  state: ", " | ".join(r["state"]))
        print(f"  completed graph steps: {r['graph_steps']} ({' → '.join(r['path'])}), model calls: {r['model_calls']},"
              f" tool calls: {r['tool_calls']}, messages in state: {r['messages']}")


if __name__ == "__main__":
    main()
run-output.txtRecorded output · textDownload
== Which open pull request has failing CI, and why?  (recursion_limit=12)
  agent  -> list_open_prs({})
  tools  -> list_open_prs [success]: [{"number": 412, "title": "Retry webhook delivery on 503", "author": "mlee"}, {"number": 415, "title": "Raise the default HTTP timeout to 30s", "author": "dkim"}, {"number": 418, "title": "Remove the unused legacy CSV export", "author": "apatel"}]
  agent  -> get_ci_status({"number": 412}), get_ci_status({"number": 415}), get_ci_status({"number": 418})
  tools  -> get_ci_status [success]: passed
            get_ci_status [success]: failed: tests/test_client.py::test_default_timeout expected 10, got 30
            get_ci_status [success]: passed
  agent  -> get_diff({"number": 415})
  tools  -> get_diff [success]: client/http.py: DEFAULT_TIMEOUT = 10 -> DEFAULT_TIMEOUT = 30
  answer: Pull request #415 has failing CI. The test `tests/test_client.py::test_default_timeout` failed because it expected a timeout value of 10, but got 30 after the default HTTP timeout was increased.
  state:  human | ai: calls list_open_prs | tool: list_open_prs | ai: calls get_ci_status, get_ci_status, get_ci_status | tool: get_ci_status | tool: get_ci_status | tool: get_ci_status | ai: calls get_diff | tool: get_diff | ai
  completed graph steps: 7 (agent → tools → agent → tools → agent → tools → agent), model calls: 4, tool calls: 5, messages in state: 10
== What does pull request 418 change, and did its CI pass?  (recursion_limit=12)
  agent  -> get_diff({"number": 418}), get_ci_status({"number": 418})
  tools  -> get_diff [success]: export/csv_legacy.py: deleted (212 lines)
export/__init__.py: drop csv_legacy import
            get_ci_status [success]: passed
  answer: Pull request 418 deletes `export/csv_legacy.py` (212 lines) and removes the `csv_legacy` import from `export/__init__.py`. Its CI check passed.
  state:  human | ai: calls get_diff, get_ci_status | tool: get_diff | tool: get_ci_status | ai
  completed graph steps: 3 (agent → tools → agent), model calls: 2, tool calls: 2, messages in state: 5
== Is CI green on pull request 999?  (recursion_limit=12)
  agent  -> get_ci_status({"number": 999})
  tools  -> get_ci_status [error]: Error: UnknownPullRequest('There is no open pull request #999.')
 Please fix your mistakes.
  agent  -> list_open_prs({})
  tools  -> list_open_prs [success]: [{"number": 412, "title": "Retry webhook delivery on 503", "author": "mlee"}, {"number": 415, "title": "Raise the default HTTP timeout to 30s", "author": "dkim"}, {"number": 418, "title": "Remove the unused legacy CSV export", "author": "apatel"}]
  answer: Pull request #999 does not exist. The currently open pull requests are #412, #415, and #418.
  state:  human | ai: calls get_ci_status | tool: get_ci_status | ai: calls list_open_prs | tool: list_open_prs | ai
  completed graph steps: 5 (agent → tools → agent → tools → agent), model calls: 3, tool calls: 2, messages in state: 6
== Is CI green on pull request 999?  [default tool errors]  (recursion_limit=12)
  agent  -> get_ci_status({"number": 999})
  error:  UnknownPullRequest: There is no open pull request #999.
  state:  human | ai: calls get_ci_status
  completed graph steps: 1 (agent), model calls: 1, tool calls: 1, messages in state: 2
== Which open pull request has failing CI, and why?  (recursion_limit=4)
  agent  -> list_open_prs({})
  tools  -> list_open_prs [success]: [{"number": 412, "title": "Retry webhook delivery on 503", "author": "mlee"}, {"number": 415, "title": "Raise the default HTTP timeout to 30s", "author": "dkim"}, {"number": 418, "title": "Remove the unused legacy CSV export", "author": "apatel"}]
  agent  -> get_ci_status({"number": 412}), get_ci_status({"number": 415}), get_ci_status({"number": 418})
  tools  -> get_ci_status [success]: passed
            get_ci_status [success]: failed: tests/test_client.py::test_default_timeout expected 10, got 30
            get_ci_status [success]: passed
  error:  GraphRecursionError: Recursion limit of 4 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.
  state:  human | ai: calls list_open_prs | tool: list_open_prs | ai: calls get_ci_status, get_ci_status, get_ci_status | tool: get_ci_status | tool: get_ci_status | tool: get_ci_status
  completed graph steps: 4 (agent → tools → agent → tools), model calls: 2, tool calls: 4, messages in state: 7
Inspect the complete recorded runs
runs.jsonRecorded output · jsonDownload
{
 "model": "gemini-3.8-flash",
 "runs": [
  {
   "question": "Which open pull request has failing CI, and why?",
   "recursion_limit": 12,
   "state": [
    "human",
    "ai: calls list_open_prs",
    "tool: list_open_prs",
    "ai: calls get_ci_status, get_ci_status, get_ci_status",
    "tool: get_ci_status",
    "tool: get_ci_status",
    "tool: get_ci_status",
    "ai: calls get_diff",
    "tool: get_diff",
    "ai"
   ],
   "path": [
    "agent",
    "tools",
    "agent",
    "tools",
    "agent",
    "tools",
    "agent"
   ],
   "graph_steps": 7,
   "model_calls": 4,
   "tool_calls": 5,
   "messages": 10,
   "steps": [
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "list_open_prs",
       "args": {}
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "list_open_prs",
       "status": "success",
       "content": "[{\"number\": 412, \"title\": \"Retry webhook delivery on 503\", \"author\": \"mlee\"}, {\"number\": 415, \"title\": \"Raise the default HTTP timeout to 30s\", \"author\": \"dkim\"}, {\"number\": 418, \"title\": \"Remove the unused legacy CSV export\", \"author\": \"apatel\"}]"
      }
     ]
    },
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "get_ci_status",
       "args": {
        "number": 412
       }
      },
      {
       "name": "get_ci_status",
       "args": {
        "number": 415
       }
      },
      {
       "name": "get_ci_status",
       "args": {
        "number": 418
       }
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "get_ci_status",
       "status": "success",
       "content": "passed"
      },
      {
       "tool": "get_ci_status",
       "status": "success",
       "content": "failed: tests/test_client.py::test_default_timeout expected 10, got 30"
      },
      {
       "tool": "get_ci_status",
       "status": "success",
       "content": "passed"
      }
     ]
    },
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "get_diff",
       "args": {
        "number": 415
       }
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "get_diff",
       "status": "success",
       "content": "client/http.py: DEFAULT_TIMEOUT = 10 -> DEFAULT_TIMEOUT = 30"
      }
     ]
    },
    {
     "node": "agent",
     "answer": "Pull request #415 has failing CI. The test `tests/test_client.py::test_default_timeout` failed because it expected a timeout value of 10, but got 30 after the default HTTP timeout was increased."
    }
   ],
   "answer": "Pull request #415 has failing CI. The test `tests/test_client.py::test_default_timeout` failed because it expected a timeout value of 10, but got 30 after the default HTTP timeout was increased.",
   "error": null
  },
  {
   "question": "What does pull request 418 change, and did its CI pass?",
   "recursion_limit": 12,
   "state": [
    "human",
    "ai: calls get_diff, get_ci_status",
    "tool: get_diff",
    "tool: get_ci_status",
    "ai"
   ],
   "path": [
    "agent",
    "tools",
    "agent"
   ],
   "graph_steps": 3,
   "model_calls": 2,
   "tool_calls": 2,
   "messages": 5,
   "steps": [
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "get_diff",
       "args": {
        "number": 418
       }
      },
      {
       "name": "get_ci_status",
       "args": {
        "number": 418
       }
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "get_diff",
       "status": "success",
       "content": "export/csv_legacy.py: deleted (212 lines)\nexport/__init__.py: drop csv_legacy import"
      },
      {
       "tool": "get_ci_status",
       "status": "success",
       "content": "passed"
      }
     ]
    },
    {
     "node": "agent",
     "answer": "Pull request 418 deletes `export/csv_legacy.py` (212 lines) and removes the `csv_legacy` import from `export/__init__.py`. Its CI check passed."
    }
   ],
   "answer": "Pull request 418 deletes `export/csv_legacy.py` (212 lines) and removes the `csv_legacy` import from `export/__init__.py`. Its CI check passed.",
   "error": null
  },
  {
   "question": "Is CI green on pull request 999?",
   "recursion_limit": 12,
   "state": [
    "human",
    "ai: calls get_ci_status",
    "tool: get_ci_status",
    "ai: calls list_open_prs",
    "tool: list_open_prs",
    "ai"
   ],
   "path": [
    "agent",
    "tools",
    "agent",
    "tools",
    "agent"
   ],
   "graph_steps": 5,
   "model_calls": 3,
   "tool_calls": 2,
   "messages": 6,
   "steps": [
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "get_ci_status",
       "args": {
        "number": 999
       }
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "get_ci_status",
       "status": "error",
       "content": "Error: UnknownPullRequest('There is no open pull request #999.')\n Please fix your mistakes."
      }
     ]
    },
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "list_open_prs",
       "args": {}
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "list_open_prs",
       "status": "success",
       "content": "[{\"number\": 412, \"title\": \"Retry webhook delivery on 503\", \"author\": \"mlee\"}, {\"number\": 415, \"title\": \"Raise the default HTTP timeout to 30s\", \"author\": \"dkim\"}, {\"number\": 418, \"title\": \"Remove the unused legacy CSV export\", \"author\": \"apatel\"}]"
      }
     ]
    },
    {
     "node": "agent",
     "answer": "Pull request #999 does not exist. The currently open pull requests are #412, #415, and #418."
    }
   ],
   "answer": "Pull request #999 does not exist. The currently open pull requests are #412, #415, and #418.",
   "error": null
  },
  {
   "question": "Is CI green on pull request 999?",
   "recursion_limit": 12,
   "state": [
    "human",
    "ai: calls get_ci_status"
   ],
   "path": [
    "agent"
   ],
   "graph_steps": 1,
   "model_calls": 1,
   "tool_calls": 1,
   "messages": 2,
   "steps": [
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "get_ci_status",
       "args": {
        "number": 999
       }
      }
     ]
    }
   ],
   "answer": null,
   "error": "UnknownPullRequest: There is no open pull request #999.",
   "catch_unknown_pr": false
  },
  {
   "question": "Which open pull request has failing CI, and why?",
   "recursion_limit": 4,
   "state": [
    "human",
    "ai: calls list_open_prs",
    "tool: list_open_prs",
    "ai: calls get_ci_status, get_ci_status, get_ci_status",
    "tool: get_ci_status",
    "tool: get_ci_status",
    "tool: get_ci_status"
   ],
   "path": [
    "agent",
    "tools",
    "agent",
    "tools"
   ],
   "graph_steps": 4,
   "model_calls": 2,
   "tool_calls": 4,
   "messages": 7,
   "steps": [
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "list_open_prs",
       "args": {}
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "list_open_prs",
       "status": "success",
       "content": "[{\"number\": 412, \"title\": \"Retry webhook delivery on 503\", \"author\": \"mlee\"}, {\"number\": 415, \"title\": \"Raise the default HTTP timeout to 30s\", \"author\": \"dkim\"}, {\"number\": 418, \"title\": \"Remove the unused legacy CSV export\", \"author\": \"apatel\"}]"
      }
     ]
    },
    {
     "node": "agent",
     "tool_calls": [
      {
       "name": "get_ci_status",
       "args": {
        "number": 412
       }
      },
      {
       "name": "get_ci_status",
       "args": {
        "number": 415
       }
      },
      {
       "name": "get_ci_status",
       "args": {
        "number": 418
       }
      }
     ]
    },
    {
     "node": "tools",
     "results": [
      {
       "tool": "get_ci_status",
       "status": "success",
       "content": "passed"
      },
      {
       "tool": "get_ci_status",
       "status": "success",
       "content": "failed: tests/test_client.py::test_default_timeout expected 10, got 30"
      },
      {
       "tool": "get_ci_status",
       "status": "success",
       "content": "passed"
      }
     ]
    }
   ],
   "answer": null,
   "error": "GraphRecursionError: Recursion limit of 4 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.\nFor troubleshooting, visit: https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT"
  }
 ]
}

The second question batched its two tool calls into one reply, and ToolNode ran both in one tools step. The first question took 4 model calls and 7 graph steps for 5 tool calls. These counts describe different work: one model reply can ask for several calls, and one tools step returns one message per call. The graph runs whatever the model asks for, so the number of rounds can change between runs. A tool shaped for the question, such as one that lists pull requests with their CI result, is the lever you control.

Too few steps. With recursion_limit set to 4, the first question stops with GraphRecursionError: Recursion limit of 4 reached without hitting a stop condition after 4 steps. All three CI results had come back, but the next agent step had not run. That next step could answer or request another tool; the recorded failure does not establish how many more steps the answer would need. The state keeps every message up to that point, but invoke returns nothing. Set the limit per call from the work you expect, catch GraphRecursionError where you call the graph, and treat it as an incomplete run, not as an answer.

Read a recorded tool round

The saved PR #415 run requested get_diff(415) and get_ci_status(415) in one model reply. ToolNode returned both results, then the model explained the timeout change. The path was agent → tools → agent: two model calls and two tool calls across three graph steps. The tools read the local PR fixture.

Inspect the saved events
Genuine Gemini run eventsRecorded output · jsonDownload
[
  {
    "event": "task",
    "question": "For pull request #415, inspect its diff and CI status. In at most three sentences, explain why CI fails and which changed constant conflicts with the test. Use the supplied read-only tools; do not change files or CI.",
    "policy": {
      "lesson_id": "build-a-graph-agent-with-tools",
      "model": "gemini-3.8-flash",
      "max_model_calls": 3,
      "max_output_tokens": 4096,
      "thinking_level": "low",
      "timeout_seconds": 45,
      "max_retries": 0,
      "vertexai": false
    }
  },
  {
    "event": "model_request",
    "ordinal": 1,
    "policy": {
      "lesson_id": "build-a-graph-agent-with-tools",
      "model": "gemini-3.8-flash",
      "max_model_calls": 3,
      "max_output_tokens": 4096,
      "thinking_level": "low",
      "timeout_seconds": 45,
      "max_retries": 0,
      "vertexai": false
    }
  },
  {
    "event": "model_response",
    "ordinal": 1,
    "message": {
      "type": "ai",
      "text": "",
      "tool_calls": [
        {
          "name": "get_diff",
          "args": {
            "number": 415
          },
          "id": "call_23483"
        },
        {
          "name": "get_ci_status",
          "args": {
            "number": 415
          },
          "id": "call_23484"
        }
      ],
      "usage": {
        "input_tokens": 243,
        "output_tokens": 34,
        "total_tokens": 277,
        "input_token_details": {
          "cache_read": 0
        }
      },
      "finish_reason": "STOP"
    }
  },
  {
    "event": "graph_step",
    "node": "agent",
    "messages": [
      {
        "type": "ai",
        "text": "",
        "tool_calls": [
          {
            "name": "get_diff",
            "args": {
              "number": 415
            },
            "id": "call_23483"
          },
          {
            "name": "get_ci_status",
            "args": {
              "number": 415
            },
            "id": "call_23484"
          }
        ],
        "usage": {
          "input_tokens": 243,
          "output_tokens": 34,
          "total_tokens": 277,
          "input_token_details": {
            "cache_read": 0
          }
        },
        "finish_reason": "STOP"
      }
    ]
  },
  {
    "event": "graph_step",
    "node": "tools",
    "messages": [
      {
        "type": "tool",
        "text": "client/http.py: DEFAULT_TIMEOUT = 10 -> DEFAULT_TIMEOUT = 30",
        "name": "get_diff",
        "tool_call_id": "call_23483",
        "status": "success"
      },
      {
        "type": "tool",
        "text": "failed: tests/test_client.py::test_default_timeout expected 10, got 30",
        "name": "get_ci_status",
        "tool_call_id": "call_23484",
        "status": "success"
      }
    ]
  },
  {
    "event": "model_request",
    "ordinal": 2,
    "policy": {
      "lesson_id": "build-a-graph-agent-with-tools",
      "model": "gemini-3.8-flash",
      "max_model_calls": 3,
      "max_output_tokens": 4096,
      "thinking_level": "low",
      "timeout_seconds": 45,
      "max_retries": 0,
      "vertexai": false
    }
  },
  {
    "event": "model_response",
    "ordinal": 2,
    "message": {
      "type": "ai",
      "text": "In pull request #415, CI fails because the test `tests/test_client.py::test_default_timeout` expects a timeout value of 10 but received 30. This failure is caused by changing the `DEFAULT_TIMEOUT` constant in `client/http.py` from 10 to 30.",
      "tool_calls": [],
      "usage": {
        "input_tokens": 348,
        "output_tokens": 72,
        "total_tokens": 420,
        "input_token_details": {
          "cache_read": 0
        }
      },
      "finish_reason": "STOP"
    }
  },
  {
    "event": "graph_step",
    "node": "agent",
    "messages": [
      {
        "type": "ai",
        "text": "In pull request #415, CI fails because the test `tests/test_client.py::test_default_timeout` expects a timeout value of 10 but received 30. This failure is caused by changing the `DEFAULT_TIMEOUT` constant in `client/http.py` from 10 to 30.",
        "tool_calls": [],
        "usage": {
          "input_tokens": 348,
          "output_tokens": 72,
          "total_tokens": 420,
          "input_token_details": {
            "cache_read": 0
          }
        },
        "finish_reason": "STOP"
      }
    ]
  },
  {
    "event": "completion",
    "status": "completed_requires_semantic_review",
    "model_calls": 2,
    "answer": "In pull request #415, CI fails because the test `tests/test_client.py::test_default_timeout` expects a timeout value of 10 but received 30. This failure is caused by changing the `DEFAULT_TIMEOUT` constant in `client/http.py` from 10 to 30.",
    "literal_checks": {
      "names_pr": true,
      "names_constant": true,
      "names_expected_value": true,
      "names_changed_value": true,
      "names_test": true
    },
    "tools": [
      "get_ci_status",
      "get_diff"
    ]
  }
]

How it works

LangGraph runs a graph in steps. In each step, the nodes that are due run, their returned updates go through the state’s reducers, and the edges choose the next nodes. Here only one node runs at a time, so the path of the second run is agent → tools → agent: 3 steps. The state at its end holds human | ai: calls get_diff, get_ci_status | tool: get_diff | tool: get_ci_status | ai. The two tool messages come from one tools step because they answer two calls in one AIMessage.

recursion_limit caps the number of steps in one run and raises GraphRecursionError when the step limit is reached. A run with N node updates needs a limit greater than N in the installed version; the offline boundary test checks that behavior. The graph API documentation gives the default as 1000 since version 1.0.6; the installed langgraph 1.2.16 uses 10007 (DEFAULT_RECURSION_LIMIT in langgraph/_internal/_config.py). Either is far more than a review question needs, so record.py passes 12. Each tool round costs two steps, one agent and one tools, so a limit of N allows about N/2 tool rounds. The first question used 7 of 12 steps.

tools_condition looks only at the last message. It cannot tell a finished answer from a model that gave up, and it does not count anything. The state and branching lesson replaces it with a router that also stops repeated calls and runs that approach the step limit.

Test the graph without a model

The offline tests replace Gemini with a scripted chat model that returns fixed replies, so they run in under a second without an API key or a network call:

Terminal commandsTerminal · bash
python3 -m pytest -v test_graph.py

The suite has 10 tests. They pin these cases:

Case Expected behavior
A reply without tool calls The run ends after one agent step.
Tool results Go back to the agent node as tool messages.
Two tool calls in one reply Run in one tools step; each result keeps its call ID.
An unknown pull request Becomes an error tool message, and the model answers.
Default tool error handling The exception stops the run.
Arguments that do not match the tool schema Become an error tool message even with the default handling.
A model that never stops calling tools The scripted model raises GraphRecursionError at the step limit.
The recorded tool results Match what the tools return for the recorded calls.
Graph steps, tool calls and messages Two parallel tool calls return two messages in one tools step.
A step limit between parallel tool results Counts the node update once, not once per result.

Removing handle_tool_errors, or replacing tools_condition with an edge that always goes to tools, fails at least one test.

Inspect the tests and their output
test_graph.pySource · pythonDownload
"""Offline tests for the review agent. A scripted chat model replaces Gemma, so no Ollama is needed."""
import json
from itertools import count
from pathlib import Path

import pytest
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.errors import GraphRecursionError

import record
import review_queue
from graph import TOOLS, build_graph

ids = count()


class ScriptedChat(GenericFakeChatModel):
    """Returns the scripted messages in order; tool binding is a no-op."""

    def bind_tools(self, tools, **kwargs):
        return self


def call(*calls):
    return AIMessage("", tool_calls=[{"name": n, "args": a, "id": f"c{next(ids)}"} for n, a in calls])


def run(script, limit=12, **options):
    graph = build_graph(ScriptedChat(messages=iter(script)), **options)
    path, state = [], None
    for kind, data in graph.stream({"messages": [HumanMessage("q")]}, {"recursion_limit": limit},
                                   stream_mode=["updates", "values"]):
        if kind == "updates":
            path += list(data)
        else:
            state = data
    return path, state["messages"]


def test_a_reply_without_tool_calls_ends_the_run():
    path, messages = run([AIMessage("Nothing to look up.")])
    assert path == ["agent"]


def test_tool_results_go_back_to_the_agent():
    path, messages = run([call(("get_diff", {"number": 415})), AIMessage("It raises the timeout.")])
    assert path == ["agent", "tools", "agent"]
    assert messages[2].content == review_queue.PULL_REQUESTS[415]["diff"]


def test_parallel_tool_calls_run_in_one_tools_step():
    path, messages = run([call(("get_diff", {"number": 418}), ("get_ci_status", {"number": 418})), AIMessage("Done.")])
    assert path == ["agent", "tools", "agent"]
    asked = [c["id"] for c in messages[1].tool_calls]
    assert [m.tool_call_id for m in messages[2:4]] == asked
    assert messages[3].content == "passed"


def test_unknown_pull_request_becomes_an_error_tool_message():
    path, messages = run([call(("get_ci_status", {"number": 999})), AIMessage("No such pull request.")])
    assert path == ["agent", "tools", "agent"]
    assert messages[2].status == "error" and "no open pull request #999" in messages[2].content


def test_default_tool_errors_stop_the_run():
    with pytest.raises(review_queue.UnknownPullRequest):
        run([call(("get_ci_status", {"number": 999}))], catch_unknown_pr=False)
    recorded = record.run(build_graph(ScriptedChat(messages=iter([call(("get_ci_status", {"number": 999}))])),
                                      catch_unknown_pr=False), "q")
    assert recorded["error"].startswith("UnknownPullRequest")
    assert recorded["path"] == ["agent"] and recorded["graph_steps"] == 1  # failed tools node emitted no update


def test_invalid_arguments_become_an_error_tool_message_by_default():
    path, messages = run([call(("get_diff", {"number": "four-fifteen"})), AIMessage("Retrying.")],
                         catch_unknown_pr=False)
    assert messages[2].status == "error" and "number" in messages[2].content


def test_endless_tool_calls_hit_the_recursion_limit():
    endless = (call(("list_open_prs", {})) for _ in count())
    with pytest.raises(GraphRecursionError):
        run(endless, limit=6)


def test_recorded_tool_results_match_the_queue():
    record = json.loads((Path(__file__).parent / "runs.json").read_text())
    tools = {t.name: t for t in TOOLS}
    for r in record["runs"]:
        calls = [c for s in r["steps"] if "tool_calls" in s for c in s["tool_calls"]]
        results = [t for s in r["steps"] if s["node"] == "tools" for t in s["results"]]
        assert r["graph_steps"] == len(r["path"]) and r["tool_calls"] == len(calls)
        for c, result in zip(calls, results):
            if result["status"] == "success":
                out = tools[c["name"]].invoke(c["args"])
                assert result["content"] == (out if isinstance(out, str) else json.dumps(out))


def test_the_record_counts_steps_tool_calls_and_messages_separately():
    two_calls = call(("get_diff", {"number": 418}), ("get_ci_status", {"number": 418}))
    r = record.run(build_graph(ScriptedChat(messages=iter([two_calls, AIMessage("Done.")]))), "q")
    assert r["path"] == ["agent", "tools", "agent"]  # two ToolMessages, one tools step
    assert (r["graph_steps"], r["model_calls"], r["tool_calls"], r["messages"]) == (3, 2, 2, 5)
    assert [t["tool"] for t in r["steps"][1]["results"]] == ["get_diff", "get_ci_status"]


def test_the_step_limit_counts_graph_steps_not_messages():
    def three_steps(limit):
        two_calls = call(("get_diff", {"number": 418}), ("get_ci_status", {"number": 418}))
        return record.run(build_graph(ScriptedChat(messages=iter([two_calls, AIMessage("Done.")]))), "q", limit=limit)
    r = three_steps(4)  # 3 graph steps and 5 messages finish within a limit of 4
    assert r["error"] is None and (r["graph_steps"], r["messages"]) == (3, 5)
    r = three_steps(3)  # a run of N steps needs a limit of at least N + 1
    assert r["error"].startswith("GraphRecursionError") and r["graph_steps"] == 3
    r = three_steps(2)
    assert r["error"].startswith("GraphRecursionError") and r["path"] == ["agent", "tools"]
test-output.txtRecorded output · text
test_graph.py::test_a_reply_without_tool_calls_ends_the_run PASSED       [ 10%]
test_graph.py::test_tool_results_go_back_to_the_agent PASSED             [ 20%]
test_graph.py::test_parallel_tool_calls_run_in_one_tools_step PASSED     [ 30%]
test_graph.py::test_unknown_pull_request_becomes_an_error_tool_message PASSED [ 40%]
test_graph.py::test_default_tool_errors_stop_the_run PASSED              [ 50%]
test_graph.py::test_invalid_arguments_become_an_error_tool_message_by_default PASSED [ 60%]
test_graph.py::test_endless_tool_calls_hit_the_recursion_limit PASSED    [ 70%]
test_graph.py::test_recorded_tool_results_match_the_queue PASSED         [ 80%]
test_graph.py::test_the_record_counts_steps_tool_calls_and_messages_separately PASSED [ 90%]
test_graph.py::test_the_step_limit_counts_graph_steps_not_messages PASSED [100%]Running teardown with pytest sessionfinish...
============================== 10 passed in 0.32s ==============================

Check your understanding

You build the graph with ToolNode(TOOLS) and no handle_tool_errors, and the model calls get_ci_status with a pull request number that is not in the queue. Decide what the caller of invoke receives before you answer.

With ToolNode(TOOLS) and no handle_tool_errors, a tool raises UnknownPullRequest. What does the caller of invoke receive?

Choose one answer, then check your reasoning.

Read the explanation

ToolNode separates argument errors, which it reports to the model, from errors raised inside a tool, which it re-raises unless you name them in handle_tool_errors.

What’s next

Add branches and stop conditions to a LangGraph agent adds a custom router and stop reasons to this loop.

References

  • LangGraph: Workflows and agents: the agent loop with bind_tools, a tool node and a conditional edge, and ToolNode. Checked October 21, 2026.
  • LangGraph: Graph API: StateGraph, MessagesState and add_messages, conditional edges, recursion_limit and GraphRecursionError. Checked October 21, 2026.
  • LangChain: Tools: the @tool decorator and tool schemas. Checked October 21, 2026.
  • LangChain: ChatGoogleGenerativeAI: the Gemini chat model integration, GOOGLE_API_KEY and tool calling. Checked October 21, 2026.
  • LangChain: ChatOllama: the local Ollama alternative. Checked October 21, 2026.
  • Gemini API: Models: the gemini-3.8-flash model ID. Checked October 21, 2026.
  • tools_condition and the default handle_tool_errors behavior are taken from the installed langgraph-prebuilt 1.1.1 source, which the documentation pages above do not describe in detail.

Written by Arjun Prabhulal.

Module 1

Agent loops and limits