keelwave
Guides

Loop Detection

Find out why an agent repeats itself instead of making progress.

A loop is a run where the same tool was called with the same input more than once. keelwave identifies it by fingerprint, not by heuristics: every step that carries a tool_name gets a SHA-256 hash of tool_name + tool_input, and any fingerprint that appears twice or more in a single run is a loop.

Latency and error rate stay clean while this happens — the agent is doing work, just not new work. That is the failure mode keelwave is built to surface.

See it on a run

Loop data is read per run:

curl -H "Authorization: Bearer $KEELWAVE_API_KEY" \
  "http://localhost:8080/v1/projects/$PROJECT_ID/agent/runs/$RUN_ID/loops"

Each element is one repeated fingerprint:

{
  "data": [
    {
      "fingerprint": "P5rk1a2VUq3o8m1TfLxK9wZ0nB7cYd4eRgHjKlMnOpQ=",
      "hits": 5,
      "step_indices": [1, 2, 3, 4, 5],
      "tool_name": "web_search"
    }
  ]
}

hits is how many steps share the fingerprint, and step_indices lists their step_index values in ascending order, so the first entry is where the repetition started. Groups are ordered by hits descending. A run with no repetition returns an empty array.

fingerprint is the raw 32-byte SHA-256 digest, so it serialises as base64 — not hex. Treat it as an opaque grouping key.

How detection actually works

Detection happens in two independent places. Both matter, and they answer different questions.

Server-side: the fingerprint of record

Every POST /v1/ingest/agent/steps request is fingerprinted by the API before the step is buffered. The hash covers the tool name, a single zero byte as a separator, and the raw tool_input JSON bytes exactly as sent:

sha256(tool_name || 0x00 || tool_input)

The result is stored on the step as input_fingerprint. If tool_name is absent the fingerprint is NULL, so think and replan steps are never candidates for a loop.

GET /v1/projects/{projectID}/agent/runs/{runID}/loops then groups the run's steps by input_fingerprint, ignoring nulls, and keeps only groups with count(*) >= 2. This is computed at query time from stored steps, which means it stays correct no matter what the SDK reported at finish time.

Because the server hashes the raw request bytes, two semantically identical inputs serialized with different key order or spacing produce different fingerprints and will not group together. The SDKs avoid this by serializing with sorted keys — see below.

Client-side: the loop_detected flag

The SDKs track fingerprints locally so a run can be flagged the moment repetition happens, without waiting for a query. Run.check_fingerprint() (Python) and Run.checkFingerprint() (TypeScript) hash the tool name plus its JSON-serialized input, keep a map of fingerprint to the step index where it was first seen, and on the first duplicate call mark_loop() / markLoop() with that original index.

Python serializes with json.dumps(..., sort_keys=True), so key order in the input dict does not affect the hash. The TypeScript SDK passes the input's own sorted keys to JSON.stringify as a replacer array, which selects the keys but preserves insertion order — so in TypeScript two objects with the same keys in a different order can hash differently. Build tool inputs consistently and this never comes up.

Those two values ride along on the finish request as loop_detected and loop_step_index, and land on the agent_runs row. loop_step_index is the step where the repeated behaviour began, not where it was caught.

The two mechanisms hash different byte sequences, so the fingerprint the SDK computes is not the same value the /loops endpoint returns. Only the grouping behaviour is meant to agree.

What your SDK has to report

Loop detection needs tool_name and tool_input on the step. A step with only content cannot be fingerprinted. If you use the decorators this is already handled — @observe with the default tool_call step type sends the function name as tool_name and its arguments as tool_input, then runs the fingerprint check for you.

import os
from keelwave import Keelwave, get_current_run

client = Keelwave(
    api_key=os.environ["KEELWAVE_API_KEY"],
    endpoint=os.environ.get("KEELWAVE_ENDPOINT", "http://localhost:8080"),
)


@client.observe(name="web_search", step_type="tool_call")
def web_search(q: str, max_results: int = 3) -> dict:
    return {"results": search(q, max_results=max_results)}


@client.agent(name="looping-agent")
def run_loop(question: str) -> str:
    run = get_current_run()
    for _ in range(5):
        # Second identical call marks the run as a loop. No extra code.
        web_search(q=question, max_results=3)
    return run.id if run else ""

Calling run.tool_call(...) / run.toolCall(...) directly works too. In TypeScript toolCall fingerprints internally, so nothing else is needed. In Python tool_call only sends the step, so call check_fingerprint() yourself when you want the run flagged:

run.tool_call("web_search", input={"q": question}, output=result)
run.check_fingerprint("web_search", {"q": question})

You can also flag a run manually when your own logic detects a loop:

run.mark_loop(step_index=3)

Loop detection only fires when repeated inputs are genuinely identical. A tool whose input embeds a timestamp, a UUID, or a retry counter produces a new fingerprint on every call and will never group, even though the agent is stuck. Keep volatile values out of tool_input and put them in metadata.

Where it surfaces

Run detail. Runs flagged loop_detected show a loop badge. The step timeline groups consecutive steps that share a looping fingerprint into a single band annotated with its hit count, so the repeated segment reads as one block instead of a wall of near-identical rows.

Run list. The same badge marks looping runs inline, alongside an aggregate loop rate.

Aggregates. loop_detected feeds the rollups directly:

  • GET /v1/projects/{projectID}/agent/summaryloop_runs and loop_rate for the window and the one before it.
  • GET /v1/projects/{projectID}/agent/healthloop_runs and loop_rate per agent_name.
  • GET /v1/projects/{projectID}/agent/runs/timeseries — a loop count per time bucket.
  • GET /v1/projects/{projectID}/agent/runs/terminations — counts grouped by termination_reason, where loop_detected is one of the accepted values.

These read the stored loop_detected column, so they reflect what the SDK reported at finish. The /loops endpoint recomputes from steps and is the one to trust when the two disagree.

Alert rules accept a loop signal, so a rising loop rate can page you instead of waiting to be noticed.

Termination reasons

When you finish a run yourself, termination_reason accepts exactly clean, max_steps_reached, context_limit, error, loop_detected, and timeout. Use loop_detected when a loop is why you stopped the agent. The SDK context managers set clean on success and error when an exception escapes the block.

On this page