When your agent burns tokens spinning through Thought/Action/Observation cycles without terminating, you need to debug ReAct agent infinite loop behavior systematically. The ReAct pattern couples reasoning and tool calls, but a missing stop condition or malformed tool output turns it into a treadmill. This guide walks through concrete steps to find the cause and force termination without crippling agent capability.
Step 1: Reproduce and Capture the Full Trajectory
You cannot fix what you cannot see. Wrap your LLM call and tool executor to log every prompt, completion, and observation. A simple stdout dump is enough for local runs; for production, pipe to structured logs.
import json, time
def logged_llm_call(client, messages, **kwargs):
resp = client.chat.completions.create(messages=messages, **kwargs)
print(f"--- LLM REQ {time.time()} ---")
print(json.dumps(messages, indent=2)[:2000])
print(f"--- LLM RESP ---")
print(resp.choices[0].message.content)
return resp
def logged_tool(dispatch, name, inp):
print(f"TOOL CALL: {name} -> {inp}")
out = dispatch(name, inp)
print(f"TOOL OUT: {out}")
return out
Run the failing task and save the logs. Look for repetition: identical Thought lines, the same Action with the same argument across steps, or an Observation that says "error" or "". If you see the model issuing Action: search five times with the same query, the loop is tool-driven, not parser-driven.
Step 2: Identify the Loop Trigger — Stop Word or Parser Failure
Most ReAct implementations parse the model output for Action: or Final Answer:. If the model never emits the exact stop phrase, the driver loops. A typical parser looks like this:
import re
def parse_react(text):
if "Final Answer:" in text:
return ("final", text.split("Final Answer:")[1].strip())
m = re.search(r"Action:\s*(\w+)\s*Action Input:\s*(.+)", text, re.DOTALL)
if m:
return ("action", m.group(1), m.group(2).strip())
return ("unknown", text)
If the model outputs Final answer: (lowercase) or wraps it in markdown fences, parse_react returns unknown and your loop treats it as a thought, prompting again. Normalize casing and strip code fences before parsing.
def normalize(text):
return text.replace("```", "").strip().lower()
def parse_react(text):
t = normalize(text)
if "final answer:" in t:
return ("final", text.split(":",1)[1].strip())
m = re.search(r"action:\s*(\w+)\s*action input:\s*(.+)", t, re.DOTALL)
if m:
return ("action", m.group(1), m.group(2).strip())
return ("unknown", text)
That alone fixes a large class of infinite loops. Also print the parsed kind alongside the raw text so you can confirm which branch fires.
Step 3: Constrain Max Iterations and Add Explicit Termination
Never let a ReAct driver run unbounded. Put a hard cap and return a fallback answer that nudges the model to stop.
MAX_STEPS = 8
def run_agent(client, dispatch, question):
hist = f"Question: {question}\n"
for i in range(MAX_STEPS):
resp = logged_llm_call(client, [{"role":"user","content":hist}])
kind = parse_react(resp.choices[0].message.content)
if kind[0] == "final":
return kind[1]
if kind[0] == "action":
_, name, inp = kind
obs = logged_tool(dispatch, name, inp)
hist += resp.choices[0].message.content + f"\nObservation: {obs}\n"
else:
hist += resp.choices[0].message.content + "\nObservation: malformed output, use Final Answer:\n"
return "Agent exceeded step limit"
The cap stops the token bleed. The fallback message tells the model to terminate next round, often breaking the loop on the next iteration. For complex tasks, scale MAX_STEPS with question length or tool count, but always keep it finite.
Step 4: Validate Tool Outputs and Schema
A tool that returns None or an exception string like "timeout" invites the model to retry the same call. Define strict contracts so the observation is actionable.
from pydantic import BaseModel, ValidationError
class WeatherOut(BaseModel):
temp_c: float
status: str
def safe_tool(raw):
try:
return WeatherOut(**raw).model_dump_json()
except ValidationError as e:
return f"TOOL_ERROR: {e}"
If the model sees TOOL_ERROR, it should branch to a different tool or final answer. If it ignores the error and repeats, your driver must count consecutive identical errors and force a final answer after three strikes.
error_streak = 0
if obs.startswith("TOOL_ERROR"):
error_streak += 1
else:
error_streak = 0
if error_streak >= 3:
hist += "\nObservation: tool failed repeatedly, give Final Answer.\n"
Step 5: Inspect Model Behavior with Controlled Prompts
Swap the live model for a scripted responder to isolate parser vs model issues. This removes sampling randomness.
class FakeModel:
def __init__(self, script): self.script = script; self.i=0
def create(self, messages, **kw):
txt = self.script[self.i]; self.i+=1
return type("R",(),{"choices":[type("C",(),{"message":type("M",(),{"content":txt})()})()]})()
bad_script = [
"Thought: need search\nAction: search\nAction Input: weather",
"Thought: need search\nAction: search\nAction Input: weather",
]
Feed bad_script and confirm your driver caps out and returns the fallback. Then feed a correct script ending with Final Answer: 4 and confirm termination. If the scripted run terminates but the live run loops, the problem is model adherence, not code.
Step 6: Rule Out Provider Degradation and Truncation
A truncated completion at the token limit looks like a partial Action. The driver waits for the rest, calls again, gets truncated again. If you use a gateway such as n4n.ai, automatic fallback may route to a different provider that emits different stop behaviors; honor client routing directives and forward cache-control hints to keep responses consistent across retries. Set max_tokens explicitly and check finish_reason.
resp = client.chat.completions.create(
messages=msgs,
max_tokens=512,
extra_body={"provider": {"order": ["anthropic", "openai"]}}
)
if resp.choices[0].finish_reason == "length":
hist += "\nObservation: response truncated, provide Final Answer now.\n"
That nudge often terminates the loop when the context is full. Also log finish_reason in Step 1 so you can spot truncation patterns in the trajectory.
Step 7: Verify Success with a Deterministic Test
Write a pytest that runs the agent on a fixed task and asserts it stops within the cap and returns a string. Use the FakeModel to keep it fast and non-flaky.
def test_agent_terminates():
fake = FakeModel([
"Thought: calc\nAction: add\nAction Input: 2,2",
"Final Answer: 4"
])
out = run_agent(fake, lambda n,i: '{"temp_c":1,"status":"ok"}', "add 2 2")
assert isinstance(out, str)
assert "exceeded step limit" not in out
assert out == "4"
Run this in CI to catch parser regressions. For live models, sample 10 tasks and assert median step count is below MAX_STEPS-2. Emit a metric agent_steps per run.
Verification Checklist
- Trajectory log shows distinct Thoughts after the fix, not repeated strings.
- Parser handles lowercase and fenced output; unit test covers both.
MAX_STEPStriggers fallback on adversarial scripted input.- Tool errors are structured (
TOOL_ERROR: ...), not raw tracebacks. - CI test green on scripted model; live smoke test under step budget.
If you still see the agent loop, diff the Observation strings: identical observations with identical subsequent Actions means the model is stuck on a bad tool. Replace the tool or add a “cannot proceed” final answer branch.
The ReAct pattern is forgiving but not self-healing. Instrument, cap, validate, and test. That’s the only reliable way to debug ReAct agent infinite loop problems in production.