Skip to content

Commit 49ce2c4

Browse files
committed
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 0056d8b commit 49ce2c4

5 files changed

Lines changed: 44 additions & 48 deletions

File tree

agent/src/pipeline.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -714,8 +714,8 @@ def run_task(
714714
from hooks import reset_blocker_reason, reset_stuck_summary
715715

716716
reset_blocker_reason()
717-
# ABCA-662: same per-task reset for the stuck-guard "why it spun" latch,
718-
# so a prior task's spin summary can't leak into this task's max_turns copy.
717+
# ABCA-662: same per-task reset for the stuck-guard recent-failure latch,
718+
# so a prior task's observation can't leak into this task's max_turns copy.
719719
reset_stuck_summary()
720720
# --trace accumulator (design §10.1): when the task opted into
721721
# trace, ``_TrajectoryWriter`` keeps an in-memory copy of each

agent/src/stuck_guard.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -331,14 +331,20 @@ def _dominant_window_failure(self) -> tuple[str, str] | None:
331331
return None
332332

333333
def recent_failure_summary(self) -> str | None:
334-
"""A one-line "why it spun" summary for a max_turns terminal reason.
334+
"""A one-line NEUTRAL observation of the recent repeated failure, for a
335+
max_turns terminal reason.
335336
336337
Returns None unless the trailing window is failure-dominated (the same
337338
bar the window-steer uses) — so a task that genuinely used its turns
338339
making progress yields no summary and its max_turns reason is unchanged.
339-
Names the dominant recent failing command + a short slice of its output,
340-
so the platform can say "hit max turns while stuck on: <cmd> → <err>"
341-
instead of a bare "Exceeded max turns".
340+
Names the dominant recent failing command + a short slice of its output.
341+
342+
Deliberately states only WHAT was observed, not WHY it capped: the window
343+
is the last few tool calls, which can't tell a hard blocker from a long
344+
task that hit a recoverable snag near the end. So the platform can say
345+
"hit max turns; last tool calls repeated: <cmd> → <err>" and let the
346+
reader judge — it must NOT assert the task was "spinning" or that more
347+
turns wouldn't have helped.
342348
"""
343349
dominant = self._dominant_window_failure()
344350
if dominant is None:
@@ -351,5 +357,5 @@ def recent_failure_summary(self) -> str | None:
351357
if failed and p == prev:
352358
detail = re.sub(r"\s+", " ", fp).strip()[:120]
353359
break
354-
base = f"spinning on failing tool calls (last: `{prev}`"
355-
return f"{base}{detail})" if detail else f"{base})"
360+
base = f"last tool calls repeated: `{prev}`"
361+
return f"{base}{detail}" if detail else base

agent/tests/test_stuck_guard.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,13 +198,15 @@ def test_recent_failure_summary_names_the_last_failure(self):
198198
g.record_tool_result("Bash", cmd, out)
199199
summary = g.recent_failure_summary()
200200
assert summary is not None
201-
assert "spinning on failing tool calls" in summary
201+
# Neutral observation only — names WHAT repeated, makes no causal claim.
202+
assert "last tool calls repeated" in summary
203+
assert "spinning" not in summary # must not editorialize
202204
assert "git push --force-with-lease" in summary # most recent failing command
203205
assert "invalid credentials" in summary # the recurring error detail
204206

205207
def test_no_summary_when_window_is_mostly_successful(self):
206208
# A productive agent (varied commands, mostly succeeding) must yield no
207-
# spin summary — so its max_turns reason stays unchanged.
209+
# summary — so its max_turns reason stays unchanged.
208210
g = StuckGuard()
209211
for i in range(6):
210212
g.record_tool_result("Bash", {"command": f"step {i}"}, OK)

cdk/src/handlers/shared/error-classifier.ts

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -306,35 +306,21 @@ const PATTERNS: readonly ErrorPattern[] = [
306306
// (live-caught on ABCA-483: a task hit the 100-turn cap but the reply
307307
// said "Unexpected error"). Match either ``agent_status=``/``subtype=``.
308308
{
309-
// ABCA-662: max_turns hit while the last several tool calls were the SAME
310-
// repeated failure (the agent's stuck-guard latched a failure-dominated
311-
// trailing window; the pipeline appended "spinning on failing tool calls
312-
// (last: …)"). This SURFACES what it was stuck on — but deliberately does NOT
313-
// assert "more turns won't help", because the window alone can't distinguish
314-
// (a) a HARD blocker no turn count fixes (bad creds, no permission) from
315-
// (b) a LONG task that hit a transient/recoverable snag late and just ran out
316-
// (662 itself: siblings 661/663 pushed fine with the same token → its
317-
// 'invalid credentials' was a transient race that MORE turns / a retry would
318-
// have cleared). So we name the failing operation and offer BOTH remedies;
319-
// the failure KIND (in the detail) tells the reader which applies. Ordered
320-
// before the generic error_max_turns bucket so this specific case wins.
321-
pattern: /error_max_turns.*spinning on failing tool calls/i,
322-
classification: {
323-
category: ErrorCategory.TIMEOUT,
324-
title: 'Ran out of turns retrying a failing step',
325-
description: 'The agent hit its max-turns limit while the last several tool calls were the same repeated failure (see the detail below). That means one of two things — the failing step is a hard blocker no extra turns can fix, OR the task was long and hit a recoverable snag near the end and simply ran out of turns.',
326-
remedy: 'Check the failing operation in the detail below. If it is an environment/tooling blocker (auth, credentials, permission, network, disk), retrying more won\'t help — fix the environment (an admin), then reply here to retry. If it looks transient or recoverable (a flaky/temporary error, or a sibling task with a shorter to-do got past the same thing), just reply to retry and/or raise --max-turns.',
327-
retryable: true,
328-
errorClass: ErrorClass.USER,
329-
},
330-
},
331-
{
309+
// A max-turns cap is a correct, self-explanatory classification. When the
310+
// stuck-guard observed the last several tool calls repeating the SAME failure
311+
// it is appended to the reason as a neutral OBSERVATION ("last tool calls
312+
// repeated: `<cmd>` → <err>") — we surface WHAT was on screen but deliberately
313+
// make NO causal claim about whether more turns would have helped: the
314+
// trailing window (last 6 calls) can't distinguish a hard blocker from a long
315+
// task that hit a recoverable snag only at the tail, so re-framing the whole
316+
// run as "retrying a failing step" would misrepresent the latter. The reader
317+
// sees the observed detail and the neutral remedy and decides.
332318
pattern: /(?:agent_status|subtype)=['"]?error_max_turns['"]?/i,
333319
classification: {
334320
category: ErrorCategory.TIMEOUT,
335321
title: 'Exceeded max turns',
336-
description: 'The agent reached the configured ``max_turns`` limit before completing.',
337-
remedy: 'Raise ``--max-turns`` on the submit call, simplify the task, or break it into smaller sub-tasks.',
322+
description: 'The agent reached the configured ``max_turns`` limit before completing. If a repeated tool failure was observed near the end, it is shown in the detail below.',
323+
remedy: 'Look at the detail below to see what the agent was doing when it ran out. Raise ``--max-turns`` on the submit call, simplify the task, or break it into smaller sub-tasks — and if the detail shows an environment/tooling blocker (auth, credentials, permission, network, disk), fix that first, then reply here to retry.',
338324
retryable: true,
339325
errorClass: ErrorClass.USER,
340326
},

cdk/test/handlers/shared/error-classifier.test.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -258,24 +258,26 @@ describe('classifyError', () => {
258258
expect(result!.remedy).toMatch(/--max-turns/);
259259
});
260260

261-
test('ABCA-662: max_turns hit while retrying a failing step surfaces WHAT it was stuck on, and offers BOTH remedies (does not over-claim)', () => {
261+
test('ABCA-662: max_turns with an observed repeated failure stays "Exceeded max turns" and makes NO causal claim', () => {
262262
// When the agent capped out with the last several calls being the same
263-
// repeated failure (pipeline appended "spinning on failing tool calls"), we
264-
// name the failing op — but must NOT assert "more turns won't help", because
265-
// the window can't tell a hard blocker from a long task that hit a
266-
// recoverable snag late (662: siblings pushed fine → transient). So the
267-
// remedy presents BOTH paths. Ordered before the generic max_turns bucket.
263+
// repeated failure, the pipeline appends a NEUTRAL observation ("last tool
264+
// calls repeated: …"). The classification must NOT re-title the failure as
265+
// "retrying a failing step" or assert more turns wouldn't help — the window
266+
// (last few calls) can't tell a hard blocker from a long task that hit a
267+
// recoverable snag late (662: siblings pushed fine → transient). It stays the
268+
// plain max_turns bucket; the observed detail rides along in the message.
268269
const result = classifyError(
269-
"Agent session error (subtype='error_max_turns') — spinning on failing tool calls "
270-
+ '(last: `git push --force-with-lease` — remote: invalid credentials fatal: exit 128)',
270+
"Agent session error (subtype='error_max_turns') — last tool calls repeated: "
271+
+ '`git push --force-with-lease` — remote: invalid credentials fatal: exit 128',
271272
);
272273
expect(result!.category).toBe(ErrorCategory.TIMEOUT);
273-
expect(result!.title).toBe('Ran out of turns retrying a failing step');
274+
expect(result!.title).toBe('Exceeded max turns');
274275
expect(result!.retryable).toBe(true);
275-
// Surfaces the environment-blocker path AND the transient/recoverable path —
276-
// it does NOT flatly claim raising turns is useless.
277-
expect(result!.remedy).toMatch(/environment/i);
278-
expect(result!.remedy).toMatch(/transient|recoverable|raise --max-turns/i);
276+
// Does not editorialize: no "spinning" / "won't help" claim. It points the
277+
// reader at the detail and still offers the environment-blocker path.
278+
expect(result!.title).not.toMatch(/retrying a failing step/i);
279+
expect(result!.remedy).toMatch(/detail/i);
280+
expect(result!.remedy).toMatch(/environment|auth|credentials/i);
279281
});
280282

281283
test('classifies error_max_budget_usd as TIMEOUT with specific title', () => {

0 commit comments

Comments
 (0)