So far we have programmed AI systems in a question-in, answer-out style — the LLM receives a static prompt and immediately produces its final answer. But when a question requires solving a multi-part problem that needs both calculation and live data lookup, a bare LLM is stuck.

The next step in AI applications is the AI agent. Instead of answering immediately, an agent uses the LLM as a planning "brain": it breaks a large task into steps, chooses and fires external programmed tools, reads the results back, adjusts, and repeats until the goal is met. This lesson dissects the ReAct (Reasoning + Acting) framework, how to design tool use, and building an AI agent that solves arithmetic and looks up information by hand, with no heavyweight framework.

✅ What you need before starting
Software: Ollama with a chat model (ollama pull qwen2.5:7b). No embedding model needed — this lesson retrieves no documents.

Knowledge you need: Lesson 12 for the four-step tool call cycle — ReAct is that same cycle repeated, with the model deciding when to stop. Lesson 11 for the role-tagged messages array, and particularly its "flatten the history into one string" pitfall: section 17.4 shows that the first version of this very project made exactly that mistake.

17.1 What is an AI agent, and how does it differ from a bare LLM?

A pure LLM is like an isolated brain: linguistically very capable, but with no limbs to interact with the world around it, and no way to reflect and correct itself mid-reasoning.

An AI agent is a software structure wrapped around the LLM, giving it three core components:

  • Planning: the ability to break a complex goal into a sequence of logical action steps.
  • Memory: storing short-term reasoning history and long-term information to keep context consistent.
  • Tools: Python functions, web APIs or databases that connect the LLM to the real world (a calculator, a search engine, a weather API).
⚠️ Pitfall: reaching for an agent on a static task
Agents carry very high cost and latency. One reasoning loop can consume 3 to 10 consecutive LLM API calls. If you only need to classify an email or summarise a short passage, use a bare LLM or a plain structured-output call instead of building an agent. Only use an agent when the flow is genuinely non-deterministic and each step's result depends directly on what the previous step actually returned.

17.2 The ReAct loop (Reasoning + Acting)

The ReAct framework is a prompting method that drives the LLM through a repeating four-step cycle:

🔄 The ReAct cycle:
  1. 1. Thought: the LLM analyses the current state of the task and answers itself: "What do I need to do next? Do I need to call a tool?"
  2. 2. Action: the LLM emits a tool call in the prescribed format, for example Action: calculate[2 + 2].
  3. 3. Observation: the Python system catches that instruction, runs the local function and returns the raw result for the LLM to read as: Observation: 4.
  4. 4. Repeat: the LLM reads the observation, thinks again, and decides to call another tool or produce the final answer (Final Answer: ...).

ReAct's greatest strength is self-correction. If a tool returns an error (a division by zero, a failed web API), the Observation containing that error goes back into the context. The LLM reads it in the next Thought and changes the argument it passes or switches to a fallback tool.

⚡ Pitfall: infinite loops & max iterations
When the LLM meets a question that is too hard, or a tool keeps returning unexpected errors, the model can get "stuck thinking" and repeatedly call the same tool with the same wrong argument. That burns API budget and hangs the application. The fix: always install a maximum iteration counter (max iterations, typically 5). If the agent has not produced a Final Answer by then, the system must break out and return a warning.
⚠️ Security pitfall: character filtering is not a substitute for removing eval()
The first version of this project used eval() to compute expressions, defended by a single character filter: re.sub(r'[^0-9+\-*/().\s]', '', expression). Against the classic attack string __import__('os').system('ls') the filter works — it reduces the string to '().()', a syntax error. But the filter keeps the * character, and two * in a row is exponentiation. The string 9**9**9 passes through the filter untouched — and eval() will sit there computing a number of roughly 370 million digits, hanging the process indefinitely. This is not hypothetical: the program below prints the post-filter string so you can see it.

The general lesson: a character blacklist always loses to a whitelist. The new version drops eval() entirely, parsing the expression into a syntax tree (ast.parse) and walking only the four permitted operations. Anything not on the allow-list cannot run, including the attacks you never thought of.

17.3 Tool use: describing your tools to the LLM

