fix(bot-mode): the DM delivery retry reads the stream the CLI writes and resumes the persisted row

Both bot-to-bot DM retry gates (tools/bot_mode_dm.py::_run_local_turn and
tui_gateway/methods_bot_relay.py bot_relay.deliver) classified a failed turn from
`(proc.stderr or proc.stdout)`. A failed `hermes … -Q` turn prints the provider
prose on stdout (it is the turn's final_response) and `session_id: …` on stderr
on every run, so the classifier only ever saw the banner: every 429/5xx/context
overflow classified `unknown`, the policy-gated re-run never fired, and the relay
sender always got `reason: unknown`. Classify from both streams in the order the
CLI writes them (tools.bot_failure_reasons.turn_failure_text) at both sites.

Classifying alone double-delivers: the failed attempt's turn-start persist already
wrote the DM as the Bot Chat's unanswered tail row, and the re-run (a fresh
process replaying the same session + payload) appended a second identical copy.
The re-run's child env now carries HERMES_RESUME_UNANSWERED_TURN=1
(tools.bot_relay.retry_turn_env); the quiet one-shot consumes it before the turn
(hermes_cli.quiet_single_query.adopt_unanswered_turn) and re-stages the
identical unanswered tail row as the pending CLI user dict, stamped durable, so
_stage_turn_user_message reuses it and the flush writes no second row. Opt-in
by design: an identical tail alone cannot tell a re-run from a person's
deliberate re-send.

Slimmer redo of #105529 by @jonpol01 (same diagnosis and shape; the marker is
consumed at the CLI seam that already reads the dispatcher's env instead of
inside agent/turn_context.py).

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
This commit is contained in:
teknium1andJohn Paul Soliva authored and Teknium committed 2026-09-16 17:19:58 -07:00
1 parent d77cf5ae51
commit 05e3b864a9
6 files changed
+68 -11

No files matched your search

