StackPractices
advanced By Mathias Paulenko

AI Agents with Tool Use: ReAct in Practice

Build autonomous AI agents that can use external tools and APIs to accomplish complex tasks.

Topics: ai

Overview

A chatbot talks; an agent acts. Give an LLM a set of tools — search, a database, an API — and it stops being a text generator and starts being a worker that searches the web, queries your tables, runs code, and chains the results into something a plain chatbot can’t do alone. For when the loop itself breaks, the LLM Fallback Pattern is the safety net worth wiring in first.

When to Use

Reach for this when the job needs more than one model call:

  • Assistants that must fetch live data — search, databases, internal APIs
  • Workflows where several API calls have to chain in the right order
  • Tasks where the model reasons over external knowledge, not just training data
  • Systems that should retry a failed step instead of dying on the first error

Solution

ReAct Pattern Agent (Python)

import openai
import json
from typing import Callable

def agent_react(query: str, tools: dict[str, Callable]) -> str:
    messages = [
        {"role": "system", "content": "You are a helpful assistant. Use tools when needed."},
        {"role": "user", "content": query}
    ]

    for _ in range(5):  # Max iterations
        response = openai.chat.completions.create(
            model="gpt-4",
            messages=messages,
            tools=[
                {"type": "function", "function": {"name": n, "parameters": {}}}
                for n in tools.keys()
            ]
        )

        msg = response.choices[0].message
        if not msg.tool_calls:
            return msg.content

        messages.append(msg)
        for call in msg.tool_calls:
            result = tools[call.function.name](**json.loads(call.function.arguments))
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": str(result)
            })

    return "Max iterations reached"

Tool Definition Example

def search_wikipedia(query: str) -> str:
    """Search Wikipedia for a topic."""
    # Implementation omitted
    return f"Results for {query}"

tools = {"search_wikipedia": search_wikipedia}
result = agent_react("Who won the 2022 FIFA World Cup?", tools)

Explanation

The ReAct loop is deceptively simple: the model thinks about what to do next (Thought), calls a tool with structured arguments (Action), reads the result back into context (Observation), and repeats until it produces a final answer — or hits the step ceiling you remembered to set.

Two decisions carry most of the weight: tool schemas in OpenAI’s function-calling format keep arguments typed instead of hallucinated, and an iteration limit turns “agent that can’t decide” from an infinite bill into a clean failure.

  • Error handling: Tools should return errors gracefully, not crash
  • Context window: Summarize long tool outputs to fit token limits
Mermaid flowchart LR diagram

Variants

FrameworkPatternBest For
LangChain AgentsReAct, Plan-and-ExecuteRapid prototyping
AutoGenMulti-agent conversationCollaborative tasks
CrewAIRole-based agentsBusiness workflows
CustomReAct with tool registryProduction systems

What Works

The rules that separate a working tool-call setup from a flaky one: every tool gets a name, a description a model can reason about, and a JSON schema — vague definitions produce vague calls. Keep the count low; three to five sharp tools beat twenty mushy ones every time. Validate arguments before executing — the model will invent them eventually. Log every step, because the reasoning is otherwise a black box. And time out your tools — external calls hang, and a hung tool hangs the whole loop.

Common Mistakes

The failures rhyme across every agent we’ve seen. Tool sprawl — twenty tools confuse the model into wrong calls; three to five sharp ones win. No error path: a failed tool call without recovery crashes the loop or worse, gets silently hallucinated around. Context exhaustion: long observation histories eat the window until the model forgets why it’s here — cap and summarize. Trusting outputs: hallucinated arguments are a when, not an if — validate every call. And no kill switch: autonomous agents need a human stop button, especially when the tools can write, send, or spend.

Plan-and-Execute Pattern

import openai
import json

def plan_and_execute(query: str, tools: dict) -> str:
    # Step 1: Generate a plan
    plan_response = openai.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": "Break the task into steps. Return a JSON array of steps."},
            {"role": "user", "content": query}
        ],
        response_format={"type": "json_object"}
    )
    steps = json.loads(plan_response.choices[0].message.content)["steps"]

    # Step 2: Execute each step
    results = []
    for step in steps:
        result = agent_react(step, tools)
        results.append(result)

    # Step 3: Synthesize
    final = openai.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": "Synthesize the results into a final answer."},
            {"role": "user", "content": f"Query: {query}\nResults: {json.dumps(results)}"}
        ]
    )
    return final.choices[0].message.content

Plan-and-Execute separates planning from execution. The planner breaks the task into steps, each step runs through a ReAct loop, and a final call synthesizes results. This reduces token consumption because each step has a smaller context window — and independent steps can run in parallel.

Tool Schemas with JSON Schema

tool_schemas = [
    {
        "type": "function",
        "function": {
            "name": "search_database",
            "description": "Search the product database by name or category",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Search term"
                    },
                    "category": {
                        "type": "string",
                        "enum": ["electronics", "books", "clothing"],
                        "description": "Product category filter"
                    },
                    "limit": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 100,
                        "default": 10
                    }
                },
                "required": ["query"]
            }
        }
    }
]

