Skip to main content

02 - Fundamentals: OODA Loop, ReAct and Tool Calling

OODA loop, ReAct and tool calling explained with executed code: how the model proposes an action, how you validate it and how to stop an agent looping forever.

What do you think about this article?

02 - Fundamentals: OODA Loop, ReAct and Tool Calling
AI AgentsPhoto:Pavel DanilyukPexels

At a Glance

OODA loop, ReAct and tool calling explained with executed code: how the model proposes an action, how you validate it and how to stop an agent looping forever.

The problem: knowing what the model does at every turn

In the first article we saw that an agent is a loop in which a model chooses the next step. Here we open that loop with three reading tools that you need to build it and to understand why it sometimes goes wrong: the OODA loop, which describes the structure of a repeated decision; the ReAct pattern, which says in what form the model alternates reasoning and action; and tool calling, which is the technical format in which the action reaches your code. They are three different levels of the same loop, and the listing we build puts them together in fewer than forty lines.

The OODA loop: observe, orient, decide, act

OODA (Observe, Orient, Decide, Act) is a decision model attributed to United States Air Force colonel John Boyd, born to describe aerial combat. His point is that the advantage lies in completing the observation-decision-action cycle faster and with fewer errors than the opponent, not in making the single perfect decision. Applying it to agents is an analogy of ours, useful as a reading grid: Boyd was not talking about language models, and the rest of this section is an interpretation, not a quotation.

For an agent, observing means assembling the context: the user's request, the tool results, what the memory returns, the errors that came back from the last action (an HTTP 429 code, for example, which says to slow down). Orienting is making sense of that data and coincides with the model's reasoning over the context; it is also the phase where the system prompt weighs the most, because it steers what matters. Deciding is choosing between calling a tool, answering, asking for clarification, changing plan or stopping. Acting is executing the choice, and the result becomes the next observation.

OODA phaseWhat the agent doesWhere it sits in the code
ObserveGathers request, observations, memoryBuilding the context to pass to the model
OrientInterprets the contextInside the call to the model (reasoning)
DecideChooses action or answerThe parsing of the model's output
ActRuns the toolThe dispatch to the function, with error handling

The table says something useful: two of the four phases (Orient and Decide) sit inside the model, and therefore you cannot control them line by line; the other two (Observe and Act) are your code, and that is where you decide how much context to give and which actions to allow. Boyd's cycle time becomes, for an agent, the number of turns and the duration of each: small, fast tools allow more corrective turns than a single slow tool that aggregates ten sources before answering.

ReAct: thought and action interleaved

ReAct is described in the article by Yao and coauthors, published on arXiv in October 2022 (ReAct: Synergizing Reasoning and Acting in Language Models). The starting point is that step-by-step reasoning (chain-of-thought) and action generation had been studied separately. ReAct has the model produce reasoning traces and actions in an interleaved manner: reasoning helps build, follow and update the plan and handle exceptions, while actions make it possible to query external sources. On the interactive environments ALFWorld and WebShop, according to the abstract, ReAct outperforms imitation and reinforcement learning methods by an absolute success rate of 34% and 10% respectively, with one or two examples in the prompt; on HotpotQA and Fever, by querying a simple Wikipedia API, it reduces the hallucination and error propagation typical of chain-of-thought alone. These are results on those benchmarks: they do not tell you how much ReAct will improve your agent.

The loop has three components that repeat: a Thought in which the model states what it knows and what it lacks, an Action with the tool and the arguments, and an Observation with the result that returns into the context. The listing implements it with a fake model that emits text in the ReAct format: the regular expression FORMAT separates the thought, the action name and the JSON arguments. Three choices deserve attention. The tool's exception does not stop the program but becomes an observation ("Error: ..."), so the model can correct itself. The max_iterations cap prevents the loop from running endlessly. The guard on the last three identical actions stops whoever repeats the same failed attempt.

import json
import re

# Fake model: returns texts in the ReAct format, one per turn.
SCRIPT_OK = [
    'Thought: I need the order status, I will look it up.\nAction: find_order({"id": "A-1042"})',
    'Thought: I have the status, I can answer.\nAction: final_answer({"answer": "Order A-1042: out for delivery."})',
]
SCRIPT_LOOP = ['Thought: I will try again.\nAction: find_order({"id": "A-9999"})'] * 6

