JJoeven

Curriculum/Agent Architectures

Detect Tool Thrash

The same tool with the same args three times is a bug. Stop, do not pay for a loop inside the loop.

advanced19 min22 / 24

Thrash is when the agent calls search with the same query again and again, or flips between two tools with no new observation.

Detect it in the loop. This is cheaper than a smarter model. Repeated identical calls are a stop, not a personality quirk. If you wait for the step budget, you still paid for the duplicates. Thrash is an early, specific stop: cannot: tool thrash.

Same search, three times
1Search 11Search 21Search 3

Three identical calls stop the run. Do not pay for a fourth.

Same search, three times

Hash (name, stable_args). If the same hash hits N times (three is a decent default), stop. Identical args after a failed parse still count if you executed or attempted the same call. If you did not execute, counting parse-fail retries as thrash is optional — they should already hit the parse-repair cap. Do not leave a hole where execute-fail retries are free.

Same args, same name

Canonicalize args: sort keys, maybe drop noise fields, JSON dump. {"q": "oom"} and the same dict built in reverse hash the same. Unstable extra keys (timestamps the model invents) can hide thrash — strip known junk or include them so extras still count as different if they should. Prefer stripping junk the schema does not allow (parser).

Three identical searches stop. Mixed queries do not. That is the live box.

Ping-pong

A slightly richer detector counts an alternating pair: search A, search B, search A, search B with no new information. You can hash a window of two names. This lesson’s box is the identical-call detector. Ship that first. Ping-pong is a sequel. Both belong in traces as stop reasons.

Why it happens

Bad assembler: the last obs was truncated, so the model searches again. Bad memory: amnesia. Bad prompt: “always search first.” Bad tools: empty hits, model retries the same q. Circuit breaker open and the model retries anyway — the executor should return circuit_open and thrash should still count.

Fix the cause, but stop the run now. Do not hope the fourth search is lucky. Do not finish with a guessed answer to escape thrash. That is a lie, same as inventing success at the step cap. Return cannot: tool thrash or handoff with that why.

Pair with breakers and budgets

Breakers stop a flaky host. Thrash stops a healthy host used stupidly. Budgets stop everything eventually. You want all three. Thrash N should be less than max steps so it can fire first.

Retries: a timeout retry with the same args might be allowed once (error recovery). Count it. The third identical is thrash even if the first two were timeouts. Tune N if one retry is policy.

Log it as itself

Do not log thrash as generic budget. Evals later will want the count. Operators want to grep thrash. Put the key and n on the row.

Canonical keys, ping-pong, and empty observations

Canonical keys must ignore insertion order and, for nested args, recurse. str(sorted(args.items())) fails on nested dicts (order inside, unhashable). Prefer JSON dumps with sort_keys for production. Strip fields the schema does not allow before hashing so the model cannot dodge thrash by adding nonce. If nonce is required by a bad schema, fix the schema.

Empty or near-empty observations that lead to the same call are still thrash: search, empty hits, search same q. The model is not “trying a new strategy.” Count it. If you want one retry on empty hits, that is a named policy (empty_retry: 1) and the second empty is thrash. Do not leave it implicit.

Ping-pong: A then B then A then B with no new obs fields. Detect a repeating pair in a short window. Ship identical-call first (this box). Add pair detection when you see it in traces. Flipping search and get_ticket with new ids is not ping-pong. Flipping the same two calls with the same args is.

Finish is not a way out: if you are one short of the thrash cap, the model may emit finish with a guess. Observe-before-finish plus grounded critic should reject that. Thrash stop should still win if the last three events were identical searches even if finish is sitting in the mouth — you already know the loop is sick.

Reset counts on a new hash, not on a new thought. Thoughts always look new.

Finish is also not a way to reset counts: a finish that fails the observe gate, then another identical search, still increments the same key. Counts live on the run, in state, so jobs resume them. RAM-only counters die with the process and you get three more searches after a crash. Put thrash_counts on typed state or recompute from the event list each step (the box recomputes from the list — that is the replay-friendly form).

Common mistakes

  • Fourth search “just in case.”
  • Finish with a guess to escape the loop.
  • Uncanonical args so hashes never match.
  • N equal to max steps (never fires first).
  • Counting only successes, not failed identical calls.
  • Treating thrash as a model quality metric only — it is a loop bug.
Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

Three identical searches stop with n 3 and a key you can log. Mixed queries plus finish do not stop; n is 1. sorted(args.items()) is the poor person’s canonical form. JSON dumps with sort_keys is better for nested args. Keep the idea: stable key, count, cap.

Call thrash after each execute (or each parse of a tool). Do not wait until the end of the run. Early stop saves tokens.

How agents use this

Pair with circuit breakers. Thrash is a healthy agent stuck on a bad assembler. Log it as its own stop reason so you can count it.

Handoff can use why=thrash and tried=the repeated key. The packet should not say “crash.” Next lesson.

Check your understanding

The agent searched the same query three times. What should the loop return?