Python & AI Tutorials Logo
LangChain & LangGraph

14. Building the Agent Loop

In Chapter 13 we learned how to execute the LLM's tool-call requests and return the results. However, that work assumed every task could be handled in a single tool call. In practice, tool calls often need to continue over multiple rounds until the LLM has gathered all the information it needs for a final answer.

Consider the request "find the population of France, then multiply it by two." This task requires at least two tool calls. You have to look up the population first — only then can you run the calculation. The second call depends on the first result, so there is no way to handle it in a single tool call.

In this chapter, we turn the single cycle from Chapter 13 into a loop. As long as the LLM requests tool calls, we keep executing them — repeating until the LLM stops requesting tools on its own. Then we add safety limits to prevent the loop from running forever, and cover error handling so the agent doesn't crash when a tool fails.

14.1) From Single Cycle to Loop

14.1.1) How Does the Agent Loop Work?

As we saw in the introduction, tasks where the next action depends on the result of the previous step often cannot be handled with a single tool call. The LLM needs to call a tool, check the result, and decide again. This is what the agent loop does, and it works in three stages:

  • Think — The LLM reads the conversation so far and decides what to do next. If it needs a tool, it requests a tool call through tool_calls. If not, it returns a final answer.
  • Act — Execute the tool specified in tool_calls.
  • Observe — Check the tool's execution result and add it to the conversation in a ToolMessage.

The agent loop repeats these three stages until the LLM no longer requests any tools. This pattern is also known as ReAct (Reason + Act), and the key idea is alternating between reasoning and action.

Yes

No

User Input

THINK
Call LLM

tool_calls
present?

ACT
Execute tool

OBSERVE
Check result

Final Answer

If tool_calls is present, execute the tool, add the result to the conversation, and call the LLM again. If tool_calls is empty, the LLM has returned a final answer and the loop terminates. Now let's put this into code.

14.1.2) Implementing the Think-Act-Observe Loop

Let's turn the Think-Act-Observe loop from the previous section into code. First, we prepare the tools and model.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain.tools import tool
 
# Define tools
@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
    return fake_data.get(city, f"No weather data for {city}.")
 
@tool
def calculate(expression: str) -> str:
    """Evaluate a simple arithmetic expression, e.g. '3 * 21'."""
    return str(eval(expression))  # Warning: eval() is a security risk. Do not use in production.
 
# Bind tools
tools = [get_weather, calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}

In Chapter 13, we executed a tool once and stopped. Now we repeat until the LLM stops requesting tool calls. Inside a while True loop, we call the LLM, and if the response contains tool_calls, we execute the tools and call the LLM again. If there are no tool_calls, the LLM has returned a final answer, so we exit the loop.

python
def run_agent(user_input: str) -> str:
    """Run the Think-Act-Observe loop until the LLM returns a final answer."""
    messages = [
        SystemMessage(content="You are a helpful assistant."),
        HumanMessage(content=user_input),
    ]
 
    while True:
        # THINK: Ask the LLM to decide the next action
        print("THINK: Asking LLM to decide")
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        # Exit condition: no tool_calls means this is the final answer
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE: Execute requested tools and add results to conversation
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            print(f"ACT: calling '{tool_call['name']}', args={tool_call['args']}")
 
            tool_message = selected_tool.invoke(tool_call)
            print(f"OBSERVE: {tool_message.content}")
 
            messages.append(tool_message)

Let's compare this with the Chapter 13 code. In Chapter 13, after executing the tools, we called the LLM one final time to get the answer. In Chapter 14, we put that same process inside a while True and check tool_calls on every iteration to decide whether to continue. The building blocks are the same as Chapter 13 — we've just wrapped them in a loop.

Let's run it.

python
answer = run_agent("What's the weather in Tokyo, and is it warm enough for a walk?")
print(f'Final answer: {answer}')

Output:

