Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions agent/stall_retry.py
Original file line number Diff line number Diff line change
@@ -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 <think>...</think> block if present; judge the visible tail.
c = re.sub(r"^<think>.*?</think>\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
Loading