Detailed tool schemas improve agent accuracy. Include description for every parameter — the LLM reads these to decide when and how to call the tool. Use enum for constrained values, minimum/maximum for numeric ranges, and required for mandatory fields. The more precise the schema, the fewer hallucinated arguments.

Context Window Management

def summarize_observations(messages: list, max_tokens: int = 4000) -> list:
    """Summarize tool outputs when context grows too large."""
    total = sum(len(str(m.get("content", ""))) for m in messages)
    if total < max_tokens:
        return messages

    # Keep system + first user message, summarize the rest
    system = messages[0]
    user_query = messages[1]
    history = messages[2:]

    summary = openai.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": "Summarize the tool calls and results concisely."},
            {"role": "user", "content": json.dumps([{"role": m["role"], "content": m["content"][:500]} for m in history])}
        ]
    ).choices[0].message.content

    return [system, user_query, {"role": "system", "content": f"Previous context: {summary}"}]

Long agent runs accumulate large observation histories that exhaust the context window. Summarize periodically by keeping the system prompt and original query, then compressing tool outputs into a summary. Alternatively, use sliding-window truncation or retrieval-augmented context selection to keep only relevant observations.

Multi-Agent Orchestration

def multi_agent_orchestrate(query: str) -> str:
    agents = {
        'researcher': {
            'system': 'You are a research agent. Find information using search tools.',
            'tools': ['web_search', 'read_file']
        },
        'coder': {
            'system': 'You are a coding agent. Write and execute code.',
            'tools': ['run_python', 'write_file']
        },
        'reviewer': {
            'system': 'You are a review agent. Check code quality and correctness.',
            'tools': ['read_file', 'run_tests']
        }
    }

    # Route to researcher first
    research = run_agent(agents['researcher'], query)

    # Pass research to coder
    code = run_agent(agents['coder'], f"Based on: {research}\nImplement the solution.")

    # Review the code
    review = run_agent(agents['reviewer'], f"Review this code:\n{code}")

    if 'APPROVE' in review:
        return code
    else:
        # Re-route to coder with feedback
        return run_agent(agents['coder'], f"Fix issues:\n{review}\nOriginal:\n{code}")

Multi-agent orchestration assigns specialized roles to different agents. Each agent has its own system prompt and tool set. A coordinator routes tasks between agents based on the current state. This pattern works well for complex workflows: research → implement → review → deploy. Keep agent communication structured — pass context as formatted strings or JSON, not raw message histories.

When to Avoid

An agent loop isn’t free — skip it where it buys nothing. Single-call lookups — one LLM call answers it — don’t deserve the latency and cost of a loop. High-stakes calls (medical, legal, trading) can’t afford hallucinated arguments, period. Real-time systems can’t afford the loop either: five LLM calls is five to thirty seconds. And when the meter matters, each iteration is a full call — a ten-step agent is ten times the bill for the same answer a chain would give.

Troubleshooting

  • Model outputs are inconsistent: set temperature to 0 for deterministic tasks, use seed where supported, and version the prompt.
  • Prompt injection leaks context: separate user input from system instructions.
  • High token costs: cache embeddings, summarize long context, and choose smaller models for simple tasks.
  • Retrieval returns irrelevant chunks: tune chunk size, overlap, and metadata filters. Evaluate retrieval metrics separately from generation.
  • Evaluation scores don’t match human judgment: define clear rubrics, use several judges, and track disagreement. Human review is still the ground truth.

Further Reading

Frequently Asked Questions

What is the difference between RAG and an agent?

RAG retrieves documents and answers once. Agents can take several actions, use tools, and iterate until a goal is met.

How many tools should an agent have?

Start with two or three. Past five to seven, the model starts picking the wrong tool often enough to notice — accuracy drops fast with every description you add.

Can agents run without OpenAI?

Sure — local models like Llama and Mistral do tool calling through JSON mode or similar structured output. The loop is the same; only the API changes.

How do I handle tool call failures?

Catch them in the tool wrapper and feed the error back as a tool result — the agent usually retries with fixed arguments or picks a different tool. Cap the retries so a stuck tool can't loop forever, and log the full trace while giving the agent a short error it can actually use.

What is the cost of running an agent loop?

One LLM call per iteration. A 5-step loop at ~2,000 tokens per call runs about $0.60 on GPT-4 — the same loop on GPT-4o-mini is under a cent. Route with the cheap model and save the expensive one for steps that actually need reasoning. Caching tool results skips calls you already paid for.

How do I test agent behavior?

Mock LLM responses and tool calls for unit tests; for integration tests, stub the tools so they return predictable results. Recording production traces and replaying them catches regressions. Skip live APIs in tests — flaky services make flaky tests.

When should I use multi-agent orchestration?

When the task splits cleanly into roles with different prompts or tool sets — research, implement, review. If one agent with all the tools handles it fine, stay there; orchestration adds coordination cost you don't need yet.