Get started

Engineering NotesConversation engine

When every tool call succeeds but the results repeat, stop the agent

Sometimes every tool an agent calls succeeds and the work still goes nowhere. Feed a document search tool slightly reworded queries and the arguments differ each time, but the documents that come back are the same. One run observed in production repeated a document search this way for 21 rounds and 19 minutes without hitting any limit. Failure detection, which stops when the same tool fails several times in a row (“One agent loop for every feature, with the differences in hooks”), cannot see this loop, because not a single call failed.

So each round, we ask whether it learned anything new. A round is one model call plus running the tool calls in its response. A call repeated with the same arguments is refused before it runs, and the model gets the reason. When five rounds in a row bring back only results already seen, the tools are removed and the model writes an answer from what it has gathered. This post covers what counts as a new fact and which way the check leans when the call is unclear.

Watching only for back-to-back repeats misses a loop that rewords its query

The usual way to catch a loop is to count consecutive calls to the same tool with the same arguments. OwlAI's chat has this check too: when the third call in a row arrives with the same tool and arguments as the one before it, it is blocked (toolPolicy.loopDetectThreshold, default 3). A call to another tool in between starts the count over. Calls that page through results have different arguments and never trip it, so false positives are rare.

A blocked call gets back an error code plus what to do next. Given only the code, the model reads "blocked" as "impossible" and gives up. In production, a model that had already fetched a connector's site ID twice was blocked with just a code on the third call; instead of using the value it had, it ended the turn saying it had only confirmed the connection details and could not fetch the issues. So the reason now tells the model that the result it already received is in the conversation, to use that value, and to turn on a different tool if it needs one.

The limit is that the check looks only at arguments. The document search above uses a different query every round, so it never trips the check. Mixing in one call to another tool while the same documents keep coming back also resets the counter to zero. A lack of progress shows up in the results, not the arguments.

So the researcher loop in deep research looks at two more things. It checks whether a call was made at any point in this run, not just in the previous call, and it checks each round whether the results that came back were already seen.

A call that has run once does not run again

A researcher refuses, before execution, any call whose tool and arguments match a call it already ran in this run. Researchers only get tools that need no approval, so we treat the same call as returning the same result. The researcher's system prompt also tells it not to repeat a call with the same arguments, but if the model ignores that, this check still blocks the call.

Arguments are compared as values, not text. The model writes JSON key order and whitespace differently from one call to the next. The same call can also arrive by two paths: as the arguments the model wrote, and as text rewritten when a saved file is expanded into the arguments. Comparing text would miss a loop that alternates between the two paths.

A refusal blocks only the execution; the loop keeps going. The reason comes back as the tool result and says what to do next. For a researcher, it says the call was already made with the same arguments and to change the arguments or the approach. Which calls get refused before execution is spelled out at the end of the system prompt, so the model can avoid them before it is ever refused.

Tools whose protocol is to be called again with the same arguments are left out of this check. A tool that waits for a subagent to finish takes one argument, the subagent to wait for, so waiting again always repeats the arguments. If that tool were checked, the call to wait again after one wait ended would be refused every time, and the loop would stop for lack of progress, cancelling a subagent that was still working. The chat check from the previous section does not count this tool either. The deep research lead launches researchers from a chat turn, so counting this tool would block the third wait for a researcher that is still working. The tool itself declares that it is exempt from both checks. If each feature kept its own exemption list, a new feature would ship with the list missing.

Five rounds in a row of already-seen results end the run

The researcher loop takes a fingerprint of each tool result: a hash of the result's content, kept until the run ends. If any result in a round has a fingerprint not seen before, the round brought a new fact. If none does, it counts as a round of already-seen results, and after five such rounds in a row the tools are removed and the model writes its answer. Any new fact resets the count to zero.

The query changes each round, but five rounds of already-seen results end the run
Figure 1. The query changes each round, but five rounds of already-seen results end the run

The count is per round, not per tool. A researcher calls several tools in one round. If one tool returns the same result but another tool in the same round finds a new document, the round made progress.