How does the LLM know what tools exist? We describe them in detail in the system prompt. That description must contain:

  • Tool name: a unique identifier the LLM must reproduce exactly (for example calculate).
  • Description: what the tool does and when it should be used.
  • Argument schema: the input types it expects.

17.4 Lesson 17 project: a ReAct agent written from scratch

The project writes a standalone ReAct agent with no LangChain and no framework at all — precisely so you can see that the loop is really just a for loop around an API call, plus a regular expression that catches tool calls.

The agent has two tools: get_stock_price (fixed data, so reruns produce the same result) and calculate. The test question deliberately needs both, in order: it has to look up the price before it can multiply by the quantity.

react_agent.py
"""Lesson 17 project: a ReAct agent written by hand, with its two sharp edges.

Run:  python3 react_agent.py
Needs: Ollama with a chat model (`ollama pull qwen2.5:7b`).

Two things this file takes seriously that a first ReAct implementation usually
does not: the agent's own output must go back as an `assistant` message rather
than as user text, and the tool that evaluates arithmetic must never be `eval`.
"""

import ast
import json
import operator
import re
import urllib.error
import urllib.request

OLLAMA = "http://localhost:11434"
CHAT_PREFERRED = ["qwen2.5:14b-instruct", "qwen2.5:7b", "llama3.1", "llama3.2",
                  "qwen2.5-coder:7b"]


# ---------------------------------------------------------------------------
# 1. The tools the agent is allowed to call
# ---------------------------------------------------------------------------

# Only these node types are allowed through. Note that ast.Pow is NOT here:
# the model can write 9**9**9, which passes a character filter untouched and
# then hangs the process for hours inside eval(). Blocking it is not paranoia.
SAFE_OPERATORS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.USub: operator.neg,
}


def _evaluate(node):
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in SAFE_OPERATORS:
        return SAFE_OPERATORS[type(node.op)](_evaluate(node.left),
                                             _evaluate(node.right))
    if isinstance(node, ast.UnaryOp) and type(node.op) in SAFE_OPERATORS:
        return SAFE_OPERATORS[type(node.op)](_evaluate(node.operand))
    raise ValueError("unsupported expression")


def calculate(expression):
    """Evaluate arithmetic without eval(): parse, then walk a whitelist."""
    try:
        return str(_evaluate(ast.parse(expression, mode="eval").body))
    except ZeroDivisionError:
        return "Division by zero."
    except (SyntaxError, ValueError, TypeError):
        return f"Cannot evaluate '{expression}' - only + - * / are supported."


def get_stock_price(symbol):
    """Look up a share price. Fixed data, so the run stays reproducible."""
    prices = {"AAPL": "185.50 USD", "GOOGL": "172.30 USD",
              "MSFT": "420.10 USD", "TSLA": "175.20 USD"}
    return prices.get(symbol.strip().upper(),
                      f"No price on file for '{symbol}'.")


TOOL_MAP = {"calculate": calculate, "get_stock_price": get_stock_price}


# ---------------------------------------------------------------------------
# 2. The system prompt that defines the ReAct protocol
# ---------------------------------------------------------------------------

SYSTEM_PROMPT = """Bạn là một AI Agent hoạt động theo vòng lặp ReAct (Thought -> Action -> Observation).
Bạn được cung cấp các công cụ sau:

1. get_stock_price[symbol]: Lấy giá cổ phiếu của một mã chứng khoán. Ví dụ: get_stock_price[AAPL]
2. calculate[expression]: Thực hiện phép tính số học. Ví dụ: calculate[150 * 1.1]

Quy trình làm việc của bạn:
Bước 1: Suy nghĩ về câu hỏi của người dùng (Thought: ...)
Bước 2: Nếu cần dùng công cụ, hãy xuất ra: Action: ten_cong_cu[tham_so] rồi DỪNG LẠI.
        Tuyệt đối không tự viết dòng Observation - hệ thống sẽ cung cấp nó.
Bước 3: Sau khi nhận được Observation, tiếp tục suy nghĩ (Thought: ...) để quyết
        định hành động tiếp theo hoặc đưa ra câu trả lời cuối cùng.
Bước 4: Khi đã có câu trả lời, hãy xuất ra: Final Answer: [câu trả lời của bạn].

Bắt đầu!
"""