ORDERS = {"A-1042": "out for delivery"}

def find_order(id):
    if id not in ORDERS:
        raise ValueError(f"order {id} does not exist")
    return ORDERS[id]

TOOLS = {"find_order": find_order}
FORMAT = re.compile(r"Thought:\s*(.*?)\nAction:\s*(\w+)\((.*)\)\s*$", re.S)

def react_loop(script, max_iterations=5):
    context, recent = [], []
    for i in range(max_iterations):
        text = script[i]                        # here you would call llm.generate(context)
        m = FORMAT.match(text)
        if not m:
            context.append("Observation: invalid format, use Thought/Action")
            continue
        thought, name, args = m.group(1), m.group(2), json.loads(m.group(3))
        if name == "final_answer":
            return args["answer"], i + 1, context
        recent.append((name, json.dumps(args, sort_keys=True)))
        if len(recent) >= 3 and len(set(recent[-3:])) == 1:
            return "Stopped: same action 3 times", i + 1, context
        try:
            observation = TOOLS[name](**args)
        except Exception as e:                  # the error becomes an observation
            observation = f"Error: {e}"
        context += [f"Thought: {thought}", f"Action: {name}({args})",
                    f"Observation: {observation}"]
    return "Could not finish within the iteration limit", max_iterations, context

for label, script in (("ok", SCRIPT_OK), ("loop", SCRIPT_LOOP)):
    answer, turns, context = react_loop(script)
    print(f"[{label}] turns={turns} answer={answer!r}")
    for line in context:
        print("   ", line)

# Output:
# [ok] turns=2 answer='Order A-1042: out for delivery.'
#     Thought: I need the order status, I will look it up.
#     Action: find_order({'id': 'A-1042'})
#     Observation: out for delivery
# [loop] turns=3 answer='Stopped: same action 3 times'
#     Thought: I will try again.
#     Action: find_order({'id': 'A-9999'})
#     Observation: Error: order A-9999 does not exist
#     Thought: I will try again.
#     Action: find_order({'id': 'A-9999'})
#     Observation: Error: order A-9999 does not exist

The program was run twice with Python 3.14.4 (python3 -I), with identical output. In the first scenario the model looks up the order, sees "out for delivery" and closes with final_answer on the second turn. In the second, the script repeats the same call for a nonexistent order: after two error observations the guard fires on the third turn, before running the action, which is why the context contains only two exchanges. If you remove the guard, the loop still ends at the limit of five iterations, but it spends five calls to the model for nothing. The model is fake: the listing proves the structure of the loop, not the ability of a real model to respect the format, and a real model sometimes produces text that FORMAT does not recognize (the continue branch with the warning is there for this).

What ReAct gives and what it costs

  • Readable traces: every Thought, Action and Observation is recorded and can be reread, which makes debugging much easier than with a single opaque answer.
  • Anchoring to facts: observations bring external data into the context, which can reduce hallucinations but does not eliminate them: the model can ignore or misunderstand the observation.
  • Cost: every turn is a call, and the context grows at every turn; the cost grows more than linearly if you resend the whole history each time.
  • The Thought is text: the Thought is generated text, not a window onto the internal computation; treat it as a clue for debugging, not as proof.

The first two items are the reasons the pattern works, the last two are the reasons it is not enough. A prudent rule: record the traces to diagnose, but do not trust a Thought as a guarantee of behavior; the guarantee lies in the checks on arguments and permissions, which we put in the next section.

Tool calling: the format of the action

In the previous listing the action was text to parse with a regular expression, fragile by nature. Tool calling (or function calling) solves the problem by moving the format inside the model's protocol: you declare the tools with a schema, and the model answers with a structured request instead of free text. The Ollama documentation shows the format: in the request a tools list of objects with type: "function" and, inside function, name, description and parameters (a JSON schema); in the response an assistant message with tool_calls, each with name and arguments; the result goes back to the model as a message with role tool and the field tool_name. The model executes nothing: it proposes a call, and you are the one who executes it.

