A developer on r/AI_Agents described watching their coding agent burn $15 in API costs in under 10 minutes. The model called a file-search tool, got an error back, interpreted "no results" as "keep looking," and called it again. Forty turns later the session was dead and the account balance was not. No turn limit. No cost guard. No check on whether the tool was returning anything useful.
Tool-calling agents need exit conditions that don't depend on the model deciding it's done. Here are three guards you can add to any Node.js agent loop in under 50 lines.
Why agent loops run away
The failure mode follows a pattern. A tool returns an error or partial data. The model reads that as "task incomplete" and retries. Same tool, same result, again. Every turn appends another tool result to the history, so later turns cost more than earlier ones. The costs compound even as the agent goes nowhere.
Two things make this worse than typical API bugs. First, most LLM client libraries have no built-in turn limit; you own the exit condition. Second, the model is doing exactly what it should: persisting toward the goal. The problem is not model behavior, it's missing infrastructure around the loop.
Guard 1: max-turn limits
A turn counter is the simplest and most important guard. Set it below what you'd be comfortable seeing on an invoice:
async function runAgent({ messages, tools, systemPrompt, maxTurns = 20 }) {
let history = [...messages];
let turns = 0;
while (true) {
if (turns >= maxTurns) {
throw new Error(`Agent stopped: ${maxTurns}-turn limit reached.`);
}
turns++;
const data = await callLLM({ messages: history, tools, systemPrompt });
history.push({ role: 'assistant', content: data.content });
if (data.stop_reason === 'end_turn') {
const text = (data.content.find(b => b.type === 'text') || {}).text || '';
return { text, turns };
}
const toolResults = await executeTools(data.content.filter(b => b.type === 'tool_use'));
history.push({ role: 'user', content: toolResults });
}
}
Twenty turns is a reasonable default for most coding or research tasks. A debugging agent that needs more than 20 tool calls without resolving has probably hit a dead end. You want to know that at turn 20, not turn 200.
Guard 2: cost budget caps
Turn limits catch infinite loops. Cost caps catch expensive-but-finite ones: an agent that stops at turn 15 after ingesting a 60,000-token context per turn.
Track spend inside the loop and throw before the next LLM call, not after:
async function runAgent({ messages, tools, systemPrompt, maxTurns = 20, budgetUsd = 0.50 }) {
let history = [...messages];
let turns = 0;
let inputTokens = 0;
let outputTokens = 0;
// claude-sonnet-4-6: $3 input / $15 output per million tokens
const INPUT_COST = 3 / 1_000_000;
const OUTPUT_COST = 15 / 1_000_000;
while (true) {
if (turns >= maxTurns) throw new Error(`Turn limit ${maxTurns} reached.`);
turns++;
const data = await callLLM({ messages: history, tools, systemPrompt });
inputTokens += data.usage.input_tokens;
outputTokens += data.usage.output_tokens;
const spent = inputTokens * INPUT_COST + outputTokens * OUTPUT_COST;
if (spent > budgetUsd) {
throw new Error(`Agent stopped: $${spent.toFixed(4)} exceeded $${budgetUsd} budget at turn ${turns}.`);
}
history.push({ role: 'assistant', content: data.content });
if (data.stop_reason === 'end_turn') {
const text = (data.content.find(b => b.type === 'text') || {}).text || '';
return { text, turns, spentUsd: spent };
}
const toolResults = await executeTools(data.content.filter(b => b.type === 'tool_use'));
history.push({ role: 'user', content: toolResults });
}
}
Set budgetUsd to what you're willing to lose if the agent stalls. $0.50 is plenty runway for a coding assistant on typical tasks. A research agent reading long documents might need $1-2. The exact number matters less than naming it explicitly in the code.
For cost tracking: the LLMTest proxy returns usage.input_tokens and usage.output_tokens in the same response shape as the upstream API, so the math above works regardless of which model you route to.
Guard 3: return value validation
Turn limits and cost caps handle loop quantity. Return value validation handles loop quality.
When a tool returns null, an empty array, or a 50,000-character blob, and that goes back into the conversation unchanged, two things happen: the model often loops because the output looks like a partial result, and your context grows with data that was never useful. A sanitizer runs before you push anything back:
function sanitize(raw) {
if (raw == null) return '{"error":"tool returned no data"}';
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
if (str.length > 8_000) {
return str.slice(0, 8_000) + '\n[TRUNCATED: output capped at 8,000 chars]';
}
return str;
}
async function executeTools(toolUses) {
return Promise.all(
toolUses.map(async (call) => {
const raw = await dispatchTool(call.name, call.input);
return {
type: 'tool_result',
tool_use_id: call.id,
content: sanitize(raw),
};
})
);
}
The 8,000-character cap is conservative by design. Most tools (web search results, file reads, database queries) should return far less. If you're regularly hitting the cap, the tool implementation needs work, not a higher limit.
Returning an explicit error string instead of empty content matters: the model can read {"error":"tool returned no data"} and decide to stop or change approach, rather than silently inferring from nothing.
Putting it together
All three guards compose into a single wrapper. Here is the full version in around 50 lines, with callLLM and dispatchTool injected so it stays testable without a real API:
// agent.js: production agent loop with turn, cost, and output guards
const INPUT_COST = 3 / 1_000_000; // claude-sonnet-4-6 input $/token
const OUTPUT_COST = 15 / 1_000_000; // claude-sonnet-4-6 output $/token
export async function runAgent({
messages,
tools,
systemPrompt,
maxTurns = 20,
budgetUsd = 0.50,
callLLM,
dispatchTool,
}) {
let history = [...messages];
let turns = 0;
let inputTokens = 0;
let outputTokens = 0;
while (true) {
if (turns >= maxTurns) throw new Error(`Turn limit ${maxTurns} reached.`);
turns++;
const data = await callLLM({ messages: history, tools, systemPrompt });
inputTokens += data.usage.input_tokens;
outputTokens += data.usage.output_tokens;
const spentUsd = inputTokens * INPUT_COST + outputTokens * OUTPUT_COST;
if (spentUsd > budgetUsd) {
throw new Error(`Budget $${spentUsd.toFixed(4)} exceeded $${budgetUsd} at turn ${turns}.`);
}
history.push({ role: 'assistant', content: data.content });
if (data.stop_reason === 'end_turn') {
const text = (data.content.find(b => b.type === 'text') || {}).text || '';
return { text, turns, spentUsd };
}
const toolUses = data.content.filter(b => b.type === 'tool_use');
const toolResults = await Promise.all(
toolUses.map(async (call) => {
const raw = await dispatchTool(call.name, call.input);
return { type: 'tool_result', tool_use_id: call.id, content: sanitize(raw) };
})
);
history.push({ role: 'user', content: toolResults });
}
}
function sanitize(raw) {
if (raw == null) return '{"error":"tool returned no data"}';
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
return str.length > 8_000 ? str.slice(0, 8_000) + '\n[TRUNCATED]' : str;
}
Starting points for common use cases, based on task scope:
- Coding assistants:
maxTurns: 15,budgetUsd: 0.30. A bug fix that needs more than 15 tool calls has probably gone sideways. - Research agents reading documents:
maxTurns: 30,budgetUsd: 1.00. Documents are long, but 30 turns is still a ceiling. - Data extraction pipelines:
maxTurns: 10,budgetUsd: 0.20. Each record should require very few turns.
Log both normal completions and limit-triggered stops. If Turn limit reached shows up in production logs more than occasionally, the tool output quality or the prompt design needs work, not a higher limit. The guard revealing a problem is better than a higher limit hiding it. To catch those prompt-design issues before they reach the agent in production, golden-set CI evals in Node.js let you verify that each updated system prompt still produces the expected outputs on every PR.
For handling the API-level issues these agents create downstream (rate limits, circuit breakers, provider failover), the four production patterns for LLM rate limits covers that layer. If you're routing agents across providers with per-session spend limits enforced at the infrastructure level, LiteLLM's budget routing patterns add that as a separate layer above what this wrapper handles.
You can point the callLLM function at the LLMTest proxy to route across Anthropic, OpenAI, and others from a single endpoint with per-call cost logging included. Start free here.