TechByteByByte

Building the Tool Loop by Hand

Fix the exact gap the last module left open — build a genuine, repeating loop that keeps calling tools until a real answer is reached, however many rounds that takes. This is what an agent actually is.

#LangChain#Agents#Tool Calling

Module 13 ended by naming its own limitation directly: run_with_tools only handles one round of tool calling. Ask, maybe call a tool once, answer — done. But real questions don’t always fit that shape. This module builds the genuine fix, and by the end of it, you’ll have built something worth pausing on: a real, working agent, entirely from first principles, with nothing hidden from you.

A question that actually needs two rounds

Let’s design a real scenario where one round genuinely isn’t enough. Imagine a question that needs one tool’s result before it even knows what to ask a second tool.

from langchain.tools import tool

@tool
def get_capital(country: str) -> str:
    """Get the capital city of a given country."""
    capitals = {"Japan": "Tokyo", "France": "Paris", "Egypt": "Cairo"}
    return capitals.get(country, f"Unknown country: {country}")

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

Now think through “What’s the weather in the capital of Japan?” carefully. The model can’t call get_weather yet — it doesn’t know which city to ask about. It first has to call get_capital(country="Japan"), get back "Tokyo", and only then does it have enough information to call get_weather(city="Tokyo"). That’s genuinely two separate rounds, with the second one depending entirely on the first one’s result. Let’s watch Module 13’s single-round approach actually fail on this.

Example 1: watching the single-round approach genuinely fall short

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

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

messages = [HumanMessage(content="What's the weather in the capital of Japan?")]

ai_response = model_with_tools.invoke(messages)
messages.append(ai_response)
print("First response tool calls:", ai_response.tool_calls)

tool_call = ai_response.tool_calls[0]
result = get_capital.invoke(tool_call["args"])
messages.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))

final_response = model_with_tools.invoke(messages)
print("\n'Final' response:", final_response.content)
print("Does it actually contain weather info?", "cloudy" in final_response.content.lower())

Run this, and look closely at that last printed line. Instead of a real weather answer, the model most likely responds by requesting get_weather — because now that it knows the capital is Tokyo, it genuinely needs a second tool call to finish the job. But Module 13’s code treats any second call as the final answer, whether or not it actually is one. The real bug here isn’t in the tools — it’s that our code only ever gave the model two chances to speak, when the task genuinely needed three.

Example 2: the actual fix — a real, repeating loop

The fix is conceptually simple once you see it: instead of calling the model a fixed, hardcoded number of times, keep calling it — and keep handling whatever tool calls come back — for as long as it keeps asking for tools, and stop only once it stops asking.

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

TOOLS = [get_capital, get_weather]
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_agent(question: str) -> str:
    messages = [HumanMessage(content=question)]

    while True:
        ai_response = model_with_tools.invoke(messages)
        messages.append(ai_response)

        if not ai_response.tool_calls:
            # no more tools requested — this is genuinely the final answer
            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"]))
        # loop back around — the model gets another turn, with the new results available

print(run_agent("What's the weather in the capital of Japan?"))

Run this, and you’ll see it correctly work through both rounds — capital lookup, then weather lookup — before finally producing a real, complete answer. The only real structural change from Module 13 is replacing a fixed sequence of steps with while True:, and moving the if not ai_response.tool_calls: check to be the loop’s actual exit condition, rather than something checked only once. This is, genuinely and completely, what the word “agent” means in practice: a model given tools and allowed to keep using them, in a loop, until it decides it’s done.

Example 3: a real, necessary safety net — capping the number of rounds

Here’s a real, honest risk worth taking seriously: what if the model never stops requesting tools? A confused model, a genuinely ambiguous question, or a subtle bug in a tool’s output could all cause the loop to keep going far longer than intended — in the worst case, indefinitely. This isn’t a hypothetical concern; it’s a documented, real failure mode of early autonomous agent systems, and it’s a genuinely important thing to guard against from the start, not add later as an afterthought.

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

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

def run_agent(question: str, max_iterations: int = 5) -> str:
    messages = [HumanMessage(content=question)]

    for _ in range(max_iterations):
        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"]))

    return "I wasn't able to fully answer this within the allowed number of steps."

print(run_agent("What's the weather in the capital of Japan?"))

Notice while True: became for _ in range(max_iterations): — a genuinely small code change, but a real, meaningful safety guarantee: this loop can now never run forever, no matter what happens inside it. If the model genuinely can’t finish within max_iterations rounds, the function returns a clear, honest message instead of hanging indefinitely. This single, small addition is exactly the kind of thing that separates a toy demo from something you’d actually trust running unattended.

Example 4: naming what you just built

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

@tool
def get_capital(country: str) -> str:
    """Get the capital city of a given country."""
    capitals = {"Japan": "Tokyo", "France": "Paris", "Egypt": "Cairo"}
    return capitals.get(country, f"Unknown country: {country}")

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

TOOLS = [get_capital, get_weather]
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_agent(question: str, max_iterations: int = 5) -> str:
    """A minimal, working agent: a model, a set of tools, and a loop."""
    messages = [HumanMessage(content=question)]

    for _ in range(max_iterations):
        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"]))

    return "I wasn't able to fully answer this within the allowed number of steps."

print(run_agent("What's the weather in the capital of France?"))
print(run_agent("What's the weather in the capital of Egypt?"))

Read the docstring on run_agent again: “a model, a set of tools, and a loop.” That is, genuinely, a complete and accurate definition of an agent — not a metaphor, not a simplification for teaching purposes. Every agent you’ll build for the rest of this course, including the ones built with LangChain’s own create_agent in the very next module, is doing exactly this same thing underneath: a model deciding what to do, tools actually doing it, and a loop that keeps this going until the model decides it’s genuinely finished.

Common mistakes worth avoiding

Using while True: in real code, without a genuine safety limit. Recall Example 3’s real justification — a model that never stops requesting tools will loop indefinitely, consuming real API calls and real money the entire time. Always cap the number of rounds in anything beyond a quick, supervised experiment.

Forgetting that each loop iteration needs the model to see the growing message list, not a fresh one. The loop only works because messages keeps accumulating across iterations — every tool result from an earlier round stays visible to the model in every later round. Accidentally resetting or truncating messages inside the loop would make the model “forget” what it already discovered, and it might repeat a tool call it already made.

Assuming five iterations is always the right cap. max_iterations=5 was a reasonable, arbitrary choice for this module’s simple examples. A genuinely complex, multi-step research task might legitimately need more rounds, while a simple customer-support bot might want a much lower cap, both to control cost and to fail fast if something’s clearly going wrong. Choose this number deliberately, based on what your specific application actually needs.

What you should take away from this module

  • The fix for Module 13’s single-round limitation is a genuine, repeating loop — keep calling the model and handling tool calls for as long as it keeps requesting them.
  • if not ai_response.tool_calls: is the loop’s real exit condition, checked fresh on every single iteration, not just once.
  • A hard cap on iterations — max_iterations — is a genuine, necessary safety measure, not an optional extra, since nothing else in this loop guarantees it will ever stop on its own.
  • A model, a set of tools, and a loop is, genuinely and completely, what an agent is. You just built one, entirely from first principles, with nothing hidden.

Where this goes next

The next module introduces create_agent — LangChain’s own, official, current way to build exactly what you just built by hand. You’ll see the very same loop you just wrote, now provided for you, with real, production-grade handling of edge cases this simplified version doesn’t yet cover.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed