Topic 43

Loops, Thrash, and Repetition

Repetition

A buyer asked where their garden bench was. The carrier had never scanned the parcel, so track_parcel returned "no scans recorded for this tracking number" — a correct, complete and entirely unhelpful answer. The agent called it again. Then seven more times, across twelve turns, forty-one seconds and $0.34 of model calls, before the turn limit stopped the run and the buyer received a holding message. Nothing errored. The tool worked perfectly on all nine calls.

Repetition is the most common control-flow failure, it is the cheapest thing in this chapter to detect — a hash and a counter — and the detection is not the interesting part. What matters is what happens on the second identical call, because the difference between one wasted call and twelve is whether the loop changes the situation or lets the model see the same context one more time and reach, reasonably, the same conclusion.

Three Shapes of Repetition

Repetition shows up in three forms, and a check that only compares each call to the one before it will happily watch the other two run to the turn limit.

ShapeWhat it looks likeHow it is detected
Identical callThe same tool with the same arguments, twice or nine timesA hash of tool name plus normalized arguments, counted per run
Alternationget_order, track_parcel, get_order, track_parcel — each result sending it back to the otherA two-step cycle in the last four call keys, so the observation can name the pair; with stable arguments the per-run counter has already fired on the third call
Re-asking the customerThe reply asks for the order number the buyer gave in their first messageThe requested field already has a value in the run's structured state

All three are detectable from the transcript in plain code. None of them needs a model call, a similarity score or a judgement about meaning — the first two come from the tool-call log and the third from comparing what the draft asks for against what Chapter 6's task state already holds. The third shape is the one that damages the relationship rather than the budget: a customer who is asked twice for an order number they have already supplied concludes, correctly, that nobody read their message.

Why It Happens

Four causes cover nearly all of it. A tool result that answers the call without resolving the question — "no scans recorded" is true and settles nothing. An error that reads as retryable, either because it carries a wait_and_retry marker on a permanent condition or because a timeout was phrased as a failure (Chapter 3). A capability that does not exist, so the model keeps working the tool nearest to the gap: nothing in Sundry's surface answers "was this parcel ever handed to the carrier", so it asks the tracking tool, repeatedly. And a constraint it cannot satisfy — a $210 remedy under a $150 ceiling — which sends it back to search_policy hoping for a clause that says something else.

None of the four is the model behaving badly. Each is the situation being unchanged. The model is a function from a context to one more message; hand it the same context plus one identical tool result and it will reach the same conclusion, which is exactly what a function is supposed to do. That single observation dictates the entire fix in this topic: to stop the repetition you have to change what is in front of it, not ask it more firmly.

One tracking number with no scans: nine calls, twelve turns, 41 seconds and $0.34 before the guard existed
First call"no scans recorded" · settles nothing
GUARD FIRESSecond call, same keycached result returned, nothing dispatched
Observation injectedwhat repeated · the options that remain
STOPPED AT THREEThird occurrenceends stuck · escalated with a summary

Detection

Build a key from the tool name and its arguments, count the keys per run, and check before dispatching rather than after. Normalization is what makes the check work in practice, because two calls that differ by a trailing space or a lower-case order id are the same call to everyone except a byte comparison.

The whole repetition guard: normalize, key, count, and a cycle check
CASE_FOLD = {"order_id", "item_id", "tracking_number", "seller_id"}

def normalize(field, value):
    if isinstance(value, str):
        s = " ".join(value.split())          # collapse whitespace
        return s.upper() if field in CASE_FOLD else s.lower()
    if isinstance(value, float):
        return round(value, 2)
    return value

def call_key(name, args):
    norm = {k: normalize(k, v) for k, v in sorted(args.items())}
    return sha256(f"{name}:{json.dumps(norm, separators=(',', ':'))}")

key = call_key(call.name, call.args)          # before dispatch
run.seen[key] += 1
if run.seen[key] == 2:  return observe(run, repeated=call, cached=run.results[key])
if run.seen[key] >= 3:  return end(run, "stuck")

last = run.call_keys[-4:]                     # the alternating shape
if len(last) == 4 and last[0] == last[2] and last[1] == last[3]:
    return observe(run, cycle=last[-2:])

In words: collapse whitespace on every string argument, upper-case the four fields that are identifiers in systems that do not care about case, round money to the cent, sort the keys, and hash the lot with the tool name. Count that key in the run's own state and check it before the call goes out, so the second attempt never reaches the carrier at all. Two occurrences trigger an intervention; three end the run. The last three lines look at the previous four keys and ask whether they form a two-step cycle. With stable arguments the counter has already fired on the third call, because it counts across the whole run rather than comparing neighbours; what the cycle check adds is the shape, so the observation injected into the context can say "you are alternating between these two" rather than "you repeated this one".

Note what normalization is and is not. Lower-casing free text is right for comparing two search_policy queries and wrong for anything else, so the normalized form exists only inside the guard and never reaches the tool. No-progress detection is the sibling check and it works on state rather than calls: hash the run's structured record — orders seen, passages retrieved, subtask statuses — and if three consecutive turns leave that hash unchanged, the run is going nowhere regardless of how busy the transcript looks. The transcript always grows; the state is what has to grow. At Sundry the two guards together fire on about 2% of runs and save an average of six turns each.

