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/summary—loop_runsandloop_ratefor the window and the one before it.GET /v1/projects/{projectID}/agent/health—loop_runsandloop_rateperagent_name.GET /v1/projects/{projectID}/agent/runs/timeseries— aloopcount per time bucket.GET /v1/projects/{projectID}/agent/runs/terminations— counts grouped bytermination_reason, whereloop_detectedis 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.