Tools & ReAct Loop: Wiring an Agent That Acts
This chapter adds tools, so the model can do more than talk back: it can read your files, search your repo, and use the result to decide what to say next.
- tool
- A normal Python function the model is allowed to call. The docstring is its manual.
- tool call
- The model's request to run one: a name plus arguments, carried on an AIMessage.
- ToolMessage
- The result we send back, tied to its call by tool_call_id.
- ReAct loop
- Act, observe, decide: chat asks for a tool, tools runs it, back to chat, until the model answers.
1. What is a tool?#
A tool is just a normal Python function the model is allowed to call.
Without tools, the LLM can only produce text. That's all it has. With tools, it can ask things like "list this directory for me" or "read this file", get the answer back, and then decide what to do next.
Here is the whole pattern as a live run. The graph on top shows the loop we build in this chapter, and the context window below it is the message list the model sees. Step through it:
Nothing has run yet. The thread holds the system prompt and this request is still in the composer. Hit send (or press Enter) to run it.
Send the message to start the run, or click the bar and use ← → to step through.
Each loop turn: the model acts (calls a tool), observes (reads the result), then decides (call another tool, or finish). This pattern has a name: ReAct (Yao et al., 2022), short for Reason + Act.
Two pieces have to fit together:
- Our side: write the function, give it a clear name and description.
- Model side: see the list of available tools, pick one, send the right arguments.
The rest of this chapter is about making those two pieces fit.
2. Writing a tool with @tool#
Here's the smallest tool we ship: list_dir. It returns the names of the files and folders in a directory.
# src/agent/tools/list_dir.py
from pathlib import Path
from langchain_core.tools import tool
@tool
def list_dir(path: str = ".") -> str:
"""List files and subdirectories in the given directory.
Args:
path: Directory path to list. Defaults to the current directory.
Returns:
Newline-separated entries; directories are suffixed with '/'.
"""
target = Path(path).expanduser()
if not target.exists():
return f"Error: '{path}' does not exist."
if not target.is_dir():
return f"Error: '{path}' is not a directory."
entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
return "\n".join(f"{e.name}/" if e.is_dir() else e.name for e in entries)Every part of this code talks to the model in some way. Let's go through them:
@toolturns the function into a tool object the model can be told about. Without it, this is just a regular Python function the model has no idea exists.- The function name (
list_dir) is what the model uses to call this tool. If the model decides "I need to list a directory", it sends back{"name": "list_dir", ...}. - The type hint (
path: str = ".") tells the model what arguments to send and which are optional. Here,pathis a string and defaults to"."if the model leaves it out. - The docstring is the description the model reads to decide when this tool should be used. More on this below; it's the most important part.
- The return value is the observation the model receives on its next turn. Whatever string we return is what the model reads.
A few rules we follow for return values:
- Always return a string. Tool results travel as text, so even errors are strings.
- Never
raise. An exception breaks the whole graph. Returning"Error: ..."lets the model read the problem and try again with different arguments.
3. Why the docstring matters#
The docstring is your tool's instruction manual, written for the model, not for human readers. It's the only thing telling the model when to reach for this tool.
Compare:
# Weak: describes WHAT the function does
"""Lists files in a directory."""
# Strong: describes WHEN to use it
"""List files and subdirectories in the given directory.
Use this when you need to discover what exists in a folder
before reading specific files.
"""The first one is technically correct but doesn't tell the model when to pick it over another tool. The second one gives the model a clear trigger.
A simple checklist for tool docstrings:
- First line: plain description of what the tool does.
- When to use it (and when not to). One short sentence is enough.
Args:block explaining each parameter: defaults, units, formats.Returns:what the result looks like, including the shape (one line, JSON, list, etc.).
Strong docstrings are the cheapest way to make an agent more reliable. Most "the model called the wrong tool" failures are docstring problems, not model problems.
4. What the model actually sees#
We have a tool. How does it get to the model?
Each request to OpenAI carries our tool list as JSON Schema. bind_tools (next section) generates this from the function's type hints and docstring:
// 1. We send the tool list with our request
{
"messages": [...],
"tools": [
{
"type": "function",
"function": {
"name": "list_dir", // ← function name
"description": "List files and subdirectories...", // ← docstring
"parameters": { // ← type hints
"type": "object",
"properties": {"path": {"type": "string", "default": "."}}
}
}
}
]
}Notice how the JSON mirrors the Python function: the docstring became description, the type hints became parameters. That's why the docstring is so important: it is what the model reads when picking a tool.
The model replies with a tool call:
// 2. The model picks list_dir and gives us the arguments
{
"role": "assistant",
"tool_calls": [
{"id": "call_abc", "function": {"name": "list_dir", "arguments": "{\"path\": \"src\"}"}}
]
}We run the function and send the result back:
// 3. We send the observation, tagged with the same id
{"role": "tool", "tool_call_id": "call_abc", "content": "agent/\nagent.egg-info/"}The tool_call_id ties each result to the call it answers, which matters when the model asks for several tools at once.
Everything else in this chapter is wiring around this three-step exchange.
5. bind_tools: telling the model about our tools#
TOOLS = [list_dir, read_file]
LLM = get_llm().bind_tools(TOOLS)bind_tools does the small amount of work needed to plug our tools into the model:
- Generates the JSON Schema from each tool (the request payload above).
- Adds it to every model call.
- Parses the model's
tool_callsback out of the reply intoAIMessage.tool_calls, a clean Python list we can loop over.
We let LangChain do this part because it's the same code every time. Nothing about agent design is hidden in here.
6. tool_node: running what the model asked for#
When the model returns tool calls, somebody has to actually run them. That's the tool_node:
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
def tool_node(state: State) -> dict:
last = state["messages"][-1] # the AIMessage with tool_calls
outputs = []
for tc in last.tool_calls:
tool = TOOLS_BY_NAME[tc["name"]] # find the tool
result = tool.invoke(tc["args"]) # run it
outputs.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
return {"messages": outputs}For each tool call on the last message:
- Find the tool by name.
- Run it with the model's arguments. (
@toolvalidates the arguments with Pydantic before running the function, so wrong types fail fast.) - Wrap the result in a
ToolMessagewith the matchingtool_call_id.
Returning {"messages": outputs} adds the new tool messages to state, and on the next turn, chat_node will pick them up as fresh observations.
LangGraph ships
langgraph.prebuilt.ToolNodethat does the same thing plus parallel calls and exception-to-error-string conversion. We'll switch to it once we need those features.
7. The conditional edge: when to loop#
After chat_node runs, we need to ask: did the model want a tool, or is it done?
def route_after_chat(state: State) -> str:
last = state["messages"][-1]
if last.tool_calls:
return "tools" # model wants a tool: loop through tool_node
return END # model gave a final answer: stopThe string we return is the name of the next node (or the special END). LangGraph reads it through add_conditional_edges.
This three-line function is what makes this an agent loop instead of a single-shot call.
8. The full graph#
# src/agent/graph.py
def build_graph():
builder = StateGraph(State)
builder.add_node("chat", chat_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "chat")
builder.add_conditional_edges("chat", route_after_chat)
builder.add_edge("tools", "chat")
return builder.compile()Two nodes, one conditional edge, one straight edge back. That's the whole loop. Click a node to see its role, contract, and code:
The branch after chat is the only place a decision is made. Everything else is straight line.
9. Up next#
The tool surface is read-only for now: list_dir and read_file. The MVP needs write tools (write_file, edit_file) and search tools (glob, grep); those land in this same chapter as we add them to the codebase.
Once write tools exist, Chapter 3 makes them safe with LangGraph's interrupt(): destructive operations pause the graph and wait for the human to confirm before continuing.