# ReAct Tool-Calling Agent Loop

> A minimal, provider-agnostic reason-act-observe loop with a step budget, tool registry, and safe termination.

Category: AI Agent Workflows  
Tags: react, tool-use, loop, python  
Setup time (estimate): 30-60 minutes  
Estimated cost: One model call per loop step; set a step budget.  
Last verified: not yet independently verified

The ReAct pattern (Yao et al., 2022) interleaves model reasoning with tool calls. Modern APIs expose this as native tool calling; the loop below works with any chat API that returns tool calls.

## Steps

1. Define tools with a name, a JSON Schema for arguments, and a Python function.
2. Send the conversation plus tool schemas to the model.
3. If the model returns tool calls, execute each, append the results as `tool` messages, and repeat.
4. Stop when the model returns a plain answer or the step budget is exhausted.
5. Log every step (input, tool, args, result, latency) for debugging.

## Code

```python
import json

MAX_STEPS = 8
TOOLS = {}

def tool(schema):
    def wrap(fn):
        TOOLS[fn.__name__] = {"fn": fn, "schema": schema}
        return fn
    return wrap

@tool({"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]})
def get_weather(city: str) -> str:
    return json.dumps({"city": city, "forecast": "replace with a real API call"})

def run(llm_call, user_msg: str) -> str:
    messages = [{"role": "system", "content": "Use tools when needed. Answer concisely."},
                {"role": "user", "content": user_msg}]
    specs = [{"type": "function", "function": {"name": n, "parameters": t["schema"]}} for n, t in TOOLS.items()]
    for _ in range(MAX_STEPS):
        reply = llm_call(messages, specs)          # returns an assistant message dict
        messages.append(reply)
        calls = reply.get("tool_calls") or []
        if not calls:
            return reply["content"]
        for c in calls:
            name = c["function"]["name"]
            try:
                args = json.loads(c["function"]["arguments"])
                out = TOOLS[name]["fn"](**args)
            except Exception as e:                  # feed errors back so the model can recover
                out = f"ERROR: {e}"
            messages.append({"role": "tool", "tool_call_id": c["id"], "content": out})
    return "Stopped: step budget exhausted."
```

## Notes

- Always cap steps; unbounded loops are the most common agent failure.
- Return tool errors as text instead of raising, so the model can retry with corrected arguments.

## Failure modes

- Infinite tool loops without a step budget.
- Raising on tool errors instead of returning them stops the agent from recovering.
- Large tool outputs blow up the context; truncate.
