TechByteByByte

Tool Calling: The Complete Request-to-Answer Cycle

Finally complete the full cycle you've only seen in pieces — a model requesting a tool, your code executing it, and the model using that result to finish its answer, entirely by hand.

#LangChain#Tool Calling#Agents

Module 12 ended right at the interesting part. You bound tools to a model, watched it correctly pick the right one for each question, and inspected response.tool_calls — but you never actually ran the tool it asked for, and never got the model’s real, final answer back. This module closes that loop completely, by hand, so you understand every single piece of the mechanism before any agent abstraction ever hides it from you.

The distinction worth being precise about: defining versus invoking

Before writing the full cycle, it’s worth being exact about something Module 12 didn’t fully separate. Defining a tool with @tool and invoking it are two completely different actions, happening at two completely different times, done by two completely different parties:

  • Defining the tool happens once, in your code, before any conversation starts. This is what you did throughout Module 12.
  • The model requesting the tool happens when you call .invoke() on a model with tools bound to it — the model doesn’t run anything itself; it just says, in effect, “please run this specific tool, with these specific arguments.”
  • Your own code actually executing the tool is a separate, distinct step — one you control entirely, and one the model has no power over at all.

This distinction matters because it’s easy to accidentally think a model “runs” a tool. It never does. It only ever asks.

The full cycle, as a diagram

flowchart TD
    A[User asks a question] --> B[Model reads the question and its available tools]
    B --> C{Does answering require a tool?}
    C -->|Yes| D[Model responds with a tool request, not an answer]
    D --> E[Your code actually executes the requested tool]
    E --> F[Result is wrapped in a ToolMessage]
    F --> G[Model is called again, now with the tool's result available]
    G --> H[Model produces the real, final answer]
    C -->|No| H

Let’s build this exactly, one real piece at a time.

Example 1: the complete cycle, for a single tool call

from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a given city."""
    return f"It's sunny and 24°C in {city}."

model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools([get_weather])

messages = [HumanMessage(content="What's the weather like in Lisbon?")]

# Step 1: ask the model — it responds with a tool REQUEST, not an answer
ai_response = model_with_tools.invoke(messages)
messages.append(ai_response)

print("Did the model ask for a tool?", bool(ai_response.tool_calls))
print("Requested tool:", ai_response.tool_calls[0]["name"])
print("Requested arguments:", ai_response.tool_calls[0]["args"])

# Step 2: WE actually run the tool — the model never touches this step
tool_call = ai_response.tool_calls[0]
result = get_weather.invoke(tool_call["args"])

# Step 3: package the result as a ToolMessage, matched to the original request
tool_message = ToolMessage(content=result, tool_call_id=tool_call["id"])
messages.append(tool_message)

# Step 4: call the model AGAIN, now with the real tool result available
final_response = model_with_tools.invoke(messages)
print("\nFinal answer:", final_response.content)

Run this and read through the printed output carefully — you’re watching every single stage from the diagram actually happen, in order. Notice, in particular, tool_call["id"] being passed straight into ToolMessage(tool_call_id=...): this is the exact matching mechanism you first met conceptually back in Module 6, now doing real, necessary work — without it, the model would have no reliable way to connect this specific result back to its specific request.

Example 2: when the model asks for more than one tool at once

A model isn’t limited to requesting a single tool per turn. Given a question needing two independent pieces of information, it can reasonably request both at once.

from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a given city."""
    return f"It's sunny and 24°C in {city}."

@tool
def get_population(city: str) -> str:
    """Get the approximate population of a given city."""
    return f"{city} has an approximate population of 500,000."

model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools([get_weather, get_population])

tools_by_name = {"get_weather": get_weather, "get_population": get_population}

messages = [HumanMessage(content="What's the weather and population of Lisbon?")]

ai_response = model_with_tools.invoke(messages)
messages.append(ai_response)

print(f"Model requested {len(ai_response.tool_calls)} tool call(s).")

# handle EVERY requested tool call, not just the first one
for tool_call in ai_response.tool_calls:
    selected_tool = tools_by_name[tool_call["name"]]
    result = selected_tool.invoke(tool_call["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))

final_response = model_with_tools.invoke(messages)
print("\nFinal answer:", final_response.content)

Notice the loop over ai_response.tool_calls — this is genuinely necessary, not a defensive habit. If the model requested two tools and you only handle the first one, the conversation you send back on the final call is missing a required ToolMessage, and most providers will raise a clear error rather than silently ignoring the gap. Every tool call the model makes needs a matching ToolMessage in response, every time.

Example 3: handling the case where no tool is needed at all

Real conversations mix questions that need a tool with ones that don’t. Your code needs to handle both gracefully, in the same flow.

from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a given city."""
    return f"It's sunny and 24°C in {city}."

model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools([get_weather])

def ask(question: str) -> str:
    messages = [HumanMessage(content=question)]
    ai_response = model_with_tools.invoke(messages)
    messages.append(ai_response)

    if not ai_response.tool_calls:
        # no tool needed — the model already gave us the real answer
        return ai_response.content

    for tool_call in ai_response.tool_calls:
        result = get_weather.invoke(tool_call["args"])
        messages.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))

    final_response = model_with_tools.invoke(messages)
    return final_response.content

print(ask("What's the weather in Lisbon?"))
print(ask("What's the capital of France?"))

Notice the if not ai_response.tool_calls: check. This is the actual, correct way to detect whether a model wants to use a tool at all — an empty tool_calls list means it decided it could answer directly, without needing any external help, and ai_response.content already holds its real, complete answer in that case.

Example 4: a single, reusable function for the whole cycle

Let’s clean up what we’ve built into one genuinely reusable function, tying Examples 1 through 3 together properly.

from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a given city."""
    return f"It's sunny and 24°C in {city}."

@tool
def get_population(city: str) -> str:
    """Get the approximate population of a given city."""
    return f"{city} has an approximate population of 500,000."

TOOLS = [get_weather, get_population]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}

model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools(TOOLS)

def run_with_tools(question: str) -> str:
    messages = [HumanMessage(content=question)]
    ai_response = model_with_tools.invoke(messages)
    messages.append(ai_response)

    if not ai_response.tool_calls:
        return ai_response.content

    for tool_call in ai_response.tool_calls:
        selected_tool = TOOLS_BY_NAME[tool_call["name"]]
        result = selected_tool.invoke(tool_call["args"])
        messages.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))

    final_response = model_with_tools.invoke(messages)
    return final_response.content