+4 -1
View File
@@ -4130,10 +4130,13 @@ def _run_quiet_single_query(cli, effective_query, emitter=None):
from agent.interrupt_compat import _accepts_keyword
from agent.turn_author import take_turn_author_from_env
from hermes_cli.quiet_single_query import (
bind_quiet_session_key, continue_quiet_notify_completions, quiet_notify_linger_seconds,
adopt_unanswered_turn, bind_quiet_session_key, continue_quiet_notify_completions,
quiet_notify_linger_seconds,
)
author = take_turn_author_from_env()
# A dispatcher's re-run of a failed bot delivery resumes the DM row its first attempt persisted.
adopt_unanswered_turn(cli, effective_query)
author_kwargs = {"turn_author": author} if author is not None and _accepts_keyword(cli.agent.run_conversation, "turn_author") else {}
with bind_quiet_session_key(getattr(cli, "session_id", "") or "default"):
try:
+29 -1
View File
@@ -11,8 +11,9 @@ was never injected as a follow-up.
from __future__ import annotations
import contextlib
import os
import time
from typing import Any, Callable
from typing import Any, Callable, MutableMapping
# Nested A→B→C is one extra turn; this caps a runaway message_agent chain.
_MAX_QUIET_NOTIFY_ROUNDS = 8
@@ -91,3 +92,30 @@ def continue_quiet_notify_completions(
if not texts:
return last
return last
def adopt_unanswered_turn(cli: Any, query: Any, environ: MutableMapping[str, str] = os.environ) -> bool:
"""A dispatcher's re-run of a failed delivery turn resumes the DM its first attempt already
persisted instead of appending it again. Returns True when the tail row was adopted.
The failed attempt's turn-start persist left the DM as the transcript's unanswered tail row. A
fresh process cannot know that by itself (``_DB_PERSISTED_MARKER`` is in-process only), and
inferring it from an identical tail alone would swallow a person's deliberate re-send — so the
dispatcher must say so with ``tools.bot_relay.RESUME_UNANSWERED_TURN_ENV``, consumed (popped) here
before the turn so tool subprocesses never inherit it. The tail is re-staged as the pending CLI
dict already stamped durable: ``_stage_turn_user_message`` reuses it as this turn's user message and
the flush writes no second row."""
from tools.bot_relay import RESUME_UNANSWERED_TURN_ENV
if environ.pop(RESUME_UNANSWERED_TURN_ENV, None) != "1":
return False
history = getattr(cli, "conversation_history", None)
tail = history[-1] if history else None
if not (isinstance(tail, dict) and tail.get("role") == "user" and tail.get("content") == query):
return False
from agent.context_compressor import _DB_PERSISTED_MARKER
history.pop()
tail[_DB_PERSISTED_MARKER] = True
cli.agent._pending_cli_user_message = tail
return True
+8
View File
@@ -80,6 +80,14 @@ _RULES: tuple[tuple[re.Pattern[str], str], ...] = tuple(
)
def turn_failure_text(stdout: str | None, stderr: str | None) -> str:
"""The error text of a failed ``hermes … -Q`` delivery turn: both streams, in the order the CLI
writes them. The provider prose is the turn's final_response and lands on STDOUT; stderr carries
session bookkeeping (``session_id: …``) on every run, so ``stderr or stdout`` only ever saw the
banner and every transient failure classified as ``unknown``."""
return "\n".join(text.strip() for text in (stdout, stderr) if text and text.strip())
def classify_agent_error(text: str) -> str:
"""Map raw agent/provider error text to a closed reason code (``unknown`` when unmatched/empty)."""
raw = str(text or "")
+8 -5
View File
@@ -390,16 +390,19 @@ def _run_local_turn(argv: list[str], dm_file: str, *, env: Optional[dict[str, st
same session; a context_overflow re-run lets the retried turn's pre-API compaction
compact the transcript first (no fresh session is ever minted). Auth/quota/config never retry."""
def _turn():
def _turn(turn_env=env):
return subprocess.run([*argv, "--query-file", dm_file], check=False, stdin=subprocess.DEVNULL,
capture_output=True, text=True, env=env)
capture_output=True, text=True, env=turn_env)
proc = _turn()
if proc.returncode != 0:
from tools.bot_failure_reasons import RETRY_NONE, classify_agent_error, retry_action
from tools.bot_failure_reasons import RETRY_NONE, classify_agent_error, retry_action, turn_failure_text
from tools.bot_relay import retry_turn_env
if retry_action(classify_agent_error((proc.stderr or proc.stdout or "").strip()[-500:])) != RETRY_NONE:
proc = _turn()
# The re-run replays the same session and payload; the failed attempt already persisted the
# user row, so the retried process is told to resume it (RESUME_UNANSWERED_TURN_ENV).
if retry_action(classify_agent_error(turn_failure_text(proc.stdout, proc.stderr))) != RETRY_NONE:
proc = _turn(retry_turn_env(env))
stderr_text = proc.stderr or ""
reason = next((line.removeprefix("hermes-refusal-reason: ").strip()
for line in stderr_text.splitlines()
+13 -1
View File
@@ -24,7 +24,7 @@ import sys
import time
import uuid
from pathlib import Path
from typing import Any, Iterator, Optional
from typing import Any, Iterator, Mapping, Optional
from tools.bot_mode_probe import _default_home, _hermes_root
from utils import atomic_json_write
@@ -78,6 +78,18 @@ _HANDLE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
# ``-c "Bot Chat"`` must match ``bot_mode_probe.BOT_CHAT_TITLE``.
BOT_CHAT_TURN_ARGS = ("chat", "--in", "~", "-c", "Bot Chat", "--create-if-missing", "-Q")
# Set by a dispatcher on the ONE policy-gated re-run of a failed delivery turn (``tools.bot_mode_dm``,
# ``tui_gateway.methods_bot_relay``). The failed attempt's turn-start persist already left the DM as the
# Bot Chat's unanswered tail row, and a fresh process cannot tell that from a new message on its own — so
# the re-run is told to adopt that row instead of appending a second copy
# (``hermes_cli.quiet_single_query.adopt_unanswered_turn``, which consumes the variable before the turn).
RESUME_UNANSWERED_TURN_ENV = "HERMES_RESUME_UNANSWERED_TURN"
def retry_turn_env(env: Optional[Mapping[str, str]]) -> dict[str, str]:
"""The re-run's child env: the first attempt's env plus the resume marker."""
return {**(os.environ if env is None else env), RESUME_UNANSWERED_TURN_ENV: "1"}
def relay_root(root: Path | str) -> Path:
return Path(root) / RELAY_DIR_NAME
+6 -3
View File
@@ -113,7 +113,8 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
return _ok(rid, {"reply": reply})
def _detail(p) -> str:
return (p.stderr or p.stdout or "").strip()[-500:]
from tools.bot_failure_reasons import turn_failure_text
return turn_failure_text(p.stdout, p.stderr)
turn_env = delivery_env(author, live_home)
@@ -137,14 +138,16 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
from tools.bot_failure_reasons import (
RETRY_NONE, classify_agent_error, retry_action)
if retry_action(classify_agent_error(_detail(proc))) != RETRY_NONE:
proc = _run(resolved, tmp, turn_env)
# The failed attempt already persisted the DM; the re-run resumes that row.
from tools.bot_relay import retry_turn_env
proc = _run(resolved, tmp, retry_turn_env(turn_env))
finally:
with contextlib.suppress(OSError):
os.unlink(tmp)
if proc.returncode != 0:
from tools.bot_failure_reasons import classify_agent_error
detail = _detail(proc)
return _err(rid, 5092, f"delivery turn failed: {detail or proc.returncode}",
return _err(rid, 5092, f"delivery turn failed: {detail[-500:] or proc.returncode}",
data={"reason": classify_agent_error(detail)})
# Use the same canonical whole-response predicate as live Bot Chat
# completion. A marker remains a successful turn, but is never sent