Hey everyone, welcome to the thirty-second issue of The Main Thread newsletter. In this issue, we will discuss building reliable AI agents in production.
This is the pattern I have noticed with many AI agent tutorials on the web - they show a 10-line ReAct loop, a weather API tool call, and a clean success path. The agent reasons, calls the tool, gets back valid JSON, generates a response. It works in the demo/notebook but if you don’t want to page through logs trying to understand why it called a search API 10+ times in a row, spent $80 on a single user session, and returned a response that confidently cited a web page that doesn’t exist anymore, then you won’t deploy it in production.
There’s a huge gap between an agent that works and an agent that is reliable. That is where production AI engineering lives. This piece is about that gap.
TL;DR
ReAct is the right default loop architecture; Plan-and-Execute is right when the task needs upfront commitment before action
Tool design is the highest-leverage reliability investment; poorly designed tools are the root cause of most agent failures
Every tool call can fail; error handling must be a first-class part of the agent design, not an afterthought
State management is a context window problem; agents lose coherence when their history overflows or isn't structured correctly
Runaway loops require hard limits at multiple layers; model-level reasoning won't reliably stop them
Budget and timeout constraints must be structural, not advisory; the model cannot enforce its own budget
The Agent Loop
An agent is a loop. At each step, the model observes its current state, decides what to do next, executes an action, observes the result, and repeats until it’s done. The architecture of that loop determines most of our reliability properties.
1. ReAct: Reason Then Act
ReAct (Reasoning + Acting) is the dominant pattern. The model interleaves reasoning steps with action steps in a single context:
Thought: I need to find the current price of AAPL stock.
Action: search_web("AAPL stock price today")
Observation: AAPL is trading at $189.42 as of 2:34 PM EST.
Thought: I have the price. I can now answer the user.
Answer: Apple (AAPL) is currently trading at $189.42.Each thought-action-observation triplet is appended to the context. The model sees its own reasoning history and builds on it. This works well for tasks where the path forward is not known upfront and must be discovered through exploration.
Where ReAct Breaks
Long task chains are where ReAct crumbles. As the context fills with thought-action-observation triplets, the model starts losing coherence. It re-reasons about conclusions it already reached. It repeats tool calls. It forgets constraints stated early in the conversation. The context window is not infinite, and even within it, attention degrades over long sequences.
2. Plan-and-Execute: Commit Then Act
This architecture separates planning from execution into two explicit phases:
[Planning Phase]
Task: Research competitors and write a summary report
Plan:
1. Search for top 5 competitors in the market
2. For each competitor, retrieve their pricing page
3. Extract pricing tiers from each page
4. Synthesize findings into a comparison table
5. Write executive summary
[Execution Phase]
Step 1: search_web("top CRM software competitors 2026")
→ [results]
Step 2a: fetch_page("salesforce.com/pricing")
→ [results]
...The plan is generated once, then each step is executed with a focused sub-context. The executing agent for each step doesn’t need to hold the entire task history, just the plan, the current step, and that step’s context.
Where Plan-and-Execute Breaks
It breaks for adaptive tasks. The plan assumes a static world. If step 2 returns unexpected results that invalidate the plan, the execution agent has no authority to revise it. We need a re-planning trigger, which adds complexity.
Practical Guidance
We should use ReAct for exploratory, interactive tasks where the path depends on what we find. We should use Plan-and-Execute for structured, multi-step workflows where the task shape is predictable, and we need the executing steps to stay focused. Most production agents are ReAct, with Plan-and-Execute reserved for batch processing pipelines.
Tool Design
Tools are the agent’s interface to the world. Every reliability failure I have encountered in production agents traces back, directly or indirectly, to poorly designed tools.
Principle of Narrow Contracts
A tool should do exactly one thing, and its input/output contract should be as specific as possible. Broad, flexible tools give the model too much surface area to misuse.
Bad tool design:
# Too broad: model must infer what format, what fields, what behavior
@tool
def database_query(query: str) -> str:
"""Run a query against the database."""
return db.execute(query)Good tool design:
@tool
def get_user_by_email(email: str) -> UserRecord | None:
"""
Retrieve a user record by email address.
Returns None if no user with that email exists.
Does not create users. Use create_user() for that.
"""
return db.users.find_one(email=email)The narrow contract tells the model exactly what it gets, what it doesn’t get, and when to use something else instead. Ambiguous tool descriptions lead to ambiguous usage. The model will make reasonable inferences based on the description. We should write descriptions for the model, not the human readers.
Idempotency is Non-Negotiable for Mutating Tools
In the real world, agents retry, networks fail, tool calls time out. Any tool that mutates state must be safe to call twice with the same arguments. This is idempotency.
@tool
def send_notification(user_id: str, message: str, idempotency_key: str) -> bool:
"""
Send a push notification to a user.
idempotency_key: Unique identifier for this notification event.
Duplicate calls with the same key are silently ignored.
"""
if notification_store.exists(idempotency_key):
return True # Already sent, safe to acknowledge
notification_store.mark_sent(idempotency_key)
return push_service.send(user_id, message)Without idempotency, a retry on a failed network call sends the notification twice. The agent doesn’t know the first call succeeded because the timeout made it look like failure. We must design mutating tools such that the worst outcome of a retry is a no-op, not a duplicate action.
Return Structured Errors, Not Exceptions
When a tool fails, the model needs to understand why in order to decide what to do next. An unhandled exception kills the agent loop, and a vague error string leaves the model guessing.
from dataclasses import dataclass
from typing import Union
@dataclass
class ToolError:
code: str # Machine-readable: "NOT_FOUND", "RATE_LIMITED", "INVALID_INPUT"
message: str # Human-readable explanation
retryable: bool # Should the agent try again?
retry_after: int | None # If retryable, how many seconds to wait
@tool
def fetch_document(doc_id: str) -> Union[Document, ToolError]:
try:
return document_store.get(doc_id)
except DocumentNotFound:
return ToolError("NOT_FOUND", f"Document {doc_id} does not exist", retryable=False)
except RateLimitExceeded as e:
return ToolError("RATE_LIMITED", "Too many requests", retryable=True, retry_after=e.wait_seconds)
except Exception as e:
return ToolError("INTERNAL_ERROR", str(e), retryable=True, retry_after=5)The retryable field is a critical signal. The model can reason: “the document doesn’t exist - I should tell the user” vs. “I was rate-limited - I should wait and retry”. Without this distinction, the model either tries retries non-retryable failures forever or gives up on retryable ones too early.
Error Recovery and Graceful Degradation
Error handling in agents is very different from error handling in APIs. An API returns an error to the caller, but an agent must decide what to do about the error and continue.
Error Recovery Loop
We should structure our agent loop to explicitly handle tool errors:
async def agent_step(context: AgentContext) -> StepResult:
response = await llm.call(context.messages, tools=context.tools)
if response.stop_reason == "tool_use":
for tool_call in response.tool_calls:
result = await execute_tool(tool_call)
if isinstance(result, ToolError):
if result.retryable and context.retry_budget > 0:
context.retry_budget -= 1
await asyncio.sleep(result.retry_after or 1)
# Re-add the error as an observation and continue
context.add_observation(tool_call.id, f"Error: {result.message}. Retrying.")
else:
# Non-retryable or budget exhausted — inform the model
context.add_observation(
tool_call.id,
f"Tool failed: {result.message}. Cannot retry. Proceed without this information."
)
else:
context.add_observation(tool_call.id, result)
return StepResult(response=response, context=context)Failed tool calls become observations. The model sees “Tool failed: Document not found. Cannot retry. Proceed without this information“. It now has the context to either ask the user for a different document ID, acknowledge the gap in its response, or try an alternative approach. Without that observation, the model has no idea that the tool failed.
Partial Success is a Valid State
Not every tool failure should abort the task. An agent researching a topic should produce a partial report if two out of five sources fail, not return an error because some sources were unavailable. Graceful degradation means completing the task with what’s available and being explicit about what’s missing.
We should implement a task completion signal that distinguishes “I couldn’t complete the task“ vs. “I completed the task but with some gaps“.
@dataclass
class AgentResult:
output: str
completed: bool
gaps: list[str] # What information was unavailable
confidence: str # "high", "medium", "low"This is the information the calling system can act on - surface the gaps to the user, trigger a human review workflow, or store the result with a low-confidence flag.
State Management Across Turns
Multi-turn agents accumulate state across the conversation. If managed incorrectly, the state becomes a source of subtle failures that are hard to reproduce and harder to debug.
Context Window is a Finite Resource
The context window is the agent’s working memory. Once it fills, we must either truncate old messages or summarize them - both lossy operations. The common mistake is treating context overflow as an edge case. This is routine in production with real users and real conversations.
There are two strategies:
1. Sliding Window With Summarization
When the context approaches the limit, summarize the oldest N messages into a compressed representation and discard them. The summary replaces the raw messages.
async def maybe_compress_context(context: AgentContext, limit_tokens: int):
current_tokens = count_tokens(context.messages)
if current_tokens < limit_tokens * 0.8:
return # Plenty of room
# Summarize the oldest half of the message history
old_messages = context.messages[:len(context.messages)//2]
summary = await llm.summarize(old_messages)
context.messages = [
SystemMessage(f"Earlier conversation summary: {summary}"),
*context.messages[len(context.messages)//2:]
]2. External State Store
In this, we don’t try to keep everything in context. Extract structured facts from the conversation and store them externally. The context holds only the recent exchange; the agent queries the state store when it needs earlier facts.
# Rather than keeping 50 messages in context, maintain extracted state
agent_state = {
"user_goal": "Find a flight from NYC to Tokyo under $1200",
"constraints": ["non-stop preferred", "departure after Oct 15"],
"already_checked": ["Delta DL006", "United UA837"],
"best_option_so_far": {"flight": "ANA NH010", "price": "$1089"},
}The model is given this structured state as a system prompt addition at each turn, not the raw conversation history. It is cheaper in tokens and actually more reliable - summarization can lose nuance, but structured extraction preserves the facts the agent needs.
Stale Belief Problem
Agents form beliefs from tool call results and reason from those beliefs in subsequent steps. If the underlying data changes between steps, or if an early tool call returned incorrect data, the agent’s reasoning can be internally consistent but factually wrong. It confidently builds on a bad foundation.
To mitigate this, in long-running agents, we should re-verify critical facts before acting on them, especially before any irreversible action. If the agent is about to book a flight based on the price it retrieved three steps ago, it should re-check the price first. We must treat time-sensitive tool results as having TTL.
Preventing Infinite Loops and Runway Costs
This is the failure mode that ends careers and budgets. An agent gets stuck, calls the same tool repeatedly, and doesn’t stop until we do.
Why Loops Happen?
The model doesn’t have a reliable self-stopping mechanism. It can reason “I should stop now“, but:
The task is ambiguous about what “done“ means
A tool keeps returning partial results that look like they require another call
An error keeps occurring and the model decides each time that retrying is reasonable
The model loses track of what it has already tried, due to context compression
We must never rely on the model’s judgement to stop. We must build structural limits.
Hard Limits at Every Layer
@dataclass
class AgentBudget:
max_steps: int = 25 # Hard cap on loop iterations
max_tool_calls: int = 50 # Total tool invocations across all steps
max_tokens_input: int = 500_000 # Total tokens sent to the model
max_cost_usd: float = 2.00 # Dollar cap per session
timeout_seconds: int = 120 # Wall-clock time limit
class BudgetEnforcer:
def __init__(self, budget: AgentBudget):
self.budget = budget
self.steps = 0
self.tool_calls = 0
self.tokens_used = 0
self.cost_usd = 0.0
self.start_time = time.time()
def check(self) -> None:
"""Raises BudgetExceededError if any limit is hit."""
if self.steps >= self.budget.max_steps:
raise BudgetExceededError(f"Step limit reached ({self.budget.max_steps})")
if self.tool_calls >= self.budget.max_tool_calls:
raise BudgetExceededError(f"Tool call limit reached ({self.budget.max_tool_calls})")
if self.cost_usd >= self.budget.max_cost_usd:
raise BudgetExceededError(f"Cost limit reached (${self.budget.max_cost_usd:.2f})")
if time.time() - self.start_time >= self.budget.timeout_seconds:
raise BudgetExceededError(f"Timeout after {self.budget.timeout_seconds}s")We call enforcer.check() at the top of every loop iteration, before each tool call. When a limit is hit, the agent stops immediately and returns whatever partial result it has. This is not optional; it is the difference between a controlled failure and an uncontrolled one.
Loop Detection
Beyond hard limits, we should detect loops in progress before they exhaust the budget:
from collections import Counter
class LoopDetector:
def __init__(self, window: int = 6, threshold: int = 3):
self.recent_calls = deque(maxlen=window)
self.threshold = threshold # Same call N times in window = loop
def record(self, tool_name: str, args_hash: str) -> bool:
"""Returns True if a loop is detected."""
key = f"{tool_name}:{args_hash}"
self.recent_calls.append(key)
counts = Counter(self.recent_calls)
return counts[key] >= self.thresholdHash the tool name and its arguments. If the same (tool, args) pair appears three or more times in the last six calls, the agent is looping. We must interrupt the loop, inject the observation into context: “You have called search_web(‘AAPL stock price‘ three times and received the same result. Stop repeating this call and proceed with the information you have”.
This is more targeted than a hard stop limit - it catches loops early without prematurely cutting off long-running tasks.
Timeout and Budget Constraints in Practice
In order to make budgets useful, we need to communicate them to the model. An agent that doesn’t know it has 15 steps left will plan as if it has unlimited steps. But the agent who knows the budget will prioritize accordingly.
We should inject budget state into the system prompt dynamically:
def build_system_prompt(base_prompt: str, enforcer: BudgetEnforcer) -> str:
remaining_steps = enforcer.budget.max_steps - enforcer.steps
remaining_budget = enforcer.budget.max_cost_usd - enforcer.cost_usd
elapsed = time.time() - enforcer.start_time
remaining_time = enforcer.budget.timeout_seconds - elapsed
budget_context = f"""
Current session constraints:
- Steps remaining: {remaining_steps} of {enforcer.budget.max_steps}
- Budget remaining: ${remaining_budget:.2f}
- Time remaining: {remaining_time:.0f}s
If you cannot complete the full task within these constraints, complete as much as
possible and clearly state what was not completed and why.
"""
return base_prompt + budget_contextUpdating this at each step means the model’s final few steps are informed by its actual budget state. This produces dramatically better partial-completion behavior - the model starts wrapping up rather than starting new tool calls when it knows it’s running low.
Observability
Agent failures are multi-step, non-deterministic, and often emerge from interaction effects between steps. Standard application logging is insufficient.
Every production agent needs:
Step-level traces: Each iteration of the agent loop is a span. The span records: step number, model input tokens, model output, tool calls made, tool results, cost, latency. When an agent misbehaves, we need to replay its reasoning step by step.
Tool call audit log: Every tool call gets a record: timestamp, tool name, input args, result (or error), latency, retries. This is separate from the agent trace; we want to query "how often does fetch_document fail?" independently of the agent context.
Budget consumption tracking: Log budget state at each step. A cost spike is diagnosable if we can see which step made the expensive tool call; it's not diagnosable from the total session cost alone.
Tools like LangSmith, Langfuse, and Arize provide agent tracing out of the box. In case of building a custom tool, OpenTelemetry spans with structured attributes on each step are the right primitive.
Mental Model
An agent is not a smart chatbot. It is a control loop with an LLM as its decision-making component. Every principle from reliable systems engineering applies: hard limits on resource consumption, explicit error states, idempotent operations, observable execution, and graceful degradation.
The LLM is good at reasoning, planning, and adapting. It is not good at enforcing constraints on itself, detecting that it’s in a loop, managing token budgets, or knowing when to stop. Those are the structural properties of our system, not emergent behaviors from a clever prompt.
Therefore, we should build the structure first. The model handles the intelligence. Our infrastructure handles the reliability.
How did you find this article? Have you implemented reliability for your agent architecture and how. I want to hear it all!
Namaste!
— Anirudh



