← Home
OpenHands/software-agent-sdk PR #4332 · show-me

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.

Head: 3f3a634. Base (before links): main. Prepared by OpenHands (AI) on behalf of the user.

1The change in one line

★ Key takeaway: the streak that used to end the run at 3 identical action-error pairs now first triggers a corrective nudge, and only becomes terminal if the model repeats the exact same failing call once more (4 pairs). The model finally gets told — in the loop — what it keeps getting wrong.
4files in diff
+335 / −63lines
1new test file
2run loops changed (run + arun)

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 repeated tool_name and its error the 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 both run() and arun() injects the nudge as an environment-sourced MessageEvent and lets the loop keep stepping, reserving STUCK for 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.

Before: 3 identical action-error pairs → STUCKEach agent step appends an ActionEvent and an AgentErrorEvent; at the 3rd identical pair the run loop sets execution_status to STUCK. agent.step() → ActionEvent + AgentErrorEvent, repeated pair 1call → error pair 2call → error pair 3call → error is_stuck()all last N equal + all errors✓ threshold reached (3) STUCKterminal · run stops Every iteration: if self._stuck_detector.is_stuck() → set STUCK Source (before): local_conversation.py:1872-1880 · detector: stuck_detector.py:177-197
Fig 1 · Before: the streak reaching the threshold is itself the terminal condition — no intervening chance to self-correct.
⚠ Asymmetry (the bug): a model that stalls with an empty/reasoning-only response already gets a corrective nudge and another turn (_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.

After: nudge at 3, hard STUCK at 4At the 3rd identical pair the loop injects an environment nudge and steps on; at the 4th identical pair it sets STUCK. pair 1call → error pair 2call → error pair 3call → error get_action_error_nudge()streak == threshold (3)→ nudge text (tool + error)err.id ≠ last_nudged → fire MessageEventsource=environment · nudge text loop continues → agent.step() again pair 4SAME call → SAME error is_stuck() now Truestreak > threshold (4 > 3) STUCKstill repeating after nudge
Fig 2 · After: the 3rd identical pair is diverted into a corrective nudge; only a 4th identical pair becomes terminal STUCK.
ⓘ How to read it: solid accent = the new nudge path this PR adds; the orange dashed edge is the delta. The green row "loop continues" is the self-correction window that did not exist before.

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:

LoopBeforeAfter
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.

new_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).

changed_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).

newget_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).

changedis_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).

ⓘ Scoped narrowly: only the 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):

Pairsis_stuck()get_action_error_nudge()
2 identicalFalseNone — before threshold
3 identicalFalsemessage returned (mentions tool + error)
4 identicalTrueNone — already nudged, now stuck
test_stuck_detector.py:378-446 asserts the nudge text names the tool (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 one environment nudge lands between the 3rd and 4th error, final status STUCK.
  • test_run_recovers_after_nudge_when_model_self_corrects — 3 identical failing calls then a different response: one nudge, conversation reaches FINISHED, never STUCK.
  • 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

ScenarioBehavior
Streak reaches threshold, then model changes its callOne 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
⚠ Deliberate shift: 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.