One corrective nudge before a repeating-tool-error run is hard-stuck
PR #4332 (issue #4331) changes what happens when a model calls the same tool, gets the same error, and does it again. Before: at the streak threshold the loop jumps straight to terminal STUCK. After: the threshold becomes a nudge — the loop injects a corrective message and gives the model one more turn — and only goes STUCK if the identical action-error pair survives one repeat past that nudge.
1The change in one line
The whole story splits into two pieces, one conceptual and one mechanical:
- The detector learns to describe the jam: a new
get_action_error_nudge()returns a message naming the repeatedtool_nameand itserrorthe moment a streak first reaches the threshold, while_is_stuck_repeating_action_error()is pushed to require one extra repeat before reporting a hard stuck (stuck_detector.py:208-250). - The run loop stops killing the conversation on sight: a new
_check_stuck_or_nudge()used by bothrun()andarun()injects the nudge as an environment-sourcedMessageEventand lets the loop keep stepping, reservingSTUCKfor a repeat that persists after that nudge (local_conversation.py:651-673).
2Before: instant hard-STUCK at the threshold
On main, once a model emitted the same failing tool call action_error_threshold times (default 3), the only answer the loop could give was terminal STUCK. The model only ever saw the tool's own validation-error text echoed back at it; there was no loop-level hint telling it what to change.
_send_corrective_nudge()), but a model that instead keeps emitting a malformed tool call never did. It only ever saw the tool's validation error echoed back, and if it didn't fix itself within 3 tries the conversation was killed unconditionally.3After: the threshold is a nudge, not a verdict
Now the run loop asks the detector for a nudge before asking whether it is stuck. On the streak's 3rd occurrence the detector returns a message naming the tool and error; the loop injects it as an environment MessageEvent and continues to agent.step(). The model gets one real chance to react. Only if the identical action-error pair persists one repeat past that nudge (4 pairs) does the loop set STUCK.
STUCK.The new gate _check_stuck_or_nudge() is shared by both run loops — the sync run() and async arun() now call it instead of inlining their own is_stuck() check:
| Loop | Before | After |
|---|---|---|
run() | inline is_stuck → STUCK | _check_stuck_or_nudge() :1897 |
arun() | inline is_stuck → STUCK | _check_stuck_or_nudge() :2090 |
4Inside the detector: how the streak is measured
The two detector changes are the load-bearing logic of the PR. Reading from the most recent event backward, the detector counts a streak: consecutive events where the action equals the first one and the observation is an AgentErrorEvent.
_action_error_streak()Returns the length of the trailing run of one action repeatedly erroring. Stops at the first action that differs from the reference or at the first non-error observation. This single helper is now shared by the nudge and the stuck check — one definition of "streak", two consumers (stuck_detector.py:192-206).
_is_stuck_repeating_action_error()Now requires the streak to be > threshold — one repeat past the threshold — before reporting a hard stuck. The threshold itself is no longer terminal (stuck_detector.py:208-216).
get_action_error_nudge()Returns nudge text when the streak equals the threshold, but only fires once per streak — a _last_nudged_error_event_id guard prevents re-firing while the streak stays frozen on the same error event (e.g. an empty response adding no new action). Returns None before the threshold and after the nudge (stuck_detector.py:218-250).
is_stuck()The collection window now grabs action_error_threshold + 1 pairs, so it can distinguish a fresh streak (== threshold, nudge) from one that already continued past the nudge (> threshold, stuck) (stuck_detector.py:118-127).
repeating_action_error pattern changed. The sibling repeating_action_observation pattern (identical non-error observation), the monologue, and the alternating patterns are untouched — they still go straight to STUCK at their own thresholds.5How it's verified
Unit — detector nudge lifecycle
tests/cross/test_stuck_detector.py::test_repeating_action_error_nudges_before_stuck walks the exact lifecycle with a real tool error (Command 'invalid_command' not found):
| Pairs | is_stuck() | get_action_error_nudge() |
|---|---|---|
| 2 identical | False | None — before threshold |
| 3 identical | False | ← message returned (mentions tool + error) |
| 4 identical | True | None — already nudged, now stuck |
terminal) and the error (Command 'invalid_command' not found), matching the PR description's "named-action nudge".Integration — a real run loop
New file tests/sdk/conversation/local/test_stuck_detector_nudge.py drives a live Conversation.run() with TestLLM and a tool whose executor always raises:
test_run_nudges_before_going_stuck_on_repeating_action_error— 4 identical failing calls: exactly oneenvironmentnudge lands between the 3rd and 4th error, final statusSTUCK.test_run_recovers_after_nudge_when_model_self_corrects— 3 identical failing calls then a different response: one nudge, conversation reachesFINISHED, neverSTUCK.test_run_does_not_renudge_action_error_while_streak_is_frozen— an empty response after the nudge must not re-fire the same nudge (the frozen-streak guard).
PR also runs ruff format --check, ruff check, and pre-commit on touched files. The full tests/cross + tests/sdk suites pass except a pre-existing, unrelated cipher/secrets test-pollution issue that reproduces identically on unmodified main.
6Edge cases and the deliberate behavior shift
| Scenario | Behavior |
|---|---|
| Streak reaches threshold, then model changes its call | One nudge; loop continues; conversation finishes normally (test:136-172) |
| Streak stays frozen on the same error (empty response) | The nudge does not re-fire — _last_nudged_error_event_id guard; only the unrelated EMPTY corrective nudge fires (test:174-200) |
| Streak continues past the nudge (4th identical pair) | Hard STUCK — identical action-error pair persists past the single nudge |
| Other stuck patterns (observation/monologue/alternating) | Unchanged — still hard STUCK at their own thresholds |
is_stuck() for the action-error pattern now trips one repeat later than before (4 identical pairs instead of 3 with default thresholds), because the repeat at the threshold is now used for the nudge. action_error_threshold still configures the same detector; the issue's own reproduction is the guide for why this is the right trade. Follow-ups like typed stuck-reason codes (#3565) are deliberately out of scope.