Skip to content

fix(agent): allow bounded repeated stall retries - #35642

Closed
OmarB97 wants to merge 23 commits into
NousResearch:mainfrom
OmarB97:fix/multiple-stall-retries-per-turn-upstream
Closed

fix(agent): allow bounded repeated stall retries#35642
OmarB97 wants to merge 23 commits into
NousResearch:mainfrom
OmarB97:fix/multiple-stall-retries-per-turn-upstream

Conversation

@OmarB97

@OmarB97 OmarB97 commented May 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replace the one-shot per-turn dflash stall retry guard with a bounded retry counter.
  • Add HERMES_STALL_RETRY_MAX_PER_TURN with a default of 5 retries per user turn.
  • Fail partial when the cap is exhausted instead of persisting another planning-only assistant response as final.
  • Add regression coverage for the second action-preamble stall from session 20260530_193349_ae35ee.

Root Cause

The previous retry guard used a single _stall_retry_used boolean for the whole user turn. In the new repro, the first dflash stall was correctly retried on Q6 and recovered into a real terminal tool call. After that tool result, the next dflash decision stalled again, but the boolean had already disabled recovery, so Hermes accepted Let me look at open tasks... as the final answer.

Stacking Note

This PR is stacked on #35638, which is itself stacked on #35620. The new commit in this stack is 39fcecb16 fix(agent): allow bounded repeated stall retries; once the earlier dflash retry PRs land, this should collapse to the bounded retry-counter change and tests.

Validation

  • scripts/run_tests.sh tests/agent/test_stall_retry.py
  • Verified the second repro fragment is retryable while completion text remains non-retryable.

Impact

Long dflash agentic turns can now recover from multiple independent no-tool-call stalls after tool progress, while still having a finite per-turn cap to prevent retry loops.

OmarB97 and others added 4 commits May 30, 2026 18:32
dflash (Qwen3.6-27B Q4_K_M, lucebox spec-decode) sometimes ends an agentic
decision turn with EOS right after a short action preamble ("Let me check
X:") and NO tool_call, stalling the loop. Higher-precision weights (the
stock qwen3.6-27b-256k lane on the same host) continue to a real tool call
on the identical prompt.

This adds agent/stall_retry.py: when a no-tool-call turn looks like that
stall (short, announces an action, not a genuine completion) and
HERMES_STALL_RETRY_MODEL is set, re-issue the SAME turn once on that lane;
if it yields tool calls, adopt it and continue. Same provider/endpoint so
only the model name is overridden (no client rebuild). Fires at most once
per conversation. No-op unless the env is set, so default behavior is
unchanged.

Validated: detector 17/17 on real captured stall contents, 0 false
positives on genuine completions; live retry recovered real stalls into
real tool calls (terminal/execute_code) against the live endpoint; runs
correctly under `hermes -z` (probe-confirmed, env propagated).
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists labels May 31, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Approved

Reviewed Changes

  • agent/stall_retry.py (new) — dflash Q4 premature-EOS detection and retry against a higher-quality lane.
  • agent/conversation_loop.py — Integration point: bounded per-turn retry count (HERMES_STALL_RETRY_MAX_PER_TURN, default 5), retry logic inserted before the final-response branch.
  • tests/agent/test_stall_retry.py — 8 tests: action preamble detection, completion distinction, incomplete fragments, retry model switching, loop integration assertions.

✅ Looks Good

  • Correctness: The stall detection in looks_like_stall() is well-calibrated — it distinguishes action preambles (Let me check X:) from genuine completions (Done., nothing to do). The action regex covers 12+ English patterns. The completion regex covers 15+ patterns. The incomplete-final-fragment heuristic (≥80 chars, no natural ending) catches dflash truncation mid-sentence.
  • Opt-in design: Entirely gated on HERMES_STALL_RETRY_MODEL — zero behavior change by default.
  • Safety: Retry is bounded per turn (default 5, env-configurable). The retry uses non-streaming for simplicity. Any exception during retry returns None, causing no harm. If the retry model equals the original, it short-circuits.
  • Test coverage: 8 unit tests covering stall detection, completion distinction, short answers, incomplete fragments, retry model switching, and loop integration. The source.indexOf pattern in the loop integration tests is a bit brittle but pragmatic for ensuring retry happens before tool-call check.
  • Documentation: The new stall_retry.py has an excellent module docstring explaining the problem, the solution, env variables, and design rationale.

Reviewed by Hermes Agent

