From 1badfcec8996fe3aad28eceaa0b89b628939701d Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Sat, 30 May 2026 16:57:40 -0700 Subject: [PATCH] feat(agent): retry stalled no-tool-call turns on a higher-quality lane 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). --- agent/conversation_loop.py | 33 +++++++++- agent/stall_retry.py | 127 +++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 agent/stall_retry.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bb6c6229cdb70..06d17e5f84aac 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -793,6 +793,11 @@ def run_conversation( should_review_memory=_should_review_memory, ) + # Agentic stall-retry guard: ensures the HERMES_STALL_RETRY_MODEL retry + # fires at most once per conversation (avoids loops if the retry lane also + # stalls). See agent/stall_retry.py. + _stall_retry_used = False + while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: # Reset per-turn checkpoint dedup so each iteration can take one snapshot agent._checkpoint_mgr.new_turn() @@ -3939,7 +3944,33 @@ def _stop_spinner(): else: # No tool calls - this is the final response final_response = assistant_message.content or "" - + + # ── Agentic stall-retry (opt-in via HERMES_STALL_RETRY_MODEL) ── + # dflash Q4 sometimes emits EOS right after an action preamble + # ("Let me check X:") with no tool_call, ending the turn early + # and stalling the agent mid-task. If this no-tool-call turn + # looks like that stall (not a genuine final answer) and a retry + # lane is configured, re-issue the SAME turn once on a + # higher-quality model; if it yields tool calls, adopt it and + # continue the loop instead of stopping. No-op unless the env is + # set, so default behavior is unchanged. + if not _stall_retry_used: + try: + from agent.stall_retry import looks_like_stall, retry_on_stall + if looks_like_stall( + final_response, finish_reason, + bool(getattr(assistant_message, "tool_calls", None)), + int(os.environ.get("HERMES_STALL_RETRY_MAX_CHARS", "400") or 400), + ): + _retried = retry_on_stall(agent, api_messages, finish_reason) + if _retried is not None and getattr(_retried, "tool_calls", None): + _stall_retry_used = True + assistant_message = _retried + finish_reason = "tool_calls" + continue # re-enter loop top; tool-calls path handles it + except Exception: + pass # any failure: keep the original response + # Fix: unmute output when entering the no-tool-call branch # so the user can see empty-response warnings and recovery # status messages. _mute_post_response was set during a diff --git a/agent/stall_retry.py b/agent/stall_retry.py new file mode 100644 index 0000000000000..06edb6ba0b784 --- /dev/null +++ b/agent/stall_retry.py @@ -0,0 +1,127 @@ +""" +Agentic stall-retry (dflash Q4 premature-EOS workaround). + +dflash (Qwen3.6-27B Q4_K_M, lucebox spec-decode) sometimes emits EOS right +after a short action preamble ("Let me check X:") on agentic decision turns, +ending the turn with NO tool_call -> the agent loop treats it as a final +answer and stops mid-task. Higher-precision weights (the stock Q6 lane on the +same host) continue to a real tool call on the identical prompt. + +This module detects that stall signature on a no-tool-call turn and retries +the SAME turn once against a higher-quality model lane. If the retry produces +tool_calls, the loop adopts that response and continues; otherwise the +original response stands (no behavior change). + +Entirely opt-in: does nothing unless ``HERMES_STALL_RETRY_MODEL`` is set +(e.g. ``qwen3.6-27b-256k``). Default-off => zero change to existing behavior. + +Env: + HERMES_STALL_RETRY_MODEL retry lane/model name (required to enable) + HERMES_STALL_RETRY_MAX_CHARS max content length to still count as a stall + (default 400; real final answers are longer) +""" +from __future__ import annotations + +import os +import re + +# Action-preamble signature: the turn announced an action but produced no tool +# call. These end mid-thought, typically with a colon, or open with intent. +_ACTION_RE = re.compile( + r"(let me\b|let's\b|i'?ll\b|i will\b|i'?m going to\b|i am going to\b|" + r"now i\b|first,?\s+i\b|next,?\s+i\b|i need to\b|i should\b|" + r"going to (check|look|run|start|examine|search|read|list|create|write|edit|use))", + re.IGNORECASE, +) +# Genuine completion signature: the model declared it is done / nothing to do. +# These must NOT be retried (they are correct no-tool-call turns). +_COMPLETION_RE = re.compile( + r"(\bdone\b|\bcomplete(d)?\b|nothing to (do|save|change|report|fix)|" + r"no changes?\b|no action\b|already (complete|done|finished)|\bfinished\b|" + r"all set\b|no further\b|nothing left\b|here('?s| is| are)\b|" + r"in summary\b|to summarize\b|the answer is\b)", + re.IGNORECASE, +) + + +def looks_like_stall(content: str, finish_reason: str, has_tool_calls: bool, + max_chars: int) -> bool: + """True when a no-tool-call turn looks like a premature agentic stall + (announced an action, didn't call a tool) rather than a real final answer.""" + if has_tool_calls: + return False + if finish_reason not in ("stop", "length"): + return False + c = (content or "").strip() + # Strip a leading ... block if present; judge the visible tail. + c = re.sub(r"^.*?\s*", "", c, flags=re.IGNORECASE | re.DOTALL).strip() + if not c: + return True # empty visible turn mid-task => stall + if len(c) > max_chars: + return False # long => almost certainly a real answer + if _COMPLETION_RE.search(c): + return False # model said it's done => respect it + if _ACTION_RE.search(c): + return True # announced an action, no tool call => stall + # Short prose that doesn't declare completion and isn't an obvious answer: + # a trailing colon strongly implies "about to do something". + if c.endswith(":"): + return True + return False + + +def retry_on_stall(agent, api_messages, finish_reason): + """If the just-finished no-tool-call turn looks like a stall and a retry + lane is configured, re-issue the SAME turn against that lane (same provider + / client / endpoint — only the model name changes) ONCE. + + Returns the normalized assistant_message from the retry IF it produced tool + calls (caller should adopt it + its finish_reason='tool_calls'), else None. + Never raises into the caller — any failure returns None (original stands). + """ + retry_model = os.environ.get("HERMES_STALL_RETRY_MODEL", "").strip() + if not retry_model: + return None + try: + max_chars = int(os.environ.get("HERMES_STALL_RETRY_MAX_CHARS", "400")) + except ValueError: + max_chars = 400 + + try: + # Build kwargs exactly as the normal turn would, then override only the + # model name. Safe when the retry lane is served by the SAME provider/ + # endpoint as agent.model (e.g. taro serves both dflash and the Q6 lane), + # so no client rebuild is needed. + api_kwargs = agent._build_api_kwargs(api_messages) + orig_model = api_kwargs.get("model") + if retry_model == orig_model: + return None # nothing to gain retrying the same model + api_kwargs = dict(api_kwargs) + api_kwargs["model"] = retry_model + # Force non-streaming for the retry (simpler, we only inspect the result). + api_kwargs.pop("stream", None) + api_kwargs["stream"] = False + + try: + agent._vprint( + f"{getattr(agent, 'log_prefix', '')}↻ stall detected " + f"(no tool call) — retrying turn on '{retry_model}'", + force=True, + ) + except Exception: + pass + + response = agent._interruptible_api_call(api_kwargs) + if response is None: + return None + transport = agent._get_transport() + normalize_kwargs = {} + if getattr(agent, "api_mode", None) == "anthropic_messages": + normalize_kwargs["strip_tool_prefix"] = getattr(agent, "_is_anthropic_oauth", False) + normalized = transport.normalize_response(response, **normalize_kwargs) + if getattr(normalized, "tool_calls", None): + return normalized + return None + except Exception: + # Any error => silently fall back to the original response. + return None