# Belt and braces for step 2. A well-behaved model stops on its own, but a stop
# sequence makes an invented Observation impossible rather than merely unlikely
# - the same distinction as the logit mask in Lesson 12.
STOP_SEQUENCES = ["Observation:"]


# ---------------------------------------------------------------------------
# 3. Talking to Ollama - the pattern from Lesson 13
# ---------------------------------------------------------------------------


def installed_models():
    try:
        with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
            return [m["name"] for m in json.loads(response.read())["models"]]
    except urllib.error.URLError:
        return []


def pick(names, preferred):
    for wanted in preferred:
        for name in names:
            if name == wanted or name.startswith(wanted):
                return name
    return None


def chat(messages, model, stop=None):
    """One call. No try/except swallowing: a broken call must stop the agent.

    The earlier version of this project returned the error text as if it were
    the model's answer, so the loop went on to parse "[connection error]" as a
    Thought and carried on for five iterations.
    """
    payload = {"model": model, "messages": messages, "stream": False,
               "options": {"temperature": 0.0, **({"stop": stop} if stop else {})}}
    request = urllib.request.Request(
        f"{OLLAMA}/api/chat", data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(request) as response:
            return json.loads(response.read())["message"]["content"].strip()
    except urllib.error.HTTPError as exc:
        detail = json.loads(exc.read() or b"{}").get("error", "no detail")
        raise RuntimeError(f"Ollama answered HTTP {exc.code}: {detail}") from None
    except urllib.error.URLError as exc:
        raise RuntimeError(f"cannot reach Ollama at {OLLAMA} - {exc.reason}") from None


# ---------------------------------------------------------------------------
# 4. The ReAct control loop
# ---------------------------------------------------------------------------

ACTION_PATTERN = re.compile(r"Action:\s*(\w+)\[(.*?)\]", re.S)


def run_react_agent(question, model, max_iterations=5, use_roles=True):
    """Drive the Thought/Action/Observation loop until a Final Answer.

    use_roles=False reproduces the original mistake - see main().
    """
    print(f"Question: {question}")
    messages = [{"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": question}]
    flat_context = question

    for step in range(1, max_iterations + 1):
        if use_roles:
            reply = chat(messages, model, stop=STOP_SEQUENCES)
        else:
            # Everything the agent ever said, glued into one user message.
            reply = chat([{"role": "system", "content": SYSTEM_PROMPT},
                          {"role": "user", "content": flat_context}],
                         model, stop=STOP_SEQUENCES)
        print(f"\n  --- step {step} ---")
        for line in reply.splitlines():
            if line.strip():
                print(f"  {line}")

        messages.append({"role": "assistant", "content": reply})
        flat_context += f"\n{reply}"

        if "Final Answer:" in reply:
            answer = reply.split("Final Answer:")[-1].strip()
            print(f"\n  RESULT: {answer}")
            return answer, messages

        match = ACTION_PATTERN.search(reply)
        if not match:
            print("\n  The model produced neither an Action nor a Final Answer.")
            return None, messages

        tool_name, argument = match.group(1), match.group(2)
        tool = TOOL_MAP.get(tool_name)
        observation = (tool(argument) if tool
                       else f"Error: no tool named '{tool_name}'.")
        print(f"  [tool] {tool_name}({argument!r}) -> {observation}")

        # The observation is new information from outside, so it is a user turn.
        messages.append({"role": "user", "content": f"Observation: {observation}"})
        flat_context += f"\nObservation: {observation}"

    print(f"\n  Gave up after {max_iterations} iterations.")
    return None, messages


# ---------------------------------------------------------------------------
# 5. Demonstrations
# ---------------------------------------------------------------------------


def demo_calculator_safety():
    """The tool the agent is allowed to call must survive hostile input."""
    print("=== Why the calculator does not use eval() ===")
    cases = ["185.50 * 12", "1 / 0", "9**9**9", "__import__('os').system('ls')"]
    for expression in cases:
        stripped = re.sub(r"[^0-9+\-*/().\s]", "", expression)
        survives = "**" in stripped
        print(f"  {expression!r}")
        print(f"    after the old character filter: {stripped!r}"
              f"{'   <-- ** SURVIVES' if survives else ''}")
        print(f"    this version returns: {calculate(expression)}")
    print("  A filter that keeps '*' cannot stop '**'. 9**9**9 has around 370")
    print("  million digits, so eval() on it hangs the agent indefinitely.\n")


def show_message_roles(messages):
    print("=== The message array the agent built ===")
    for message in messages:
        first_line = message["content"].splitlines()[0] if message["content"] else ""
        print(f"  {message['role']:<9} | {first_line[:58]}")
    roles = [m["role"] for m in messages]
    print(f"  assistant turns recorded: {roles.count('assistant')}")
    print("  Without them the model reads its own reasoning as if the user had")
    print("  typed it - the exact mistake Lesson 11 warns about.\n")


def main():
    names = installed_models()
    if not names:
        print("Ollama is not reachable. Start it, then run this again.")
        return
    model = pick(names, CHAT_PREFERRED)
    if not model:
        print("No suitable chat model found. Try: ollama pull qwen2.5:7b")
        return
    print(f"model: {model}\n")

    demo_calculator_safety()

    question = ("Nếu tôi mua 12 cổ phiếu AAPL thì tôi cần trả tổng cộng "
                "bao nhiêu tiền?")
    print("=== ReAct loop, with proper message roles ===")
    answer, messages = run_react_agent(question, model)
    print()
    show_message_roles(messages)

    print("=== The same question, with the history flattened into one string ===")
    print("  (this is what the first version of this project did)")
    run_react_agent(question, model, use_roles=False)


if __name__ == "__main__":
    main()

An agent's tools must survive hostile input

Before running the loop, the program puts four expressions through both the old character filter and the new evaluator:

Terminal
=== Why the calculator does not use eval() ===
  '185.50 * 12'
    after the old character filter: '185.50 * 12'
    this version returns: 2226.0
  '1 / 0'
    after the old character filter: '1 / 0'
    this version returns: Division by zero.
  '9**9**9'
    after the old character filter: '9**9**9'   <-- ** SURVIVES
    this version returns: Cannot evaluate '9**9**9' - only + - * / are supported.
  "__import__('os').system('ls')"
    after the old character filter: '().()'
    this version returns: Cannot evaluate '__import__('os').system('ls')' - only + - * / are supported.
  A filter that keeps '*' cannot stop '**'. 9**9**9 has around 370
  million digits, so eval() on it hangs the agent indefinitely.

The <-- ** SURVIVES line is the whole argument of the pitfall in section 17.2. The filter stops the attack its author thought of (__import__) and lets through the attack its author did not. That is the nature of every blacklist.

The loop, running for real

Given a question that needs two tools in sequence, the agent works through all three steps itself:

Terminal
=== ReAct loop, with proper message roles ===
Question: Nếu tôi mua 12 cổ phiếu AAPL thì tôi cần trả tổng cộng bao nhiêu tiền?

  --- step 1 ---
  Thought: Để trả lời được câu hỏi này, trước tiên tôi cần lấy giá hiện tại của cổ phiếu AAPL bằng cách sử dụng công cụ get_stock_price.
  Action: get_stock_price[AAPL]
  [tool] get_stock_price('AAPL') -> 185.50 USD

  --- step 2 ---
  Thought: Giờ tôi đã có được giá hiện tại của cổ phiếu AAPL là 185.50 USD, tôi sẽ tính tổng số tiền cần trả cho việc mua 12 cổ phiếu này.
  Action: calculate[185.50 * 12]
  [tool] calculate('185.50 * 12') -> 2226.0

  --- step 3 ---
  Thought: Bây giờ tôi đã có kết quả từ phép tính, tổng số tiền cần trả cho việc mua 12 cổ phiếu AAPL là 2226.0 USD.
  Final Answer: Tổng cộng bạn sẽ phải trả 2226.0 USD để mua 12 cổ phiếu AAPL.

  RESULT: Tổng cộng bạn sẽ phải trả 2226.0 USD để mua 12 cổ phiếu AAPL.

Read step 2 carefully: the model does not multiply 185.50 by 12 in its head. It writes Action: calculate[185.50 * 12] and waits. That is the crux of ReAct — language models are unreliable at arithmetic (Lesson 11), so the right move is to let it decide what to compute and hand the computation to Python.

Message roles: where the first version of this project went wrong

The first version accumulated the entire reasoning history into one Python string and sent it up as a single user message. Every sentence the model had produced came back to it as though the user had typed it — exactly the pitfall Lesson 11 describes. The new version records the real roles:

Terminal
=== The message array the agent built ===
  system    | Bạn là một AI Agent hoạt động theo vòng lặp ReAct (Thought
  user      | Nếu tôi mua 12 cổ phiếu AAPL thì tôi cần trả tổng cộng bao
  assistant | Thought: Để trả lời được câu hỏi này, trước tiên tôi cần l
  user      | Observation: 185.50 USD
  assistant | Thought: Giờ tôi đã có được giá hiện tại của cổ phiếu AAPL
  user      | Observation: 2226.0
  assistant | Thought: Bây giờ tôi đã có kết quả từ phép tính, tổng số t
  assistant turns recorded: 3
  Without them the model reads its own reasoning as if the user had
  typed it - the exact mistake Lesson 11 warns about.
🔬 Being honest about this comparison
The program runs both versions on the same question so you can compare them yourself. On the authoring machine, with a 14B model and a task only three steps long, both produce the correct answer — I could not construct a failure to show you, and I will not invent one.

The reason to get the roles right lies elsewhere: a properly role-tagged messages array is what you can plug straight into the API's native function calling (Lesson 12), what you can trim turn by turn when you exceed the token budget (Lesson 11), and what you can persist as an audit log. A flat string does none of those three. Getting it right costs nothing; get it right from the start.

How to run this project on your machine

  1. Start Ollama, pull a chat model (ollama pull qwen2.5:7b), then run python3 react_agent.py. The calculator check runs even without Ollama.
  2. The reasoning chain on your machine will differ in wording (different models phrase things differently), but the shape must match: look up the price first, calculate second, then Final Answer.
  3. Then try breaking it three ways:
    • Remove stop=STOP_SEQUENCES from run_react_agent. With a strong model nothing changes — but a smaller model may write its own Observation: line and invent the tool result. A stop sequence makes that impossible rather than merely unlikely, in the same spirit as the logit mask in Lesson 12.
    • Change max_iterations=5 to 1. The agent is cut off mid-task and prints "Gave up" — the safety net from the infinite-loop pitfall in section 17.2, now visibly firing.
    • Ask a question needing a tool the agent does not have, such as "what is the weather in Hanoi today?". Watch how the model handles it: does it invent a non-existent tool (and get back Error: no tool named ...), or does it admit it cannot?

Lesson summary & what comes next

🔑 What you achieved:
  • Achieved: writing a complete ReAct loop in plain Python — no LangChain — and watching the agent chain two tools in the right dependency order.
  • Achieved: recording agent history in a properly role-tagged messages array instead of the flattened string Lesson 11 warns against.
  • Achieved: seeing why character filtering cannot replace a whitelist: 9**9**9 passes the filter untouched and hangs eval(). The new version uses ast.parse and permits exactly four operations.
  • Achieved: knowing an agent's three mandatory safety nets: an iteration limit, a stop sequence, and tools that do not trust the model's output.

Bridge to the next lesson: the for loop above is enough for a linear agent. But once the workflow has branches, loops back on itself, and points where a human must approve before it continues, a for loop stops being enough. Lesson 18 models the agent as a state graph with LangGraph.

Download the practice code for this lesson

The Python file react_agent.py — the complete ReAct loop, the AST-based calculator and the message-role comparison (run python3 react_agent.py, needs Ollama with a chat model):

Download react_agent.py

📖 Further reading

Related lessons in this series

Lesson 16: Advanced RAG — query rewriting & cross-encoder reranking Lesson 18: Stateful agents with LangGraph Back to the Practical AI Engineer roadmap

Comments