THINK: Asking LLM to decide
ACT: calling 'get_weather', args={'city': 'Tokyo'}
OBSERVE: 18°C, cloudy
THINK: Asking LLM to decide
Final answer: Right now in Tokyo it's 18°C (about 64°F) and cloudy.
That temperature is generally mild and comfortable for a walk for most people.

The output shows the THINK → ACT → OBSERVE → THINK flow. In the first iteration the LLM requested a get_weather call, and in the second iteration it saw the weather result and generated a final answer. Since tool_calls was empty, the final answer was returned and the loop terminated.

Now let's test the dependent-step scenario from the introduction — where the next call can only happen after seeing the result of the previous one.

python
answer = run_agent("Get the temperature in Cairo, then multiply the number by 3.")
print(f'Final answer: {answer}')

Output:

THINK: Asking LLM to decide
ACT: calling 'get_weather', args={'city': 'Cairo'}
OBSERVE: 31°C, sunny
THINK: Asking LLM to decide
ACT: calling 'calculate', args={'expression': '31 * 3'}
OBSERVE: 93
THINK: Asking LLM to decide
Final answer: Current temperature in Cairo: 31°C. Multiplied by 3 = 93.

This time the loop ran three iterations.

  1. First iteration — The LLM requests get_weather("Cairo").
  2. Second iteration — After seeing the "31°C, sunny" result, the LLM requests calculate("31 * 3"). It could only form the expression after seeing the temperature.
  3. Third iteration — After seeing both the "31°C, sunny" and "93" results, the LLM returned the final answer.

14.2) Adding Safety Limits

The loop we built above has only one exit condition: when the LLM responds with no tool_calls, we break out of the loop. Under normal circumstances this is sufficient, but what happens if the LLM never stops requesting tool calls?

For example, if a tool always returns ambiguous results, the LLM may keep calling it hoping for something better. Since the loop is while True, if the LLM doesn't stop, the program doesn't stop either. API call costs keep accumulating as the program runs indefinitely.

The simplest fix is to put a ceiling on how many times the loop can run. Replace while True with for step in range(max_steps), and the loop is guaranteed to terminate after max_steps iterations regardless of what the LLM does.