That is why the important work lies in validation. The following listing defines the schema of a search_orders tool and a validate function that checks a proposed call against the schema: required fields, unknown fields, type, enum values, and application of the default value of limit. The default value is applied by your code, not by the model: the schema declares it, but nothing guarantees that the model respects it. In production you would use a JSON schema validation library; here it is written by hand to show what it checks.

import json

# Schema in the format used by Ollama and OpenAI-compatible APIs.
TOOL = {
    "type": "function",
    "function": {
        "name": "search_orders",
        "description": ("Searches orders in the order system. Use it when the user asks "
                        "about an order; not for questions about prices."),
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "filter_type": {"type": "string", "enum": ["name", "email", "order_id"]},
                "limit": {"type": "integer", "default": 10},
            },
            "required": ["query", "filter_type"],
        },
    },
}
TYPES = {"string": str, "integer": int}

def validate(call):
    """Checks the arguments against the schema; returns (arguments, error)."""
    schema = TOOL["function"]["parameters"]
    args = dict(call["function"]["arguments"])
    for field in schema["required"]:
        if field not in args:
            return None, f"missing required field '{field}'"
    for field, value in args.items():
        prop = schema["properties"].get(field)
        if prop is None:
            return None, f"unknown field '{field}'"
        if not isinstance(value, TYPES[prop["type"]]):
            return None, f"'{field}' must be {prop['type']}"
        if "enum" in prop and value not in prop["enum"]:
            return None, f"'{field}' must be one of {prop['enum']}"
    for field, prop in schema["properties"].items():
        if field not in args and "default" in prop:
            args[field] = prop["default"]       # the runtime applies the default
    return args, None

def execute(call):
    args, error = validate(call)
    if error:
        return {"role": "tool", "tool_name": call["function"]["name"], "content": f"Error: {error}"}
    return {"role": "tool", "tool_name": call["function"]["name"],
            "content": json.dumps({"results": [], "total_count": 0, "args": args}, sort_keys=True)}

calls = [
    {"function": {"name": "search_orders", "arguments": {"query": "Mario Rossi", "filter_type": "name"}}},
    {"function": {"name": "search_orders", "arguments": {"query": "Mario Rossi", "filter_type": "names"}}},
    {"function": {"name": "search_orders", "arguments": {"filter_type": "name"}}},
]
for c in calls:
    print(execute(c))

# Output:
# {'role': 'tool', 'tool_name': 'search_orders', 'content': '{"args": {"filter_type": "name", "limit": 10, "query": "Mario Rossi"}, "results": [], "total_count": 0}'}
# {'role': 'tool', 'tool_name': 'search_orders', 'content': "Error: 'filter_type' must be one of ['name', 'email', 'order_id']"}
# {'role': 'tool', 'tool_name': 'search_orders', 'content': "Error: missing required field 'query'"}

Run twice, with identical output. The first call is valid and the runtime completes the arguments with limit 10. The second proposes "names" instead of "name": it is rejected with a message that lists the allowed values, and it is that message, returned as the tool's result, that lets the model correct itself on the next turn. The third has no query field. The error returned must say what to fix: a generic "error" is not enough for the model to retry better. The listing runs no real searches (results is empty): it tests the validation, not the order system.

The tool description matters more than the code

The model does not see your code: it sees name, description and schema. A description like "Searches the database" does not tell it which database, what data it contains, or when to prefer it over other tools. A useful description answers three questions: what the tool does, when to use it (and implicitly when not to), and what format the arguments have, for example "order ID in the format ORD-YYYY-NNNN". In the listing the description also says that it should not be used for questions about prices: stating the boundary reduces wrong calls. How to test it: write ambiguous requests and watch which tool the model chooses, but this can only be done with a real model; here it was not run. Tool design is explored further in Advanced Tool Calling: APIs, Web and Custom Tools.

How to choose the patterns

FeatureChain-of-ThoughtTool calling aloneReAct
Visible reasoningYesNot necessarilyYes (Thought)
External actionsNoYesYes
Up-to-date dataOnly the model's knowledgeYes, via toolsYes, via tools
Turns to the modelOneOne or a fewMany, not known in advance
When it fitsPure reasoning: logic, arithmeticTasks with a single actionTasks where the step depends on the outcome of the previous one