@OmarB97

OmarB97 commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Pushed follow-up commit 8739ca04b for the latest taro/dflash failure mode: when the primary model returns an empty response immediately after tool output, route the same turn through the configured local stall-retry lane before generic/external fallback. This path can accept either tool calls or visible continuation text; the existing action-preamble stall path stays strict and still requires tool calls.

Validation:

  • python3 -m pytest tests/agent/test_stall_retry.py -q -> 12 passed
  • python3 -m py_compile agent/stall_retry.py agent/conversation_loop.py

The local fork mirror for deployment tracking is OmarB97#51.

@OmarB97

OmarB97 commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in f981551:

  • resolves the stall retry through the configured retry provider/base URL/key env instead of only swapping model on the active client;
  • classifies the observed phone failure shape (Onboarding complete. Now let me...) as an unfinished action promise rather than a final completion;
  • routes the per-turn retry cap through the shared stall-retry config resolver;
  • adds regression coverage for provider-specific retry auth, long action-promise tails, completion-then-action text, telemetry bounds, and empty-post-tool recovery ordering.

Verified locally:

  • python3 -m pytest tests/agent/test_stall_retry.py -q -> 21 passed
  • python3 -m py_compile agent/stall_retry.py agent/conversation_loop.py

Also deployed the same Python fix to the live taro Hermes install and verified the retry lane canary recovered through the configured taro provider instead of hitting the previous 401.

@OmarB97

OmarB97 commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in 0b86506 after live phone reproduction:

Root cause of the latest 401 screenshot was secondary fallback, not primary auth: local dflash returned empty content after the onboard tool result, Hermes exhausted same-model empty retries, then fell into stale opencode-zen fallback models, ending on a 401 Invalid API key.

This commit fixes the systemic retry-config issue that let that happen: an empty per-agent _stall_retry_config no longer masks the top-level stall_retry config from config.yaml. The retry lane now resolves stall_retry.model=qwen3.6-27b-256k / provider=taro from config and runs before generic fallback.

Verification:

  • python3 -m pytest tests/agent/test_stall_retry.py -q -> 22 passed
  • python3 -m py_compile agent/stall_retry.py agent/conversation_loop.py
  • live taro retry-lane canary: resolved config from empty agent config and recovered through qwen3.6-27b-256k on taro
  • live phone/TUI canary: ran meshctl.py onboard and got ONBOARD_OK instead of falling into opencode 401.

@OmarB97

OmarB97 commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up from live phone evidence at 2026-05-31 16:00 PDT:

The latest abrupt stop was a short visible final after a tool result, not a 401/auth failure and not an empty response:

I see a lot of discord-res tasks (digest Discord content) and some

Hermes accepted that as finish_reason=stop at API call #13, ending the turn. The previous heuristic only treated generic mid-sentence fragments as suspect at 80+ chars, so this 66-char connector-tail fragment slipped through.

Updated this PR with e9992858f:

  • catches short incomplete connector tails like and some;
  • uses get_stall_retry_model(agent) in the loop instead of env-only gating, so the merged config retry lane is honored;
  • lets the retry lane recover this short-fragment class with either tool calls or completed visible continuation.

Verification:

  • local python3 -m pytest tests/agent/test_stall_retry.py -q -> 25 passed
  • local python3 -m py_compile agent/stall_retry.py agent/conversation_loop.py
  • live taro .venv/bin/python -m pytest tests/agent/test_stall_retry.py -q -> 25 passed
  • live TUI canary returned DETECTOR_OK after checking the exact screenshot fragment

Replacement fork mirror because #53 was already merged: OmarB97#54

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Updated this PR with the post-#56 dflash stability work.

What changed:

  • Added the bounded scripts/dflash_stability_canary.py runner and focused tests.
  • Replaced the narrow last-5-message post-tool detector with has_recent_tool_result(...), scoped to the current user turn.
  • Kept empty post-tool dflash recovery on the configured local stall-retry lane before falling through to stale/global fallback providers.
  • Included the targeted TUI gateway regression assertion update needed by the current status payload shape.

Verification:

  • python3 -m pytest tests/agent/test_stall_retry.py tests/scripts/test_dflash_stability_canary.py -q -> 32 passed.
  • python3 -m pytest tests/test_tui_gateway_server.py::test_session_compress_uses_compress_helper tests/agent/test_stall_retry.py tests/scripts/test_dflash_stability_canary.py -q -> 33 passed.
  • python3 -m py_compile agent/stall_retry.py agent/conversation_loop.py scripts/dflash_stability_canary.py tests/agent/test_stall_retry.py tests/scripts/test_dflash_stability_canary.py tests/test_tui_gateway_server.py passed.
  • git diff --check passed.

