Back to blog

How tree-llm detects and breaks infinite tool-call loops

May 26, 2026 4 min read
aitypescriptagents

The problem

Give an LLM agent a tool and enough turns, and eventually it will call that tool with the exact same arguments twice in a row — then a third time, then get stuck doing it forever. This isn't a rare edge case. It happens whenever a tool result doesn't visibly change the model's plan: a search that returns nothing new, a file read that didn't answer the question the model was actually trying to ask, a retry after a transient error the model didn't register as resolved.

In tree-llm, an agent executor that runs reasoning as a tree of ThinkNodes and ToolNodes (branching tool calls, not a flat message list), this is a real correctness problem, not just wasted tokens. A stuck branch keeps spawning child nodes forever unless something stops it.

Signatures, not just tool names

The naive fix — "don't call the same tool twice" — is wrong. An agent legitimately calls read_file ten times in one session, once per file. What actually indicates a loop is the same tool with the same arguments, repeated past a threshold, on the same reasoning path.

LoopDetector builds a signature per call:

private signature(toolName: string, args: Record<string, unknown>): string {
    return `${toolName}:${this.stableHash(args)}`;
}

The hash isn't just JSON.stringify(args) — argument key order isn't guaranteed to be stable coming out of an LLM's tool-call JSON, so {a: 1, b: 2} and {b: 2, a: 1} need to produce the same signature. stableHash recursively sorts object keys before stringifying, then runs a plain djb2 hash over the result. Nothing fancy — it doesn't need to be cryptographically strong, just deterministic and collision-resistant enough for this purpose.

Path-local, not global

The part that matters more than the hash itself: signatures are counted per path, not globally across the whole tree.

public wouldLoop(
    pathSignatures: Map<string, number>,
    toolName: string,
    args: Record<string, unknown>,
    maxRepeats?: number
): boolean {
    const sig = this.signature(toolName, args);
    return (pathSignatures.get(sig) ?? 0) >= (maxRepeats ?? this.maxRepeats);
}

Each ThinkNode carries its own pathSignatures: Map<string, number> — the call counts accumulated from the root down to that specific node. extendPath returns a new map with one count incremented, rather than mutating the map in place:

public extendPath(
    pathSignatures: Map<string, number>,
    toolName: string,
    args: Record<string, unknown>
): Map<string, number> {
    const sig = this.signature(toolName, args);
    const next = new Map(pathSignatures);
    next.set(sig, (next.get(sig) ?? 0) + 1);
    return next;
}

That immutability is what makes branching safe. If the tree forks into two branches after a ThinkNode, both children start from the same parent pathSignatures map, and each accumulates its own counts independently from there. A tool called once in branch A and once in branch B isn't a loop in either — global deduplication would have wrongly flagged it as one.

Per-tool overrides, not one global limit

The default limit is a single number — maxRepeatsPerSignature: 1 at the tree-executor level, meaning by default a (tool, args) pair may not repeat at all on a path. But some tools are legitimately safe to retry a couple of times (a flaky network call, a search worth refining), so the limit is overridable per tool:

const toolMaxRepeats = this.tools.get(tc.name)?.maxRepeats;
if (this.loopDetector.wouldLoop(this.node.pathSignatures, tc.name, args, toolMaxRepeats)) {
    // ...pruned
}

ThinkNode looks up the tool's own maxRepeats from the tool registry and passes it through as an override; if the tool doesn't define one, wouldLoop falls back to the detector's global default.

What happens when a loop is caught

Pruning happens before execution, not after — filtered out of userToolCalls before any ToolNode is created for it. The user-visible side is a streamed chunk: [tree] Loop detected: toolName(args) already in path — pruning branch, plus an observer event (loop:detected) other code can hook into for logging or metrics. If every tool call in a turn gets filtered this way, the node completes with no children rather than erroring — the branch just ends.

The honest limitation

This only catches exact repeats. An agent that rephrases the same query slightly, or retries with limit: 10 after limit: 9 failed, sails right past it — the signatures don't match, so nothing looks like a loop. Catching semantic repetition would need something closer to embedding similarity between calls, which is a fuzzier, more expensive check than a hash comparison, and — like most loop-detection heuristics — trades false negatives for staying cheap enough to run on every single tool call.