What to Do on Detection

Three moves, in order. Do not execute the duplicate — return the cached first result, which costs nothing and spares the carrier. Add a system observation to the context that states what was repeated and what options remain. And if the repetition survives that, exit: end the run stuck and escalate with a summary, rather than spending the rest of the budget finding out.

The observation injected on the second identical call, generated from run state
{"role": "system",
 "content": """track_parcel has been called twice with tracking number
JD0002261150 and returned the same result both times: no scans recorded.
A third call will return the same thing.

Options that remain:
  - answer from the order record's last known status (already in context)
  - ask the seller whether the parcel was handed over (message_seller)
  - escalate_to_human

Choose one of these."""}

Read that as engineering rather than phrasing. It names the call and the result, it states plainly that repeating will not change the answer, and it lists the options that are still open. The third part does most of the work: an agent hammering one tool is frequently an agent that has stopped considering the others, and a list restores them. None of this text is hand-written per tool — the calls come from the run log, the result from the cache, and the options from the tool list minus whatever has been exhausted.

The version that does not work is an instruction with no new information: "do not repeat yourself" appended to a context that otherwise says exactly what it said last turn. The model repeats because the situation repeats, and a scolding sentence usually buys an apology followed by the same call. Sundry measured both. The observation with the options list ended the repetition on 78% of the runs where it fired; the bare instruction, tried for a week, managed 31%.

The Missing-Capability Case

Every repeat event should be logged with the tool that caused it, because the histogram is a product backlog that nobody has to write. Four weeks of Sundry's guard produced 214 repeat events, and they were not spread evenly.

ToolRepeat eventsWhat the repetition was actually saying
track_parcel131 (61%)One result string covers "never scanned" and "unknown tracking number", and the ticket turns on which it is
search_policy47 (22%)No way to ask for one seller's supplement directly, so the agent rephrases and searches again
get_order21 (10%)The response omits the per-charge breakdown that duplicate-delivery tickets need
Everything else15 (7%)Scattered across five tools, no pattern worth acting on

Three tool-design defects, found by a counter rather than by a design review, and each one is a description or a return shape rather than a model problem. Splitting the tracking result into two distinct outcomes removed 131 repeat events a month and, as a side effect, a category of confident wrong answers about parcels that were never dispatched. That is the pattern worth keeping: repetition is the cheapest product feedback an agent produces, and it is thrown away by every team that treats the guard purely as a safety net.

Common Mistakes
  • Relying on the turn limit as the only defence — the customer waits twelve turns for a holding message and the bill is paid in full, when the second identical call was detectable in one line.
  • Detecting repetition and changing nothing in the context — the model repeats because the situation repeats, so a warning that adds no information buys an apology and the same call again.
  • Comparing arguments byte-for-byte — a trailing space or a lower-case order id defeats the check completely, and the run thrashes with the guard sitting right there watching it.
  • Ignoring which tools repeat — the histogram points straight at the weakest tool descriptions and return shapes, and a team that only counts total repeats throws that away every month.
Best Practices
  • Detect identical calls and no-progress inside the loop, before dispatch, and act on the second occurrence rather than the ninth.
  • Respond by adding information to the context — what repeated, what came back, which options remain — and escalate if it continues; never continue silently.
  • Normalize arguments before hashing them: collapse whitespace, case-fold identifiers, round money to the cent, and sort the keys.
  • Track repeated calls per tool as a metric and treat a spike as a tool-design bug, not as a model problem.
Comparable toolsCircuit breakers the same guard, one dependency at a timeTemporal heartbeats and timeouts on activities that stop progressingLangGraph recursion limits that stop a graph without diagnosing itFramework iteration caps detect nothing and merely stopLangfuse repeated-call counts visible per tool in traces

Knowledge Check

An agent alternates get_order, track_parcel, get_order, track_parcel with the same arguments every time. Which guard fires first, and on which call?

  • The per-run counter, on the third call, because the second get_order is the second occurrence of its key
  • The cycle check, on the fourth call, because that is the first moment the A-B-A-B shape exists
  • Neither, because no key occurs twice in a row and both guards compare each call to the previous one
  • The no-progress check, because two read-only calls add nothing to the run's structured state

The guard fires on a second identical track_parcel call. What should the loop do first?

  • Return the cached result and add an observation naming the repetition and the remaining options
  • End the run immediately as stuck and escalate, since a repeat means the agent is lost
  • Append an instruction telling the model not to call the same tool twice in a row
  • Execute the call again but with a longer timeout, in case the first result was incomplete

Why does the guard normalize arguments before hashing them?

  • Trivial formatting differences would otherwise produce two keys for one call
  • Hashing raw argument dictionaries is too slow to run before every dispatch
  • Tools need the normalized form anyway, so the guard does the cleaning for them
  • Similar calls with different arguments should also be treated as repetitions

Sixty-one per cent of a month's repeat events came from one tool. What is the right reading of that number?

  • That tool's description or return shape has a defect the model keeps working around
  • That tool is simply the most-called one of the nine, so it accumulates the most events
  • The guard's repeat threshold is set too low for that particular tool and should be raised
  • The model has a preference for that tool and needs a prompt instruction against it

You got correct