Implementing agentic loops with retry and reflection
Agentic systems fail in ways that synchronous API calls do not. A regular API call either returns or throws. An agent loop can drift, oscillate, hallucinate tool calls, exhaust budgets, or reach locally optimal dead ends with no clear error signal. Building a reliable agentic loop means designing for these failure modes explicitly, not hoping they do not occur.
This article covers the architecture of production agentic loops: the core loop structure, retry strategies with backoff, reflection checkpoints, termination conditions, and the observability layer that lets you understand what happened when things go wrong.
The anatomy of an agentic loop
An agentic loop is a control structure that alternates between model inference (deciding what to do) and tool execution (doing it), continuing until a completion condition is met or a safety limit is reached.
type LoopResult =
| { status: "success"; output: string; steps: number }
| { status: "max_steps_exceeded"; partialOutput: string; steps: number }
| { status: "tool_error"; error: string; steps: number; lastTool: string }
| { status: "model_gave_up"; reason: string; steps: number };
interface AgentConfig {
model: string;
maxSteps: number;
tools: Anthropic.Tool[];
systemPrompt: string;
}
The basic loop:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function runAgentLoop(
initialMessage: string,
config: AgentConfig
): Promise<LoopResult> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: initialMessage },
];
let steps = 0;
while (steps < config.maxSteps) {
steps++;
const response = await client.messages.create({
model: config.model,
max_tokens: 4096,
system: config.systemPrompt,
tools: config.tools,
messages,
});
// Agent produced a final answer
if (response.stop_reason === "end_turn") {
const text = response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("\n");
return { status: "success", output: text, steps };
}
// Agent requested tool calls
if (response.stop_reason === "tool_use") {
// Add assistant turn to history
messages.push({ role: "assistant", content: response.content });
// Execute all tool calls in this turn
const toolResults = await executeToolCalls(
response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")
);
// Add tool results to history
messages.push({
role: "user",
content: toolResults.map((r) => ({
type: "tool_result" as const,
tool_use_id: r.toolUseId,
content: r.result,
is_error: r.isError,
})),
});
// Check if any critical tool failed
const criticalFailure = toolResults.find((r) => r.isError && r.isCritical);
if (criticalFailure) {
return {
status: "tool_error",
error: criticalFailure.result,
steps,
lastTool: criticalFailure.toolName,
};
}
continue;
}
// Unexpected stop reason (max_tokens during generation, etc.)
break;
}
return { status: "max_steps_exceeded", partialOutput: "", steps };
}
Tool execution with retry
Individual tool calls need their own retry logic. Network errors, rate limits, and transient failures are normal. The loop itself should not restart — only the failed tool call should retry.
interface ToolCallResult {
toolUseId: string;
toolName: string;
result: string;
isError: boolean;
isCritical: boolean;
attemptsTaken: number;
}
async function executeToolCall(
toolCall: Anthropic.ToolUseBlock,
toolImplementations: Map<string, (input: unknown) => Promise<string>>,
maxAttempts = 3
): Promise<ToolCallResult> {
const impl = toolImplementations.get(toolCall.name);
if (!impl) {
return {
toolUseId: toolCall.id,
toolName: toolCall.name,
result: `Tool "${toolCall.name}" is not available`,
isError: true,
isCritical: false, // Unknown tool: let model recover
attemptsTaken: 0,
};
}
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await impl(toolCall.input);
return {
toolUseId: toolCall.id,
toolName: toolCall.name,
result,
isError: false,
isCritical: false,
attemptsTaken: attempt,
};
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// Don't retry if the error is definitely not transient
if (isNonRetryableError(lastError)) {
break;
}
if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s
await sleep(1000 * Math.pow(2, attempt - 1));
}
}
}
return {
toolUseId: toolCall.id,
toolName: toolCall.name,
result: `Tool execution failed after ${maxAttempts} attempts: ${lastError?.message}`,
isError: true,
isCritical: isWriteOperation(toolCall.name), // Writes failing = critical
attemptsTaken: maxAttempts,
};
}
function isNonRetryableError(err: Error): boolean {
// 4xx errors (except 429) are not retryable
if (err.message.includes("400") || err.message.includes("403") || err.message.includes("404")) {
return true;
}
// Validation errors
if (err.message.toLowerCase().includes("invalid")) {
return true;
}
return false;
}
function isWriteOperation(toolName: string): boolean {
const writeTools = ["write_file", "send_email", "create_record", "delete_record", "execute_sql"];
return writeTools.includes(toolName);
}
async function executeToolCalls(
toolCalls: Anthropic.ToolUseBlock[],
implementations: Map<string, (input: unknown) => Promise<string>>
): Promise<ToolCallResult[]> {
// Execute tool calls in parallel when they are independent
return Promise.all(
toolCalls.map((call) => executeToolCall(call, implementations))
);
}
Reflection checkpoints
Reflection is the mechanism by which an agent evaluates its own progress and course-corrects. Without explicit reflection, agents tend to continue confidently down wrong paths.
A reflection checkpoint is a model call where you ask the agent to evaluate what it has done so far against the original goal:
interface ReflectionResult {
onTrack: boolean;
confidence: number;
issues: string[];
recommendation: "continue" | "revise_approach" | "abort";
revisedPlan?: string;
}
async function reflectOnProgress(
originalGoal: string,
stepsCompleted: AgentStep[],
currentObservations: string[]
): Promise<ReflectionResult> {
const stepSummary = stepsCompleted
.map((s, i) => `Step ${i + 1}: ${s.toolName}(${JSON.stringify(s.input)}) → ${s.output.slice(0, 200)}`)
.join("\n");
const response = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 512,
messages: [
{
role: "user",
content: `
You are evaluating an agent's progress toward a goal.
## Original goal
${originalGoal}
## Steps taken so far
${stepSummary}
## Current observations
${currentObservations.join("\n")}
Evaluate whether the agent is on track. Respond with a JSON object:
{
"onTrack": true|false,
"confidence": 0.0-1.0,
"issues": ["list of problems observed"],
"recommendation": "continue"|"revise_approach"|"abort",
"revisedPlan": "optional: what to do differently"
}`,
},
],
});
const text = response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("Reflection response did not contain JSON");
return JSON.parse(jsonMatch[0]) as ReflectionResult;
}
Insert reflection checkpoints at fixed intervals (every N steps) and at semantic milestones (after completing a phase of work):
async function runAgentLoopWithReflection(
initialMessage: string,
config: AgentConfig & { reflectionInterval: number }
): Promise<LoopResult> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: initialMessage },
];
const completedSteps: AgentStep[] = [];
let steps = 0;
while (steps < config.maxSteps) {
steps++;
// Reflection checkpoint
if (steps > 0 && steps % config.reflectionInterval === 0) {
const reflection = await reflectOnProgress(
initialMessage,
completedSteps,
extractCurrentObservations(messages)
);
if (reflection.recommendation === "abort") {
return {
status: "model_gave_up",
reason: `Reflection aborted: ${reflection.issues.join(", ")}`,
steps,
};
}
if (reflection.recommendation === "revise_approach" && reflection.revisedPlan) {
// Inject revised guidance into conversation
messages.push({
role: "user",
content: `[System note] Mid-task review suggests: ${reflection.revisedPlan}. Please adjust your approach accordingly.`,
});
}
}
// ... rest of loop as before
}
return { status: "max_steps_exceeded", partialOutput: "", steps };
}
Reflection is expensive (an extra model call every N steps) but pays for itself by catching wrong-path execution early. For tasks where 10 wasted steps cost more than one reflection call, the trade-off is clear.
Detecting and breaking loops
Agents can get stuck in cycles: calling the same tool repeatedly with the same arguments, alternating between two tools that each fail, or restating the problem differently each turn without making progress.
class LoopDetector {
private recentToolCalls: Array<{ name: string; inputHash: string }> = [];
private windowSize = 6; // check last 6 tool calls
recordToolCall(name: string, input: unknown): void {
const inputHash = JSON.stringify(input);
this.recentToolCalls.push({ name, inputHash });
if (this.recentToolCalls.length > this.windowSize) {
this.recentToolCalls.shift();
}
}
isLooping(): { looping: boolean; pattern?: string } {
if (this.recentToolCalls.length < 4) return { looping: false };
// Check for exact repetition in last N calls
const recent = this.recentToolCalls.slice(-4);
const unique = new Set(recent.map((c) => `${c.name}:${c.inputHash}`));
if (unique.size <= 2 && recent.length >= 4) {
return {
looping: true,
pattern: `Repeating: ${[...unique].join(" | ")}`,
};
}
// Check for same tool called N times with identical input
const lastCall = recent[recent.length - 1];
const sameCallCount = recent.filter(
(c) => c.name === lastCall.name && c.inputHash === lastCall.inputHash
).length;
if (sameCallCount >= 3) {
return {
looping: true,
pattern: `${lastCall.name} called ${sameCallCount} times with identical input`,
};
}
return { looping: false };
}
}
When a loop is detected, inject a corrective message rather than immediately aborting:
const loopDetector = new LoopDetector();
// Inside the main loop, after recording tool calls:
const loopStatus = loopDetector.isLooping();
if (loopStatus.looping) {
messages.push({
role: "user",
content: `[System warning] You appear to be in a loop: ${loopStatus.pattern}.
The current approach is not working. Please try a fundamentally different strategy
or explain why the task cannot be completed.`,
});
}
Budget and timeout controls
Agents need hard limits that cannot be reasoned around:
interface AgentBudget {
maxSteps: number;
maxTokensTotal: number;
maxWallClockMs: number;
maxCostUsd: number;
}
class BudgetTracker {
private tokensUsed = 0;
private costAccrued = 0;
private startTime = Date.now();
recordUsage(inputTokens: number, outputTokens: number, model: string): void {
this.tokensUsed += inputTokens + outputTokens;
// Approximate cost (update with current pricing)
const inputCost = (inputTokens / 1_000_000) * 3.0;
const outputCost = (outputTokens / 1_000_000) * 15.0;
this.costAccrued += inputCost + outputCost;
}
checkBudget(budget: AgentBudget): { exceeded: boolean; reason?: string } {
if (this.tokensUsed > budget.maxTokensTotal) {
return { exceeded: true, reason: `Token budget exceeded: ${this.tokensUsed}` };
}
if (this.costAccrued > budget.maxCostUsd) {
return { exceeded: true, reason: `Cost budget exceeded: $${this.costAccrued.toFixed(4)}` };
}
if (Date.now() - this.startTime > budget.maxWallClockMs) {
return { exceeded: true, reason: `Time budget exceeded: ${Date.now() - this.startTime}ms` };
}
return { exceeded: false };
}
summary() {
return {
tokensUsed: this.tokensUsed,
costUsd: this.costAccrued,
elapsedMs: Date.now() - this.startTime,
};
}
}
Cost-based termination is especially important in production: a loop that runs 10x longer than expected due to a bad tool response costs 10x more, not just more time.
Structured logging and observability
A running agent is opaque unless you log its state at each step in a structured way:
interface AgentStepLog {
runId: string;
step: number;
timestamp: string;
type: "tool_call" | "tool_result" | "model_response" | "reflection" | "termination";
data: {
toolName?: string;
toolInput?: unknown;
toolOutput?: string;
toolError?: boolean;
modelStopReason?: string;
tokensUsed?: number;
reflectionResult?: ReflectionResult;
terminationStatus?: string;
};
}
class AgentLogger {
private logs: AgentStepLog[] = [];
private runId: string;
constructor(runId: string) {
this.runId = runId;
}
log(step: number, type: AgentStepLog["type"], data: AgentStepLog["data"]) {
const entry: AgentStepLog = {
runId: this.runId,
step,
timestamp: new Date().toISOString(),
type,
data,
};
this.logs.push(entry);
// Ship to your observability platform
console.log(JSON.stringify(entry));
}
getTrace(): AgentStepLog[] {
return this.logs;
}
summarise(): string {
const toolCalls = this.logs.filter((l) => l.type === "tool_call");
const errors = this.logs.filter((l) => l.data.toolError);
const reflections = this.logs.filter((l) => l.type === "reflection");
return (
`Run ${this.runId}: ${toolCalls.length} tool calls, ` +
`${errors.length} errors, ${reflections.length} reflections`
);
}
}
Ship these logs to your existing observability stack. The structured format makes it easy to:
- Query average steps to completion by task type
- Identify which tools fail most often
- Find the step where failed runs first went wrong
- Compare loop detection trigger rates before and after prompt changes
Composing agents hierarchically
For complex tasks, a single flat loop is insufficient. A planner agent decomposes the task; executor agents carry out subtasks; a verifier agent checks the results. Each sub-agent has its own budget and retry logic:
async function runPlannerExecutorLoop(
goal: string,
availableAgents: Map<string, (task: string) => Promise<LoopResult>>
): Promise<LoopResult> {
// Step 1: Planner decomposes into subtasks
const planResponse = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: [
{
role: "user",
content: `Decompose this goal into 3-7 concrete subtasks that can be executed sequentially.
Goal: ${goal}
Respond with a JSON array of subtask objects: [{"task": "...", "agent": "researcher|writer|analyst"}]`,
},
],
});
const planText = planResponse.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
const jsonMatch = planText.match(/\[[\s\S]*\]/);
if (!jsonMatch) throw new Error("Planner did not return valid JSON");
const plan: Array<{ task: string; agent: string }> = JSON.parse(jsonMatch[0]);
// Step 2: Execute each subtask with appropriate agent
const results: string[] = [];
for (const subtask of plan) {
const agentFn = availableAgents.get(subtask.agent);
if (!agentFn) {
results.push(`[${subtask.agent} not available] Skipped: ${subtask.task}`);
continue;
}
const result = await agentFn(subtask.task);
if (result.status === "success") {
results.push(result.output);
} else {
// One subtask failure does not abort the whole plan
results.push(`[Failed: ${result.status}] ${subtask.task}`);
}
}
// Step 3: Synthesise results
const synthResponse = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [
{
role: "user",
content: `Synthesise these subtask results into a final answer for the goal: "${goal}"\n\n${results.join("\n\n---\n\n")}`,
},
],
});
const output = synthResponse.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return { status: "success", output, steps: plan.length + 2 };
}
Each layer of the hierarchy provides a natural checkpoint for budget management and failure isolation. A failed executor subtask does not collapse the entire run.
Designing prompts for reliable termination
Agents that do not terminate cleanly are costly and unpredictable. The system prompt is the primary control surface:
const AGENT_SYSTEM_PROMPT = `
You are a research agent. Your task is to answer the user's question using the available tools.
## Termination criteria
- Stop when you have enough information to answer confidently
- Stop when you have attempted 3 different approaches without success
- Do NOT keep searching for a perfect source when a good enough one exists
- If you cannot find the answer after reasonable effort, say so clearly
## Output discipline
- When you are done, output ONLY the answer to the original question
- Do not summarise your research process
- Do not list the sources you checked unless specifically asked
## Error handling
- If a tool returns an error, try once more with different parameters
- If it fails twice, move on to a different approach
- Never call the same tool with identical parameters more than twice
## What NOT to do
- Do not loop through the same data sources repeatedly
- Do not ask the user for clarification — infer intent and proceed
- Do not generate next steps or recommendations unless asked
`;
The clearest agentic failure mode is an agent that does not know when to stop. Explicit termination criteria in the system prompt — especially negative examples ("do NOT...") — significantly reduce runaway loops.
Building reliable agentic loops is fundamentally about designing failure paths as carefully as the happy path. The structures above — explicit retry, reflection, loop detection, budget controls, and structured logging — transform an agentic system from something that works in demos into something that runs in production.