> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentium.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Tool Loop Detection (DebounceHook)

> Catch agents stuck calling the same tool with identical arguments

# Tool Loop Detection

## The problem

Agents occasionally get stuck. The LLM calls a tool, gets a result, doesn't like it, and calls the same tool with the same arguments again hoping for a different answer. Repeat 20 times.

You burn:

* \~80 tokens of tool definition × 20 = 1,600 tokens
* 20 × tool result tokens
* 20 × LLM completion tokens
* Real wall-clock time

By the time you notice, the bill is real.

`loopDetection` on `ToolExecutor` catches this within a single run.

## Configuration

```typescript theme={null}
import { ToolExecutor } from "@agentium/core";

const executor = new ToolExecutor(tools, {
  loopDetection: {
    maxRepeats: 3,
    action: "abort", // or "hint"
  },
});
```

| Field        | Type                | Meaning                                                                    |
| ------------ | ------------------- | -------------------------------------------------------------------------- |
| `maxRepeats` | `number`            | Threshold above which the action fires. Counter starts at 1 on first call. |
| `action`     | `"abort" \| "hint"` | What to do when threshold is hit                                           |

## Action semantics

### `action: "abort"`

After call number `maxRepeats + 1` of the same `(toolName, JSON.stringify(arguments))`, the executor throws `ToolLoopError`:

```typescript theme={null}
import { ToolLoopError } from "@agentium/core";

try {
  const result = await agent.run(input);
} catch (err) {
  if (err instanceof ToolLoopError) {
    console.error(`Loop detected: ${err.toolName} called ${err.repeats} times`);
    // Decide what to do — fall back to a different model? Ask the user for help?
  }
}
```

`ToolLoopError` properties:

```typescript theme={null}
class ToolLoopError extends Error {
  readonly name = "ToolLoopError";
  readonly toolName: string;
  readonly repeats: number;
  readonly message: string;  // "Tool "X" was called N times with identical arguments - aborting to prevent a loop"
}
```

### `action: "hint"`

Instead of throwing, the executor returns a synthetic tool result back to the LLM:

```
[loop-detected] Tool "fetchData" has now been called 4 times with identical arguments.
Consider trying a different approach, changing the arguments, or finishing the response.
```

The LLM sees this in its next turn and almost always self-corrects. The `ToolCallResult` returned has `error: "loop-detected"` so you can tell it apart from a real result.

```typescript theme={null}
const result = await executor.executeAll([call], ctx);
if (result[0].error === "loop-detected") {
  // The model is getting stuck. Surface this in your UI / metrics.
}
```

## How the counter works

The executor maintains a `Map<string, number>` per `ToolExecutor` instance. The key is:

```
${toolName}::${JSON.stringify(arguments)}
```

This means:

* **Identical args bump the counter.** `fetchData({q: "hi"})` called twice → count 2.
* **Different args reset.** `fetchData({q: "hi"})` then `fetchData({q: "bye"})` → two independent counters of 1 each.
* **Order matters in JSON.stringify.** `{a:1, b:2}` and `{b:2, a:1}` count separately. (Usually not a problem because LLMs are remarkably consistent about key order.)
* **The counter survives across `executeAll` calls within the same `ToolExecutor` instance.** It's not per-call.

## Recommended settings

| Use case                                                 | Setting                                                                          |
| -------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Most agents                                              | `{ maxRepeats: 3, action: "hint" }`                                              |
| Production pipelines                                     | `{ maxRepeats: 2, action: "abort" }`                                             |
| Exploratory / status-polling tools (cache lookups, etc.) | Don't set `loopDetection`, OR set `maxRepeats: 10+`                              |
| Debugging an agent that's spinning                       | `{ maxRepeats: 1, action: "abort" }` — first repeat throws, you see the bug fast |

## Interaction with `defineAsyncTool` / `pollResult`

Polling is genuinely repetitive — `pollResult({handle: "ah:abc"})` called 5 times in a row is correct behavior, not a loop.

If you use both, raise the threshold OR skip loop detection on the executor that holds the poll tool. The simplest pattern:

```typescript theme={null}
const executor = new ToolExecutor(tools, {
  // Only abort after many repeats — pollResult will hit this legitimately.
  loopDetection: { maxRepeats: 30, action: "hint" },
});
```

Better: define your own per-tool exemption logic by wrapping `ToolExecutor`. (Not built in yet; would be a future addition.)

## Telemetry

Loop detection emits a `tool.result` event on the `RunContext.eventBus` with the synthetic hint message when `action: "hint"` fires. Wire your observability layer to count these — a spike in `loop-detected` results is a strong signal that a particular prompt template is broken.

## See also

* [Hooks and Guardrails](/agents/hooks-and-guardrails) — pre/post tool hooks for finer control
* [Async HandleId Pattern](/features/async-tools) — the right way to handle naturally repetitive polling