Live taro evidence after fork mirror #56 merged:

  • 3 canary rounds / 9 probes passed on taro against /home/omar/Workspaces: onboard, STATUS read, and short-fragment detector all returned exact markers.
  • Evidence log: /home/omar/.hermes/logs/dflash-stability-canary/20260601T000055Z.jsonl.

Fork mirror tracking:

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed: the latest live phone trace showed repeated successful qwen rescues being counted as retry-budget exhaustion, and the next became slow because Hermes kept bouncing dflash -> qwen -> dflash with ~50k+ context.\n\nChanges now on this PR:\n- recovered retry-lane tool calls no longer consume the terminal stall budget; only unrecovered retry failures do\n- after repeated successful rescues (, default 2), Hermes promotes the configured retry lane for the rest of that user turn\n- the promotion is turn-scoped: normal primary runtime restoration brings dflash back on the next user turn\n- local telemetry records / events for postmortems\n\nVerification:\n- ..................................... [100%]
37 passed in 1.40s -> 37 passed\n- \n- \n- deployed the patch to taro live install and restarted ; TUI is back at ready on dflash with .\n\nFresh fork mirror PR for this follow-up: OmarB97#57

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed: the latest live phone trace showed repeated successful qwen rescues being counted as retry-budget exhaustion, and the next continue became slow because Hermes kept bouncing dflash -> qwen -> dflash with ~50k+ context.

Changes now on this PR:

  • recovered retry-lane tool calls no longer consume the terminal stall budget; only unrecovered retry failures do
  • after repeated successful rescues (stall_retry.promote_after, default 2), Hermes promotes the configured retry lane for the rest of that user turn
  • the promotion is turn-scoped: normal primary runtime restoration brings dflash back on the next user turn
  • local telemetry records runtime_promoted / promotion_skipped events for postmortems

Verification:

  • python3 -m pytest tests/agent/test_stall_retry.py tests/scripts/test_dflash_stability_canary.py tests/test_tui_gateway_server.py::test_session_compress_uses_compress_helper -q -> 37 passed
  • python3 -m py_compile agent/stall_retry.py agent/conversation_loop.py tests/agent/test_stall_retry.py
  • git diff --check
  • deployed the patch to taro live install and restarted hermes-phone; TUI is back at ready on dflash with promote_after=2.

Fresh fork mirror PR for this follow-up: OmarB97#57

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Updated this upstream stack with the latest dflash phone-session recovery fix.

Root cause addressed: after the dflash retry lane is promoted for the current turn, the promoted lane can itself return an action preamble with no tool call. The previous state machine treated that as terminal, so long mobile/TUI sessions could still stop even after recovery promotion.

Added in commit f9228d754:

  • allow same-model stall retry after runtime promotion
  • add a bounded same-turn corrective continuation when the retry lane also returns no tool call
  • add regression coverage for same-model promotion retry and the no-tool recovery cap

Validation run on the upstream-stack branch:

  • scripts/run_tests.sh tests/agent/test_stall_retry.py
  • python3 -m py_compile agent/conversation_loop.py agent/stall_retry.py tests/agent/test_stall_retry.py
  • git diff --check

Also deployed the equivalent fork-main commit to taro live Hermes and restarted hermes-phone; the live venv canary for the promoted same-model retry path passed.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in 10ad833d0 with the dflash first-turn/first-chunk hardening from the live failure investigation.

What changed:

  • Added a classifier-visible local_first_chunk_timeout failover reason so local streams that accept a request but emit no first chunk do not loop back into same-primary recovery.
  • Kept dflash bounded at the existing strict first-chunk timeout, and added a finite generic local fallback first-chunk timeout so the fallback lane cannot inherit an infinite local wait.
  • Updated the conversation loop so local first-chunk timeouts activate fallback before primary transport recovery.
  • Made hermes -z fail closed when the agent result is failed/error-only instead of printing Connection error. as if it were a successful assistant response.
  • Preserved the existing non-stream local behavior while bounding dflash non-stream stale calls too.