Zero results and results of unknown shape count as new

An empty result gets no fingerprint and counts as a new fact. Zero results in a row is normal exploration: the agent is confirming that nothing turns up for another period or another keyword either. Treat empty results as identical, and that confirmation is cut off after five rounds.

Whether a result is empty is judged by its structure. Tools say "zero results" in different shapes: {"items": [], "count": 0} in one, {"rows": [], "rowCount": 0} in another. A list of keys ("if this key is empty, it's zero results") silently starts going wrong each time a tool is added. So the check walks the whole value, and a result holding nothing but empty containers, empty strings, 0, false and null carries no facts.

Two more cases count as new facts.

  • A result that cannot be fingerprinted. If every result of unknown shape were filed as already seen, the first use of a new tool would cut off a healthy run.
  • A result from a tool that needs approval. Even when the response is the same, things like sending an email or creating a ticket happened again with every call.

Whether a tool needs approval comes from what the admin set in the tool policy. If the check relied on a tool's own read-only hint, which is optional, every lookup tool from a connector that leaves the hint out would be exempt. In the production tool list, five lookup tools were like that, and result comparison would never run for them.

Every unclear case leans toward "new fact." A wrong call means the loop keeps going instead of stopping. A run that keeps going is still cut off by failure detection, the round limit and the run's time limit; a healthy run that was cut off wrongly cannot be brought back.

Rounds where every call was refused are counted separately

When every call in a round is refused, nothing runs and the model gets only the reasons. Five such rounds in a row also end the run. While the model reads the reasons and keeps repeating the same calls, all it produces is model-call cost.

This counter is kept apart from the already-seen-results counter. The refusal counter resets as soon as any call runs; the results counter resets only when a new fact appears. Merge them, and a round where calls run but return only seen results is wiped by the refusal-side reset, so the results-side loop is never counted.

Refusals are also counted per round, not per tool. Counted per tool, refusals scattered between productive rounds pile up on that tool's counter. The counter resets only when that same tool succeeds, so a run making progress with other tools gets cut off too. In measurement, a run that was progressing with five successful calls to another tool was cut off this way at round 11. Counted per round, the counter resets the moment anything runs, so it only fires when the agent truly cannot do anything.

Overloaded calls come off the budget but don't count as progress

A researcher works within a tool-call budget set by the lead. A call that failed because the tool server hit a request limit returned nothing, yet it still used up that budget. Left alone, no calls would remain once the congestion passed. So up to agentLoop.overloadedToolCallsExemptMax (default 3) overloaded calls per budget are excluded from the call count, and the round limit grows by the number of rounds that had such calls. A researcher's round limit is sized to its budget, so without the extra rounds it could not use the calls it got back.

Coming off the budget does not make them progress. Failure detection still counts an overloaded call as a failure, and since the same error comes back each time, result comparison sees an already-seen result. Together, those two checks keep the agent from retrying an overloaded tool forever.

Design choices

Consecutive failures, rounds where every call was refused, and rounds of already-seen results all share the same threshold, 5. All three express the same judgment, "stop after five tries that go nowhere," and we found no basis for giving them different values. Splitting the value three ways would only add combinations to verify. The cost is that the three cannot be tuned separately.

The check leans toward not stopping. Zero results, results of unknown shape and results from tools that need approval are all new facts. The cost is that a tool whose every response carries a slightly different value, such as the lookup time, always looks new even when the content is the same. Failure detection, the round limit and the run's time limit stop that kind of loop.

Result comparison and the run-wide refusal of repeated calls apply to the researcher loop. A chat turn stops on the back-to-back repeat check, failure detection and the 40-round limit (“One agent loop for every feature, with the differences in hooks”). In chat, an agent that insists on the same call accumulates refusals as failures of that tool until failure detection fires.

If your agent uses tools like search, which can bring back the same thing under different arguments, failure counts alone will not stop its loops. A starting point is to compare each round's results with what has already been seen, while counting empty and unknown results as new.