mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
d77cf5ae51
commit
05e3b864a9
6 files changed
+68
-11
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user