Verification:

  • scripts/run_tests.sh tests/agent/test_local_stream_timeout.py tests/agent/test_error_classifier.py tests/run_agent/test_run_agent.py tests/hermes_cli/test_tui_resume_flow.py -> 618 passed.
  • python3 -m py_compile run_agent.py agent/chat_completion_helpers.py agent/error_classifier.py agent/conversation_loop.py agent/agent_runtime_helpers.py hermes_cli/oneshot.py tests/agent/test_local_stream_timeout.py tests/agent/test_error_classifier.py tests/run_agent/test_run_agent.py tests/hermes_cli/test_tui_resume_flow.py -> passed.
  • git -c core.fsmonitor=false diff --check -> passed.

Live/taro evidence from the fork mirror path: the canary reproduced the old no-first-chunk dflash stall, then after the change it skipped same-primary recovery and went straight to fallback; aggressive stress settings now fail as structured nonzero exits instead of hanging indefinitely.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Added one more commit, 00d5900b9, for the automation gap we found during live hardening.

The earlier scripts/dflash_stability_canary.py intentionally ran finite canary passes only. That was useful for evidence, but it was not an owner. The new scripts/dflash_hardening_loop.py is the supervisor layer: it runs canary cycles until a failure, writes JSONL evidence, and can register/update a MeshBoard repair task with only sanitized failure metadata plus a local evidence-log link.

Additional verification after this commit:

  • scripts/run_tests.sh tests/agent/test_local_stream_timeout.py tests/agent/test_error_classifier.py tests/run_agent/test_run_agent.py tests/hermes_cli/test_tui_resume_flow.py tests/scripts/test_dflash_stability_canary.py -> 625 passed.
  • python3 -m py_compile run_agent.py agent/chat_completion_helpers.py agent/error_classifier.py agent/conversation_loop.py agent/agent_runtime_helpers.py hermes_cli/oneshot.py scripts/dflash_stability_canary.py scripts/dflash_hardening_loop.py tests/agent/test_local_stream_timeout.py tests/agent/test_error_classifier.py tests/run_agent/test_run_agent.py tests/hermes_cli/test_tui_resume_flow.py tests/scripts/test_dflash_stability_canary.py -> passed.
  • git -c core.fsmonitor=false diff --check HEAD~2..HEAD -> passed.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Update after live hardening loop:

  • Fixed the deeper local-backend starvation mode: local OpenAI-compatible endpoints now get a finite default max_tokens cap, so llama.cpp no longer receives max_tokens=-1/n_predict=-1 when Hermes omitted the field.
  • Local non-streaming calls are no longer implicitly infinite; generic local calls now have a bounded stale timeout, while dflash keeps its stricter timeout.
  • Local stall-retry calls stay streaming so the first-chunk watchdog can fire instead of creating another silent non-streaming GPU job.
  • Added an opt-in local backend recovery hook plus scripts/recover_local_llama_server.py for llama.cpp/llama-swap deployments where closing the client does not cancel the server-side generation.
  • Deployed to taro live Hermes, cleared the wedged qwen child that had decoded ~253k tokens, restarted hermes-phone with the recovery hook, and started dflash-hardening with --continue-after-failure.
  • Automated loop evidence now passes two full cycles across all three canary cases: /home/omar/.hermes/logs/dflash-stability-canary/20260601T060841Z.jsonl.