python
def run_agent(user_input: str, max_steps: int = 10) -> str:
    """Run the Think-Act-Observe loop within max_steps iterations."""
    messages = [
        SystemMessage(content="You are a helpful assistant."),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        # THINK
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        # Exit condition: no tool_calls means this is the final answer
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            tool_message = selected_tool.invoke(tool_call)
            messages.append(tool_message)
 
    # max_steps reached: loop ended without a final answer
    return f"[Stopped after reaching max iterations ({max_steps})]"

Compared to the previous code, two things changed. while True became for step in range(max_steps), and a return value was added for when the loop reaches max_steps. The loop now terminates in one of two ways: the LLM returns a final answer on its own (natural termination), or max_steps is reached (safety termination).

Let's verify that the safety limit actually works. We'll create a tool that never returns useful results, forcing the LLM into a situation where it never stops requesting tool calls.

python
@tool
def unhelpful_search(query: str) -> str:
    """Search for information."""
    return "No results found. Try rephrasing your query."
 
llm_with_bad_tool = llm.bind_tools([unhelpful_search])
tool_map_bad = {unhelpful_search.name: unhelpful_search}
 
def run_agent_bad(user_input: str, max_steps: int = 5) -> str:
    messages = [
        SystemMessage(content=(
            "You must ALWAYS use the unhelpful_search tool to find information. "
            "You are NOT allowed to answer from your own knowledge. "
            "If the tool returns no results, you MUST rephrase and search again. "
            "Keep searching until you find the answer."
        )),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        print(f"--- Step {step + 1} ---")
        ai_message = llm_with_bad_tool.invoke(messages)
        messages.append(ai_message)
 
        if not ai_message.tool_calls:
            return ai_message.content
 
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map_bad[tool_call["name"]]
            tool_message = selected_tool.invoke(tool_call)
            print(f"ACT: '{tool_call['name']}' → {tool_message.content}")
            messages.append(tool_message)
 
    return f"[Stopped after reaching max iterations ({max_steps})]"
 
answer = run_agent_bad("What is the population of France?")
print(f"\nFinal answer: {answer}")

Output:

--- Step 1 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 2 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 3 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 4 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 5 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
 
Final answer: [Stopped after reaching max iterations (5)]

Without max_steps, this loop would have run forever. Thanks to max_steps=5, it was forcibly terminated after five iterations.

The right value for max_steps depends on your agent's complexity. Too low, and complex tasks get cut short. Too high, and a misbehaving agent racks up costs before being stopped. 15–25 is a common starting point; adjust based on your actual workloads.

14.3) Handling Tool Errors in the Loop

The loop we built in 14.1 and 14.2 assumes tools always execute successfully. But what happens if a tool raises an exception? The current code has no exception handling, so if an exception occurs during tool execution, the entire agent crashes.

In Chapter 12 we learned how to catch exceptions inside the tool itself with try/except and return error messages as strings. If a tool is built that way, there's no problem. But not every tool handles errors internally. Tools that call external libraries or APIs can raise unexpected exceptions.

To guard against this, it's a good idea to handle errors at the loop level as well. The approach is straightforward: wrap the tool execution in a try/except, and if an exception occurs, put the error message in a ToolMessage and pass it to the LLM. The LLM can read this error message and retry with corrected arguments or choose a different approach. This is called self-correction.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
 
# Define tools
@tool
def calculate(expression: str) -> str:
    """Evaluate a simple arithmetic expression, e.g. '3 * 21'."""
    return str(eval(expression))  # Warning: eval() is a security risk. Do not use in production.
 
# Bind tools
tools = [calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
 
def run_agent(user_input: str, max_steps: int = 10) -> str:
    """Agent loop that passes tool errors to the LLM, enabling self-correction."""
    messages = [
        SystemMessage(content="You are a helpful assistant."),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        # THINK
        print("THINK: Asking LLM to decide")
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            try:
                print(f"ACT: calling '{tool_call['name']}', args={tool_call['args']}")
                tool_message = selected_tool.invoke(tool_call)
                print(f"OBSERVE: {tool_message.content}")
            except Exception as e:
                print(f"OBSERVE: Error - {e}")
                tool_message = ToolMessage(
                    content=f"Error: {e}",
                    tool_call_id=tool_call["id"],
                )
            messages.append(tool_message)
 
    return f"[Stopped after reaching max iterations ({max_steps})]"

Compared to the 14.2 code, the only change is the try/except. If a tool raises an exception, the error message is placed in a ToolMessage and added to the conversation. Note that even a failed call must have a ToolMessage with the matching tool_call_id. The LLM will see this error in the next iteration and decide what to do next.

Let's verify that self-correction works. We'll trigger an exception by asking the calculate tool to divide by zero.

python
answer = run_agent("Use the calculator tool to compute 10 / 0")
print(f"Final answer: {answer}")

Output:

THINK: Asking LLM to decide
ACT: calling 'calculate', args={'expression': '10 / 0'}
OBSERVE: Error - division by zero
THINK: Asking LLM to decide
Final answer: I used the calculator tool and it returned an error: "division by zero."
 
Explanation: 10 / 0 is undefined in ordinary arithmetic, so it cannot produce a finite number.
 
Would you like me to:
- compute the one-sided limits,
- show the IEEE-754 floating-point result,
- or evaluate a different expression?

In the first iteration, the LLM requested calculate("10 / 0") and a ZeroDivisionError was raised. The try/except caught the exception and passed the error message to the LLM. In the second iteration, the LLM saw the error message and returned a final answer explaining that division by zero is impossible. Without the try/except, the program would have crashed at the first ZeroDivisionError.