print(run_with_tools("What's the weather in Cairo?"))
print(run_with_tools("What's 12 plus 30?"))

This run_with_tools function is genuinely everything you’ve learned in this module, assembled into one working piece. Every real building block is here: the tool request, the loop over however many tool calls actually came back, the ToolMessage matching, and the final call to get a real answer.

The honest limitation this module hasn’t solved yet

There’s a real gap in everything you’ve built so far, worth naming directly. run_with_tools handles exactly one round of tool calling — ask, maybe use a tool once, answer. But what if answering a question genuinely requires calling a tool, looking at its result, and then realizing a second, different tool call is needed before a real answer is possible? Nothing in this module’s code accounts for that — it calls the model a second time expecting a final answer, and simply returns whatever it gets, even if that second response is itself another tool request.

This is exactly the gap the next module exists to close.

Common mistakes worth avoiding

Only handling the first entry in tool_calls, and ignoring the rest. Recall Example 2 — a model can genuinely request several tools in one turn, and every single one of them needs a matching ToolMessage sent back. Code that only reads ai_response.tool_calls[0] will work fine during testing, right up until a real user’s question triggers two tool requests at once, and then silently, confusingly breaks.

Forgetting to append the model’s own tool-requesting message back into the conversation. Recall Example 1’s messages.append(ai_response), done immediately after the first .invoke() call. Skip this step, and the final call — the one meant to produce the real answer — has no record that a tool was ever requested in the first place, which will produce a genuinely confusing error about a missing or unmatched tool call.

Assuming the model always needs exactly zero or one round of tool calls. This module’s own closing section named this limitation directly — real questions sometimes need a tool call, then another one based on what the first one revealed. Code built assuming a fixed, single round will simply return whatever the model says on its second call, even if that second response is itself another tool request going unhandled.

What you should take away from this module

  • Defining a tool, the model requesting it, and your code executing it are three separate, distinct steps — the model never runs anything itself, it only ever asks.
  • The full cycle is: ask the model → check tool_calls → execute whatever was requested → wrap each result in a matching ToolMessage → call the model again for the real answer.
  • A model can request multiple tools in a single turn — your code needs to loop over every entry in tool_calls, not just handle the first one.
  • An empty tool_calls list means the model already gave you its real, complete answer directly — no tool execution needed.
  • This module’s approach only handles one round of tool calling. A question needing a sequence of tool calls — one leading to the discovery that another is needed — isn’t handled yet.

Where this goes next

The next module builds the genuine fix for that exact gap: the manual tool loop — turning this module’s single-round logic into a real, repeating loop that keeps calling tools and re-checking the model’s response until a genuine final answer is reached, however many rounds that actually takes. This is, quite literally, what an “agent” is.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed