| name | react-agent-loop |
| description | ReAct (Reason+Act) agent loop — the canonical Observe/Think/Act cycle. Tool registry, stop conditions, turn budget, observation formatting. Every 2026 agent framework runs this loop under the hood. Sources: rohitg00/ai-engineering-from-scratch (Apache-2.0). |
/react-agent-loop
When to Use
- Building a new agent loop from scratch (stdlib, no framework dependency)
- Diagnosing why an existing agent loops infinitely, hallucinates success, or ignores tool errors
- Deciding whether to use interleaved ReAct vs decoupled plan-then-execute ([[rewoo-plan-execute]])
- Wiring stop conditions and turn budgets into any LLM-based workflow
Do NOT use for
- Parallel task planning where token efficiency matters (use [[rewoo-plan-execute]])
- Self-improvement across sessions (use [[reflexion-verbal-rl]])
- Multi-agent orchestration (use [[a2a-protocol-patterns]] or [[multi-agent-debate]])
The five mandatory ingredients
1. Message buffer — grows: user → assistant → tool → assistant → tool → assistant → final
2. Tool registry — name → callable; schema in, execution, result string out
3. Stop condition — model calls finish / no tool call in response / max turns / guardrail trip
4. Turn budget — hard cap; 40–400 steps is normal; pick per task class, not one global cap
5. Observation formatter — every tool error becomes an observation string, never a crash
Missing any one of these = a chatbot, not an agent.
Minimal ReAct loop (stdlib Python)
import json
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class ToolRegistry:
tools: dict[str, Callable] = field(default_factory=dict)
def register(self, name: str, fn: Callable) -> None:
self.tools[name] = fn
def call(self, name: str, args: dict) -> str:
if name not in self.tools:
return f"[error] unknown tool: {name}"
try:
result = self.tools[name](**args)
return str(result)
except Exception as e:
return f"[error] {name} raised {type(e).__name__}: {e}"
def run_agent(llm, registry: ToolRegistry, task: str, max_turns: int = 40) -> str:
messages = [{"role": "user", "content": task}]
for turn in range(max_turns):
response = llm(messages)
messages.append({"role": "assistant", "content": response})
tool_call = parse_tool_call(response)
if tool_call is None:
return response
if tool_call["name"] == "finish":
return tool_call["args"].get("answer", response)
observation = registry.call(tool_call["name"], tool_call["args"])
messages.append({
"role": "tool",
"content": f"[{tool_call['name']}] {observation}"
})
return "[max_turns reached — task incomplete]"
def parse_tool_call(text: str) -> dict | None:
"""Extract Action: tool_name({json}) from model output."""
import re
m = re.search(r'Action:\s*(\w+)\((.*?)\)', text, re.DOTALL)
if not m:
return None
name = m.group(1)
try:
args = json.loads(m.group(2) or '{}')
except json.JSONDecodeError:
args = {}
return {"name": name, "args": args}
Register tools and run
store = {}
registry = ToolRegistry()
registry.register("kv_get", lambda key: store.get(key, "[not found]"))
registry.register("kv_set", lambda key, value: store.update({key: value}) or "ok")
registry.register("calc", lambda expr: str(eval(expr, {"__builtins__": {}})))
registry.register("finish", lambda answer="": answer)
result = run_agent(my_llm, registry, "What is 17 * 19? Store the result as 'product'.")
Trust boundary — tool outputs are untrusted
import re
INJECTION_PATTERNS = [
r'ignore (all )?(prior|previous|above) instructions',
r'</?inst(ruction)?s?>',
r'system:\s*you are',
]
def sanitize_observation(text: str, max_len: int = 2000) -> str:
text = text[:max_len]
for pattern in INJECTION_PATTERNS:
if re.search(pattern, text, re.IGNORECASE):
return "[observation redacted — potential injection detected]"
return text
Stop condition checklist
def should_stop(response: str, turn: int, max_turns: int) -> tuple[bool, str]:
if turn >= max_turns:
return True, "max_turns"
if "Action: finish" in response:
return True, "finish"
if not re.search(r'Action:\s*\w+', response):
return True, "no_tool_call"
if re.search(r'(task complete|done|all (tests )?pass)', response, re.I):
return False, "suspected_hallucination_continue"
return False, "continue"
Anti-Fake-Pass Checklist
❌ No turn budget → loop runs until context limit or billing cap
❌ Tool errors crash the loop → agent gets stuck, not a graceful observation
❌ Success on 400 error → agent says "done" after a failed API call; re-probe actual state
❌ Trust boundary ignored → PDF/web content appended raw → prompt injection possible
❌ Single global max_turns for all task types → research task needs 200, button-click needs 5
❌ No finish action → agent can't signal completion, loop always hits max_turns