dimetron / pi-loop-forensics
Diagnose pi-go agent loops and degenerate turns — "agent loop aborted", runaway thinking with no tool calls, repeated phrases. Discriminates genuine model repetition collapse from a race, a tool-parse failure, or a too-low guard, and A/B replays a seed session across providers.
Loop Forensics
Use when a run dies with agent loop aborted: ..., or a model burns a long turn
thinking without ever calling a tool. This skill decides why before anything
is changed: the abort message names a symptom, not a cause.
Sibling skill pi-check-session-logs covers tool call errors. This one covers
repetition and runaway turns. They do not overlap.
Where the evidence lives
| What | Path |
|---|---|
| Session logs (JSONL, one per run) | ~/.pi-go/log/<yyyy-mm-dd>/session-HH-MM-SS.log |
| Session state (resumable) | ~/.pi-go/sessions/<id>/ — events.jsonl, meta.json, trajectory.atif.json |
| Detector source | internal/tui/agent_loop.go |
Log rows are {"time","type","content",...} with type in session_start,thinking, llm_text, tool_call, tool_result, user, error. The model
name is on the session_start row — always record it, findings are per-model.
How the guard actually works
stuckDetector (internal/tui/agent_loop.go:119) has three independent arms:
observe— identical consecutive tool calls, trips atmaxRepeatToolCalls=10.
Pagination args are stripped first (volatileToolArgs), so paging one file
collapses to one fingerprint.observeError— same tool failingmaxToolErrorStreak=10 times running.observeOutput(:246) — the model's own text/thinking. Needs a period ≥minOutputPeriod=16 bytes, ≥minPeriodVariety=8 distinct bytes, andmaxOutputRepeats=12 byte-exact back-to-back copies inside an 8 KB tail.
The third arm is the only one that sees a turn making no tool calls at all.outBuf is never reset across a run, and the thinking branch (:690) skipsdedup.SkipText, unlike the text branch (:697).
Timeline caveat — check this before judging any old log. Sessions predating
these commits cannot be compared against current behavior:
| Commit | Landed | Effect |
|---|---|---|
e069b34 | 2026-08-08 06:56:44 +0200 | added observeOutput (the phrase-repetition arm) |
5a4cb8b | 2026-08-08 18:52:42 +0200 | stopped aborting productive polling (bash_output) |
Steps
Sweep the corpus for scale and per-model distribution:
python3 .pi-go/skills/pi-loop-forensics/scan_logs.pyReports per model: session count, aborts, longest tool-free thinking run
(max/p95), worst periodic repetition. Also lists aborted sessions and the
top offenders. Loops are almost always concentrated in one model.Score individual logs:
python3 .pi-go/skills/pi-loop-forensics/score_run.py ~/.pi-go/log/*/session-*.logThree metrics per log:
think_run(longest tool-free thinking run),reps/period(longest byte-exact periodic tail),intent/calls
("let me write/run/test" phrases vs actual tool calls). A high intent:calls
ratio is the signature of announce-but-never-act.reps>=12is the reliable discriminator. Thethink_run>=20arm is
heuristic and does produce false positives on models that legitimately think
in long bursts — confirm withrepsbefore calling it a loop.Discriminate the cause. Run all three tests; do not stop at the first
plausible one.Race / double-emission? Hash every
thinkingpayload in the session
and count exact duplicates, and check timestamps. Genuine model output has
zero duplicate payloads, varied lengths, and monotonic timestamps
spaced by stream latency. Duplicated payloads or identical timestamps
would mean pi-go re-emitted chunks — a real bug in the stream path.Tool-parse failure? Grep thinking and text for tool-call syntax that
leaked in as prose:<tool_call,<function,tool_calls,"name":...,"arguments",json,<think>,<|tool`. If the model
emitted a call the provider layer failed to parse, the raw syntax shows up
in a text channel and the model never receives a result — which looks
exactly like a loop. Zero matches rules this out.Guard too low? Compare the tripping value against the corpus. Healthy
sessions peak around 1 periodic repeat; the threshold is 12 byte-exact
copies. If healthy runs sit far below the threshold, the guard is not the
problem.
If all three are ruled out, it is inference-level repetition collapse, and
the fix is a provider/sampling question, not a pi-go parsing question.A/B replay across providers. Find a seed session whose **last persisted
event is the tool result immediately before the spiral** — the degenerate
thinking usually never gets committed toevents.jsonl, so resuming
restores the exact pre-failure state and the next turn is the one that broke.# From an isolated worktree — a resumed agent can write files. PI=/path/to/pi TRIALS=3 OUT=/tmp/ab-replay \ bash .pi-go/skills/pi-loop-forensics/ab_replay.shArms are
ollama-cloud,ollama-local,opencode(override withARMS="ollama-cloud opencode"). Each arm runs a preflight one-shot first
and is skipped with its error if credentials are dead, so a bad key costs one
call instead of every trial. Override the seed withSEED=<session-id>.Per trial the script copies the seed to a throwaway session ID (the original
is never mutated), rewritesmeta.json(id/model/provider/workDir),
resumes with--trace-http --mode print, scores the log, and deletes the copy.Nothing pins temperature or seed, so reproduction is probabilistic —
run several trials per arm and compare rates, not single outcomes.--trace-httpwrites full request/response bodies to the session log.
Credentials are masked (internal/provider/provider.go:440); **prompts and
source context are not**.Run from a git worktree, never the primary checkout: a resumed agent can
write files.
Guidelines
Always name the model and check it against the commit timeline before
concluding anything about an old log.An abort message describes the symptom the guard caught, not the cause. A
correct abort on genuine degeneration and a false positive on healthy polling
look identical in the log.Ollama requests set only
num_predict(internal/provider/ollama.go:104-110)
— norepeat_penalty,repeat_last_n,temperature. A looping Ollama model
currently has no tunable knob, which makes provider A/B the informative test.When reporting, separate the guard behaved correctly from *the model
degenerated*. Both can be true at once, and they imply different fixes.
Probing providers: pi ping is not trustworthy
Verified 2026-08-09. pi ping resolves URLs and credentials differently from the
real agent path, so a ping failure is not evidence a provider is down.
Confirm with a one-shot real run instead:
./pi --model <model> --mode print "reply with exactly: OK"Observed ping-only failures, all of which the real path handled fine:
| Symptom | Cause |
|---|---|
DNS resolution failed: lookup : no such host (empty host) on opencode/* | ping never applies opencodeDefaultBaseURL (internal/provider/opencode.go:62) |
<model>:cloud dials localhost:11434 despite OLLAMA_API_KEY being set | ping does not pass the key, so the cloud-URL switch (internal/provider/ollama.go:38) never fires |
dial tcp [::1]:11434: connection refused while the daemon is up | ping's dialer picks the IPv6 literal; the Ollama daemon binds IPv4. Pass --url http://127.0.0.1:11434 |
Keys live in ~/.pi-go/.env and <repo>/.pi-go/.env; pi-go merges both
(internal/config/config.go:387-389). Neither is in a shell profile, so an
agent's own shell will not have them exported — but pi-go reads the files
itself, so that only matters for scripts that gate on env vars.
Examples
/pi-loop-forensics— sweep all logs, report per-model loop distribution- "why did my run abort with 'repeated a 89-character phrase'" — steps 2 and 3
- "does this loop happen on the other provider too" — step 4