Whetstone.
Create AgentTools, and the loop they drive
Module 1, Lesson 216 min

Tools, and the loop they drive

A tool is a Python function with type hints and a docstring. That sentence is genuinely the whole API, and everything else in this lesson is about what those two decorations are actually for.

The decorator, and the two things that carry weight

A tool, complete
from langchain.tools import tool

@tool
def search_database(query: str, limit: int = 10) -> str:
    """Search the customer database for records matching the query.

    Args:
        query: Search terms to look for
        limit: Maximum number of results to return
    """
    return f"Found {limit} results for '{query}'"

Type hints are mandatory, because they define the input schema the model has to fill in. The docstring becomes the description, which is what the model reads when choosing. Tool names should be snake_case for provider compatibility.

You can override both explicitly, which is worth knowing because it is how you decouple a tool’s public contract from your function naming.

Overriding name and description
@tool("web_search", description="Performs web searches for information.")
def search(query: str) -> str:
    """Search the web for information."""
    return f"Results for: {query}"

For richer inputs, hand it a Pydantic model through args_schema, which buys you per-field descriptions and literal constraints.

Two reserved names

config is reserved for RunnableConfig and runtime is reserved for ToolRuntime. You cannot use either as an ordinary tool argument, and the collision is silent enough to be worth memorising.

ToolRuntime is how a tool reaches beyond its own arguments:

A tool reading conversation state
from langchain.tools import tool, ToolRuntime

@tool
def get_user_message(runtime: ToolRuntime) -> str:
    """Get the most recent message from the user."""
    return runtime.state["messages"][-1].content

It carries state (short-term memory), context (immutable per-run config), store (persistent long-term memory), stream_writer, execution_info, server_info, and tool_call_id.

Four things a tool can return

  • A string. The normal case.
  • An object or dict. Structured output the model reads back.
  • A Command. This is how a tool writes to state rather than merely reporting.
  • Anything, with return_direct=True, which short-circuits the loop: the output goes straight to the caller with no further model turn.
A tool that writes state
from langgraph.types import Command
from langchain.messages import ToolMessage

@tool
def set_user_name(new_name: str, runtime: ToolRuntime) -> Command:
    """Set the user's name in conversation state."""
    return Command(
        update={
            "user_name": new_name,
            "messages": [
                ToolMessage(content=f"User name set to {new_name}.",
                            tool_call_id=runtime.tool_call_id),
            ],
        }
    )

Note the ToolMessage inside the update. Every tool call needs a matching result message or the conversation is malformed, and when you take over the return value you take over that obligation too.

Errors are a middleware concern

There is no handle_tool_errors flag on the tool. Failures are caught with a wrap_tool_call middleware that turns the exception into a ToolMessage the model can read and recover from. That is module 3, and it is the first hint that middleware is not an advanced topic bolted on the end: it is where several things you would expect to be tool options actually live.

Practice

Try it yourself

Quiz

Spot the bug

One of these tools will not behave as written. The defect is not a typo.

Two tools, one defect
@tool
def fetch_invoice(invoice_id: str, runtime: str) -> str:
    """Fetch an invoice by id from the billing system."""
    return billing.get(invoice_id, env=runtime)

@tool
def fetch_order(order_id: str, region: str) -> str:
    """Fetch an order by id from the orders service."""
    return orders.get(order_id, region=region)
  1. Afetch_order, because region has no default and no injection annotation
  2. Bfetch_invoice, because runtime is a reserved parameter name
  3. CBoth, because neither declares an args_schema for its parameters
  4. DNeither; both are valid tool definitions as written
Show answer

Correct answer: B — fetch_invoice, because runtime is a reserved parameter name

runtime is reserved for ToolRuntime injection, exactly as config is reserved for RunnableConfig, so using it as an ordinary argument collides with the framework. The tempting answer is the last one, because the code reads perfectly well and nothing about it looks wrong; the collision is invisible unless you already know the two reserved names.

Recall

What the docstring is doing

This is the piece most engineers underrate, because in every other codebase they have worked in a docstring is documentation for humans.

What role does a tool's docstring play at runtime, and what happens to tool selection if it is vague?

Reveal answer

The docstring becomes the tool's description, which is what the model reads when deciding whether to call it. It is not documentation, it is the selection contract. A vague docstring produces a model that calls the wrong tool or fails to call the right one, and the symptom presents as a reasoning failure rather than as a documentation problem, which is why it is so often misdiagnosed. Type hints matter for the same reason: they define the input schema the model has to fill in.

Quiz

What return_direct changes

A tool is declared with @tool(return_direct=True). What happens after it runs?

  1. AThe agent returns the tool's output to the caller with no further model call
  2. BThe result is sent back through the model, which then summarises it for the user
  3. CThe result is written to state but never returned to the caller
  4. DThe tool runs outside the agent loop entirely, before the first model call
Show answer

Correct answer: A — The agent returns the tool's output to the caller with no further model call

return_direct short-circuits the loop; the tool's output goes straight back to the caller with no further model turn. The second option is exactly what happens without return_direct, which is what makes it the natural wrong answer: it describes the default behaviour that this flag exists to switch off.

Quiz

Making a tool write to state

A tool needs to update a field in the agent's state, not just return a string. What does it return?

  1. AA plain dict, which the runtime merges into state automatically
  2. BA ToolMessage carrying the new value in its content for the runtime to apply
  3. CA Command with an update mapping, including a ToolMessage for the tool call id
  4. DNothing; only middleware can write state, tools only return strings
Show answer

Correct answer: C — A Command with an update mapping, including a ToolMessage for the tool call id

A tool returns Command(update={...}) to write state, and the update must include a ToolMessage carrying the runtime's tool_call_id, because every tool call needs a matching result message or the conversation is malformed. The first option is tempting because returning a dict is legal, but a returned dict is structured tool output, not a state update.

Check

Write the same tool twice

In a scratch file, write one tool with a lazy one-line docstring and the same tool with a docstring that names its arguments, its failure mode, and when not to call it.

You should see

The second version reads like a specification a stranger could act on, and you can point at the sentence that would stop the model calling it in the wrong situation.

Sign in to track your progress →