Verification:

  • scripts/run_tests.sh tests/agent/test_local_stream_timeout.py tests/agent/test_local_backend_recovery.py tests/agent/transports/test_chat_completions.py tests/agent/test_stall_retry.py => 170 passed
  • python3 -m py_compile agent/chat_completion_helpers.py agent/local_backend_recovery.py agent/stall_retry.py agent/transports/chat_completions.py run_agent.py scripts/recover_local_llama_server.py
  • taro live py_compile for deployed files

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed: fixed the Windows-footgun CI failure in the dflash diagnostics scripts. The runtime checks already guarded POSIX-only signals, but the static checker requires checker-visible guarded patterns, so this replaces bare signal references with getattr-guarded values and avoids os.kill(pid, 0).\n\nVerified locally:\n- python3 scripts/check-windows-footguns.py --all\n- python3 -m py_compile hermes_cli/oneshot.py scripts/recover_local_llama_server.py scripts/dflash_stability_canary.py\n- scripts/run_tests.sh tests/agent/test_local_stream_timeout.py tests/agent/test_local_backend_recovery.py tests/agent/transports/test_chat_completions.py tests/agent/test_stall_retry.py tests/scripts/test_dflash_stability_canary.py (178 passed)

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed for CI: updated the stale timeout test that still expected implicit local non-stream calls to disable the stale detector. That old expectation is the failure mode this PR fixes, so the test now asserts the new finite 120s local default and documents the local-backend default path.\n\nVerified locally after the change:\n- python3 -m py_compile run_agent.py tests/hermes_cli/test_timeouts.py\n- scripts/run_tests.sh tests/hermes_cli/test_timeouts.py tests/agent/test_local_stream_timeout.py tests/agent/test_local_backend_recovery.py tests/agent/transports/test_chat_completions.py tests/agent/test_stall_retry.py tests/scripts/test_dflash_stability_canary.py (190 passed in this upstream worktree)

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed from the live hardening loop: cycle 5 exposed a marker-mismatch where the onboard canary returned the status canary marker. I hardened the canary runner so every case invocation uses a unique nonce marker, which prevents static marker echo/cross-talk from being mistaken for a valid run.\n\nVerified locally:\n- python3 scripts/check-windows-footguns.py scripts/dflash_stability_canary.py scripts/dflash_hardening_loop.py tests/scripts/test_dflash_stability_canary.py\n- scripts/run_tests.sh tests/scripts/test_dflash_stability_canary.py tests/agent/test_local_stream_timeout.py tests/agent/test_local_backend_recovery.py tests/agent/transports/test_chat_completions.py tests/agent/test_stall_retry.py tests/hermes_cli/test_timeouts.py (192 passed in this upstream worktree)\n\nAlso deployed this to taro and restarted the dflash-hardening tmux loop.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed from the next hardening-loop failure: after nonce markers, the short-fragment canary failed because the prompt said 'current Hermes checkout' while the canary cwd is the MeshBoard workspace. That made the canary probe the wrong filesystem location. I changed the canary to inject the actual Hermes source root into the prompt.\n\nVerified locally:\n- python3 -m py_compile scripts/dflash_stability_canary.py tests/scripts/test_dflash_stability_canary.py\n- python3 scripts/check-windows-footguns.py scripts/dflash_stability_canary.py tests/scripts/test_dflash_stability_canary.py\n- scripts/run_tests.sh tests/scripts/test_dflash_stability_canary.py (11 passed)\n\nDeployed to taro and restarted dflash-hardening again.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Latest status after the source-root canary follow-up:\n\n- Fork PR #62 checks are green, including all 6 test shards, e2e, nix, ruff/ty, and Windows footguns. This upstream mirror PR currently reports no upstream status checks on the fork head branch.\n- The deployed taro dflash-hardening loop passed two fresh cycles with nonce markers and the corrected Hermes source-root prompt: evidence .\n\nI am leaving the MeshBoard tasks in review, not closed, until the relevant PRs are merged/closed and the task contract remains satisfied after settlement.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the previous status comment: the live taro evidence path is /home/omar/.hermes/logs/dflash-stability-canary/20260601T063707Z.jsonl. That log shows two fresh green dflash-hardening cycles with nonce markers and the corrected Hermes source-root prompt.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed from cycle-3 hardening evidence: the short-fragment canary prompt was still describing the old single-argument shape. It now probes the real API: looks_like_incomplete_final_fragment(content, "stop", False, 400). Verified with py_compile plus scripts/run_tests.sh tests/scripts/test_dflash_stability_canary.py tests/agent/test_stall_retry.py (47 passed). Deployed to taro and restarted dflash-hardening again.

@OmarB97

OmarB97 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Latest status: fork PR #62 checks are fully green after the final canary prompt fix. The upstream mirror PR head is updated to the same content; GitHub reports no upstream status checks on this fork head. The taro dflash-hardening loop passed three fresh full cycles on evidence /home/omar/.hermes/logs/dflash-stability-canary/20260601T064421Z.jsonl. Leaving MeshBoard tasks in review until PR settlement and post-settlement verification.

@OmarB97

OmarB97 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Split follow-up from hermes-pr35642-split-snowball-20260602: this PR is now superseded by smaller draft PRs, each with focused scope and validation.

I left this source PR open rather than closing it automatically, but it should be treated as a snowball reference branch, not the merge target. The smaller PRs are the reviewable path forward.

@OmarB97

OmarB97 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Closing this monolith now that the split PRs have been published for review: #37160, #37163, #37166, #37168, and #37176. Please use those focused PRs as the merge/review path; this branch remains only as historical context for the original snowball.

@OmarB97 OmarB97 closed this Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants