Your agent doesn't burn tokens because the model is expensive. It burns tokens because it loops. I watched a prototype eat a ten-million-token allowance in a single night, and the root cause was a retry sitting in the wrong layer. The fix took four lines. Finding it took a trace log and a hard look at what the agent was actually doing at 3 a.m.
The setup was ordinary. A small agent watched a webhook, summarized incoming payloads, and posted the summary to a channel. It ran on a free server, which is the right place for a prototype: no billing alarm, no SSH key ceremony, just a process that stays alive while you sleep. The model access came from MonkeyCode's free tier, and the server was their free option too. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The allowance was ten million tokens, which sounds like a lot until you multiply it by a loop.
The first sign of trouble was the morning report. The agent had processed four webhooks overnight, and the allowance was gone. Four jobs, ten million tokens. That is not a pricing problem. That is a geometry problem — each iteration was bigger than the last, and the growth was compounding.
Here is the mechanism. The agent's main loop called a model, got an answer, and sometimes called a tool. The tool call failed. The code caught the failure and retried — but it retried the whole loop, not the tool call. Each retry re-sent the entire conversation history, including the previous failed attempt. The next iteration then included the retry's output in its own history. The context grew by the size of the previous response, every time, and the token cost grew with it. What started as a 2,000-token call became a 200,000-token call by the fourth retry. The agent was not summarizing webhooks anymore. It was summarizing its own failure, recursively.
This is the failure mode that free tiers make visible. A paid API bills you per token, so a runaway loop is a surprise on next month's invoice. A free allowance is a hard ceiling, so the loop announces itself immediately. The ceiling is not a limitation; it is a diagnostic instrument.
The first step in the fix is observability. You cannot debug what you cannot see, so wrap the model call with a tracer that logs tokens and a fingerprint of the recent conversation.
# trace_agent.py — log every model call with a token estimate and turn fingerprint
import hashlib, json, os, time
from functools import wraps
LOG_PATH = os.environ.get("AGENT_TRACE", "trace.jsonl")
def estimate_tokens(text: str) -> int:
# crude heuristic: ~4 chars per token. Use the provider's usage API for exact numbers.
return len(text) // 4
def trace_calls(fn):
@wraps(fn)
def wrapper(messages, **kwargs):
start = time.perf_counter()
result = fn(messages, **kwargs) # assumes fn returns a dict-like object with "content"
elapsed = time.perf_counter() - start
tokens_in = sum(estimate_tokens(m.get("content", "")) for m in messages)
tokens_out = estimate_tokens(result.get("content", ""))
# fingerprint the last two turns so repeated states are easy to spot
recent = json.dumps(messages[-2:], sort_keys=True, default=str)
turn_hash = hashlib.sha256(recent.encode()).hexdigest()[:12]
with open(LOG_PATH, "a") as fh:
fh.write(json.dumps({
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"context_len": len(messages),
"elapsed_ms": round(elapsed * 1000, 1),
"turn_hash": turn_hash,
}) + "\n")
return result
return wrapper
Decorate your model call with @trace_calls, run the agent overnight, and the log will tell you the story. The column that matters is context_len. If it climbs on every row, you have context amplification. The column that confirms it is turn_hash: when the same hash appears twice, the agent is re-reading its own output.
A loop detector is a ten-line script.
# find_loop.py — scan a trace for repeated turns
import json, sys
from collections import Counter
hashes = []
for line in open(sys.argv[1]):
row = json.loads(line)
hashes.append(row["turn_hash"])
repeats = [h for h, count in Counter(hashes).items() if count >= 2]
if repeats:
print(f"Loop detected: turn hash {repeats[0]} repeated {hashes.count(repeats[0])} times")
else:
print("No repeated turns found")
Run it with python find_loop.py trace.jsonl. In the failing agent, it printed one hash, repeated five times, with context_len climbing from 12 to 38 across the repeats. That is the whole diagnosis in two lines.
The fix is to move the retry down a layer. Retry the transport, not the loop. A failed tool call is often a transient network error; a failed agent turn is a logic error, and retrying it only amplifies the context. The corrected code retries the HTTP request with backoff and lets the failure propagate to the loop, where it is handled once.
# retry_transport.py — retry the network call, never the whole agent turn
import time
def call_with_retry(client, messages, model, retries=3, backoff=2.0):
last_error = None
for attempt in range(retries):
try:
return client.chat.completions.create(model=model, messages=messages)
except Exception as exc: # network errors, 429s, 5xx
last_error = exc
time.sleep(backoff ** attempt)
raise last_error
Two more guardrails belong next to it. Cap the context: if context_len exceeds a threshold, summarize the history instead of appending to it. And add a circuit breaker: if the same turn hash appears twice, stop and write the trace to a file. A loop that stops is a bug you can fix; a loop that runs all night is a bill.
The deeper lesson is about where retries belong in an agent. An agent is two systems stacked: a control loop that decides what to do, and a transport layer that executes each step. Failures in the transport layer are expected and retryable. Failures in the control loop are not — they are bugs, and retrying them makes them worse. The moment you treat a logic error like a network error, you build a machine that repeats its mistakes with growing confidence and a growing context window.
This is why the free tier is the right place to learn the habit. The ceiling forces the failure to surface fast, while the stakes are still zero. On a paid API, the same bug would have cost real money before anyone noticed. On a self-hosted box, it would have cost a night of GPU time and a confusing dashboard in the morning. The free server makes the leak visible because there is nowhere to hide the overage.
Who should not use this approach? If your agent handles regulated data, a shared free server is already the wrong answer, and no tracing decorator fixes that. If your workload is steady and high-volume, you are past the free tier by definition. But if you are prototyping an agent and want to see its failure modes before they become expensive, this is the cheapest observability setup I know.
The trace decorator is a starting point, not a finished observability stack. The token estimate is a heuristic; trust the provider's usage API for real numbers. The loop detector only catches exact repeats; a loop that mutates a timestamp each turn will evade it. Build on it, but build the habit first.
If you want to see a leak before it sees you, MonkeyCode's free tier is a cheap place to practice. Wrap your model call, let it run one night, and read the trace in the morning. The log will tell you things about your agent that you did not ask it to say.
MonkeyCode provides free models that can run this workflow.
Top comments (0)