Commit 99f9d11
authored
feat(agent): stuck/runaway guard + surface the failure behind a max_turns cap (ABCA-662) (#600)
* feat(agent): stuck/runaway guard — break a repeating-failing-command loop
Live-caught ABCA-483: a one-line README task burned ALL 100 turns (~22 min,
$1.53) because the agent re-ran the SAME failing command (mise //cdk:test →
JS-heap OOM, exit 134) over and over, yak-shaving the build env instead of
finishing. Nothing noticed until the hard max_turns cap killed it.
New `stuck_guard.py` (pure, dep-free): PostToolUse records each tool result;
a coarse (tool_name, command) signature tracks consecutive failures. A new
between-turns hook (registered after cancel, before nudge — same
short-circuit rationale) then:
- STEER once at 3 repeats: inject 'stop retrying X — work around it or
finish with what you have';
- BAIL at 6 repeats: end the turn loop early (continue_=False) so the task
fails fast instead of grinding to the cap.
Conservative by design: keys on a REPEATING FAILURE (not a raw turn count),
so a large task making varied progress never trips it; unknown tool output
counts as success. Fail-open everywhere — a guard bug can't wedge the agent.
Platform-agnostic, MAIN-BOUND: lives entirely in agent/src (stuck_guard.py +
hooks.py); surfaces via the channel-neutral error_message. The honest reason
is then rendered per-channel by the classifier / failure-reply (several fixes).
1158 agent tests pass (ruff + ty clean).
* fix: stuck-guard is advisory-only — drop the bail, keep a one-time steer
Reviewing the guard for false-positive risk (user: 'make sure the turn limit
isn't too aggressive') showed the bail path was unsafe: distinguishing a true
spin from a legitimately-iterating agent (re-running the same test command as
it fixes failures one by one) from raw output is genuinely fragile — a digit-
normalizing fingerprint that ignores volatile GC timings ALSO collapses
'test file_0' vs 'file_1' (the progress signal), so it would kill a working
agent. A false-positive KILL is far worse than a false-positive nudge.
So: removed BAIL entirely. The guard now only ever injects a ONE-TIME advisory
steer when the same command fails with IDENTICAL output N times; the max_turns
cap (with a fix's honest 'Exceeded max turns' reason) is the real runaway backstop.
A false positive costs exactly one extra advisory comment. Platform-agnostic,
main-bound.
* fix(agent): explain WHY a task hit max_turns + catch loop-of-variations early (ABCA-662)
ABCA-662 capped at max_turns, but the CAUSE was masked: it had finished the code
and then thrashed ~15 turns retrying a failing 'git push' (remote: invalid
credentials) every which way — a DIFFERENT command each turn, so the existing
per-signature stuck-guard (same command × identical output ≥3) never tripped,
and the terminal reason read only 'Exceeded max turns'. max_turns is a CORRECT
classification, but the user can't tell 'genuinely needed more turns' from 'spun
on an error' — and raising the cap just burns more tokens.
Two changes, both advisory (no hard kill — keeps the K10 no-false-kill stance):
- stuck_guard: add a signature-agnostic TRAILING WINDOW (last 6 tool outcomes).
When ≥5 share the SAME byte-identical failure fingerprint across DIFFERENT
commands, steer once ('you're spinning on one failing op — stop, it won't
resolve by retrying'). EXACT fingerprint (no digit-blur) so a healthy
iterate-and-fix loop (same cmd, a different test failing each run) never trips
it — the K10 case, still covered by its test. Also exposes recent_failure_
summary().
- pipeline/hooks: the between-turns hook latches that summary; the terminal path
appends it to a max_turns reason → 'error_max_turns … — spinning on failing
tool calls (last: git push → invalid credentials)'. Only enriches max_turns;
a productive run adds nothing.
- error-classifier: a new bucket for 'error_max_turns … spinning on failing tool
calls' → 'Ran out of turns while stuck on a failing step' with the honest
remedy (raising --max-turns won't help; fix the failing op / check the env),
ordered before the generic max_turns bucket. Tests: window-spin + K10 no-trip
+ summary + pipeline enrichment + classifier bucket.
* fix(errors): don't over-claim on a max_turns spin — offer BOTH remedies (ABCA-662 review)
The prior 'spinning on failing tool calls' classifier bucket asserted 'raising
--max-turns won't help'. That over-claims: the trailing window can't distinguish
(a) a HARD blocker no turn count fixes from (b) a LONG task that hit a
transient/recoverable snag near the end and just ran out — which is 662 itself
(siblings 661/663 pushed fine with the same token → its 'invalid credentials'
was a transient race that more turns / a retry WOULD have cleared). Reframe the
bucket to SURFACE what it was stuck on and present BOTH paths (environment
blocker → fix + retry; transient/recoverable or a shorter sibling got past it →
just retry / raise --max-turns), letting the failure KIND in the detail tell the
reader which applies. Title 'Ran out of turns retrying a failing step'. The
early window-steer (agent told to STOP and report while it still has turns) is
the real mechanism that lets a long task escape the thrash; this copy is just
the honest post-hoc explanation. Test updated to assert both remedies, not the
over-claim.
* fix(errors): max_turns stays "Exceeded max turns" — observe the repeated failure, don't diagnose it
Reviewer concern on the ABCA-662 work: re-titling a max_turns failure as "Ran out
of turns retrying a failing step" over-claims. The stuck-guard's trailing window is
only the last ~6 tool calls, which genuinely CANNOT distinguish a hard blocker (bad
creds, no permission — more turns won't help) from a long task that made real
progress and hit a recoverable snag only at the tail (more turns / a retry WOULD
help — 662 itself: siblings pushed fine with the same token). Framing the whole run
around its last 6 calls misrepresents the latter.
- error-classifier: drop the separate "retrying a failing step" bucket. A max_turns
failure stays the plain "Exceeded max turns"; the description/remedy point the
reader at the observed detail and still surface the environment-blocker path,
but assert NO cause.
- stuck_guard.recent_failure_summary: emit a NEUTRAL observation ("last tool calls
repeated: `<cmd>` → <err>") instead of "spinning on failing tool calls". The
mid-run steer TO the agent (an advisory nudge, not a user surface) still says
"spinning" — that's fine, it's coaching the agent, not classifying the outcome.
- tests updated to pin the neutral wording + assert the title is NOT re-framed.1 parent 77042c8 commit 99f9d11
8 files changed
Lines changed: 824 additions & 8 deletions
File tree
- agent
- src
- tests
- cdk
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| 37 | + | |
37 | 38 | | |
38 | 39 | | |
39 | 40 | | |
| |||
172 | 173 | | |
173 | 174 | | |
174 | 175 | | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
175 | 197 | | |
176 | 198 | | |
177 | 199 | | |
| |||
1039 | 1061 | | |
1040 | 1062 | | |
1041 | 1063 | | |
| 1064 | + | |
1042 | 1065 | | |
1043 | 1066 | | |
1044 | 1067 | | |
| |||
1051 | 1074 | | |
1052 | 1075 | | |
1053 | 1076 | | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
| 1080 | + | |
| 1081 | + | |
1054 | 1082 | | |
1055 | 1083 | | |
1056 | 1084 | | |
| |||
1113 | 1141 | | |
1114 | 1142 | | |
1115 | 1143 | | |
| 1144 | + | |
| 1145 | + | |
| 1146 | + | |
| 1147 | + | |
| 1148 | + | |
| 1149 | + | |
| 1150 | + | |
| 1151 | + | |
1116 | 1152 | | |
1117 | 1153 | | |
1118 | 1154 | | |
| |||
1395 | 1431 | | |
1396 | 1432 | | |
1397 | 1433 | | |
| 1434 | + | |
| 1435 | + | |
| 1436 | + | |
| 1437 | + | |
| 1438 | + | |
| 1439 | + | |
| 1440 | + | |
| 1441 | + | |
| 1442 | + | |
| 1443 | + | |
| 1444 | + | |
| 1445 | + | |
| 1446 | + | |
| 1447 | + | |
| 1448 | + | |
| 1449 | + | |
| 1450 | + | |
| 1451 | + | |
| 1452 | + | |
| 1453 | + | |
| 1454 | + | |
| 1455 | + | |
| 1456 | + | |
| 1457 | + | |
| 1458 | + | |
| 1459 | + | |
| 1460 | + | |
| 1461 | + | |
| 1462 | + | |
| 1463 | + | |
| 1464 | + | |
| 1465 | + | |
| 1466 | + | |
| 1467 | + | |
| 1468 | + | |
| 1469 | + | |
| 1470 | + | |
| 1471 | + | |
| 1472 | + | |
| 1473 | + | |
| 1474 | + | |
| 1475 | + | |
| 1476 | + | |
1398 | 1477 | | |
1399 | 1478 | | |
1400 | 1479 | | |
| |||
1406 | 1485 | | |
1407 | 1486 | | |
1408 | 1487 | | |
| 1488 | + | |
| 1489 | + | |
| 1490 | + | |
| 1491 | + | |
1409 | 1492 | | |
1410 | 1493 | | |
1411 | 1494 | | |
| |||
1423 | 1506 | | |
1424 | 1507 | | |
1425 | 1508 | | |
| 1509 | + | |
1426 | 1510 | | |
1427 | 1511 | | |
1428 | 1512 | | |
| |||
1445 | 1529 | | |
1446 | 1530 | | |
1447 | 1531 | | |
| 1532 | + | |
1448 | 1533 | | |
1449 | 1534 | | |
1450 | 1535 | | |
| |||
1526 | 1611 | | |
1527 | 1612 | | |
1528 | 1613 | | |
| 1614 | + | |
| 1615 | + | |
| 1616 | + | |
| 1617 | + | |
| 1618 | + | |
1529 | 1619 | | |
1530 | 1620 | | |
1531 | 1621 | | |
| |||
1568 | 1658 | | |
1569 | 1659 | | |
1570 | 1660 | | |
1571 | | - | |
| 1661 | + | |
| 1662 | + | |
| 1663 | + | |
| 1664 | + | |
| 1665 | + | |
| 1666 | + | |
1572 | 1667 | | |
1573 | 1668 | | |
1574 | 1669 | | |
| |||
1593 | 1688 | | |
1594 | 1689 | | |
1595 | 1690 | | |
| 1691 | + | |
1596 | 1692 | | |
1597 | 1693 | | |
1598 | 1694 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
466 | 466 | | |
467 | 467 | | |
468 | 468 | | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
469 | 484 | | |
470 | 485 | | |
471 | 486 | | |
| |||
696 | 711 | | |
697 | 712 | | |
698 | 713 | | |
699 | | - | |
| 714 | + | |
700 | 715 | | |
701 | 716 | | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
702 | 720 | | |
703 | 721 | | |
704 | 722 | | |
| |||
0 commit comments