The three patterns are not mutually exclusive: ReAct is, in practice, tool calling inside a loop with explicit reasoning, and modern models often reason internally without you asking for a "Thought". The selection criterion is the same as in the first article: use the simplest one that solves the case. A single action and no dependence on results? One call with a tool. A task where the next step depends on what you have discovered? The loop.

Tree of Thoughts and upfront planning

Tree of Thoughts (Yao and coauthors, May 2023) generalizes chain-of-thought: the model explores several reasoning paths, evaluates itself and backtracks when needed. In the abstract, on the Game of 24 problem, GPT-4 with chain-of-thought alone solves 4% of the tasks and with ToT 74%. It is a result on a task built to require exploration; the price is that many generations are needed for each node, hence many more calls than a single loop.

Upfront planning (plan-first) is another strategy: the agent first writes a plan and then executes it, replanning if a step fails. It is useful when the task is long and you want an overall view; the risk is that the plan rests on assumptions that execution disproves. The sketch below is an example of format, not a listing that was run.

[Plan]
1. Retrieve the sprints closed in the quarter
2. Compute average velocity and trend
3. List the most frequent blockers
4. Write the report

[Execution]
- If step 1 returns no sprints: replan (ask for the correct period)
- If new data emerges: add a step
- If a step is no longer needed: skip it, and say so in the log

The sketch shows the basic difference from ReAct: here the plan is an object that you can show to the user for approval before spending calls, whereas in ReAct the plan stays implicit in the traces. Practical implementations combine the two: an initial plan and the possibility of correcting it at every turn. It is not an example that was run, and it is a schematization: every framework implements it in its own way.

Common design mistakes

  • Endless loops: a cap on turns or a stopping criterion is missing. Remedy: max_iterations and detection of repeated actions, as in the first listing.
  • Vague descriptions: the wrong tool is chosen or the right one is ignored. Remedy: a description with what, when and format.
  • Swallowed errors: the exception brings the program down, or disappears without the model seeing it. Remedy: return the error as an observation.
  • Context explosion: every turn adds tokens. Remedy: summarize or truncate, the topic of Memory Systems.
  • Tools too small or too large: too many turns on one side, no control on the other. Remedy: one tool, one responsibility.
  • No limits on actions: an agent can delete data or spend money. Remedy: a list of allowed actions and human confirmation for irreversible ones, as in Security and Safety.

Half of these mistakes are solved with a few lines of code already seen above (cap, guard on repetitions, error as observation, argument validation), the other half with design (descriptions, granularity, permissions). The first half can be covered with tests; the second is only discovered by watching the agent on real cases, which is why the piece on Testing and Evaluation starts from the traces.

Frequently asked questions

What is the difference between ReAct and tool calling?

ReAct is the way of structuring the loop, alternating reasoning, action and observation; tool calling is the format in which the model expresses the action, as a structured request instead of text to parse. They are used together.

Does the model really execute the functions?

No. The model proposes the name and arguments of a call; your code validates it, executes it and returns the result as a message. This separation is the point where you put checks on arguments and permissions.

How do I stop the agent from looping forever?

Always impose a maximum number of iterations and, in addition, stop when the last actions are identical. The first listing in this article does both and shows that the guard on repetitions fires before the limit.

What you can do now

You can read an agent as an OODA loop and say which phases are in the model and which in your code. You can write a ReAct loop with a cap on turns, a guard on repetitions and errors turned into observations, and you can validate a tool call against its schema before executing it. In the next article, LangChain and LangGraph: Agents with AgentExecutor and Tools, we see how a framework offers this same loop ready-made and what you still have to check yourself. When the roles become more than one, the same loop repeats inside each and the problem becomes coordinating them: it is discussed in Multi-Agent Orchestration: Production Architectures.

Sources

AI Agents Complete Guide

The complete guide to AI Agents: architectures, patterns, implementation and real-world use cases. 10 chapters, 172 pages, practical Python examples.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects