Files
hermes-agent/hermes_cli/cli_chat_turn_mixin.py
Teknium 485f5ce6ce fix(cli): the paused-chat refusal prints as written; /discard says when another lost turn remains
The refusal behind a lost turn went through the final-response markdown
renderer: `strip` rewrote the quoted prompt (`*bug*`, `__init__.py`,
backticks) and `render` merged the `/discard <id>` line into one
paragraph. A gateway refusal (failure_reason "gateway_connection") is
Hermes's own copy, so the panel now prints it raw.

/discard said "You can send messages again" even when another lost turn
still refused the next message. It now reads the refreshed pending list
and says how many paused turns are left, and uses "Discarded ... it was
not re-run" like messaging's gateway.discard.done (a kept result is not
thrown away).
2026-10-27 16:46:52 -07:00

798 lines
45 KiB
Python

"""chat() and its per-turn phases: image routing, staging, agent thread, interrupt monitor, rendering.
Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY
inside each method — importing ``cli`` at module load time would be a cycle.
"""
from __future__ import annotations
import logging
import os
import queue
import sys
import threading
import time
from pathlib import Path
from rich import box as rich_box
from rich.panel import Panel
from typing import Optional
from agent.i18n import t
from hermes_cli.cli_agent_setup_mixin import _retire_agent
class CLIChatTurnMixin:
"""chat() and its per-turn phase helpers."""
# Last completed turn's raw agent result. chat() returns only the rendered
# response string, so one-shot callers that must map an outcome onto a
# process exit code (see cli._run_single_query_mode) read this instead.
_last_turn_result = None
def _sync_fallback_chain_with_config(self, agent) -> None:
"""Adopt ``fallback_providers`` edits made while this chat is open (#95066) — the same
per-turn, fail-closed contract as the Desktop/TUI and messaging gateways: a torn config.yaml
keeps the last known-good chain instead of reading as "chain removed"."""
from cli import logger
try:
from gateway.run import GatewayRunner
from hermes_cli.config_effective import load_user_config_effective
from hermes_cli.fallback_config import get_fallback_chain
self._fallback_model = get_fallback_chain(load_user_config_effective(fail_closed=True))
except Exception as e:
logger.debug("fallback chain sync skipped (keeping current chain): %s", e)
return
GatewayRunner._apply_fallback_chain_to_agent(agent, self._fallback_model)
def chat(self, message, images: list | None = None, voice_input: bool = False) -> Optional[str]:
"""Run one user turn; returns the agent's response, or None on error.
Input typed while the agent runs goes to ``_interrupt_queue`` (separate from
``_pending_input`` so process_loop and the interrupt monitor never compete); an
interrupting message is re-queued as the next turn. ``voice_input`` gates the
concise voice-response prefix.
Args: message: The user's message (str or multimodal content list) images: Optional list of Path
objects for attached images voice_input: True when the message came from voice transcription (gates
the concise voice-response prefix, #65827)
"""
from cli import ChatConsole, _ChatTurn, _DIM, _RST, _accent_hex, _cprint, set_secret_capture_callback
from tools.process_registry_notifications import TimelineNotification
# Single-query and direct chat callers do not go through run().
set_secret_capture_callback(self._secret_capture_callback)
# Reset per turn; only a real interrupt flips it, so early returns leave it False.
self._last_turn_interrupted = False
self._last_turn_failed = False
if not self._ensure_runtime_credentials():
return None
turn_route = self._resolve_turn_agent_config(message)
if turn_route["signature"] != self._active_agent_route_signature:
_retire_agent(self)
# A first-turn agent build shows in the live spinner (the spinner thread repaints it), never
# as a scrollback line.
initializing = t("cli.chat.initializing_agent") if self.agent is None else ""
if initializing:
self._spinner_text = initializing
try:
if not self._init_agent(model_override=turn_route["model"], runtime_override=turn_route["runtime"],
request_overrides=turn_route.get("request_overrides")):
return None
finally:
if initializing and getattr(self, "_spinner_text", "") == initializing:
self._spinner_text = ""
agent = self.agent
if agent is None:
return None
self._sync_fallback_chain_with_config(agent) # chain added after this chat opened reaches this turn
message = self._chat_route_images(message, images)
if isinstance(message, str) and not isinstance(message, TimelineNotification):
# ``@`` references are expanded once, by the gateway, against this session's cwd.
# Lone surrogates (rich-text clipboard paste) crash the OpenAI SDK's JSON serialization.
from agent.message_sanitization import _sanitize_surrogates
message = _sanitize_surrogates(message)
self._chat_stage_user_message(agent, message)
if isinstance(message, TimelineNotification):
message = str(message) # UI metadata is on the staged row, never in model content.
if not getattr(self, "_modern_layout", False):
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
_cprint("")
from agent.notification_presentation import notification_config_snapshot, notification_policy_snapshot
with notification_policy_snapshot(agent, "cli", notification_config_snapshot()):
turn = _ChatTurn()
from gateway.warning_notifications import diagnostic_turn_muted
turn.mute_notification_reply = diagnostic_turn_muted(
agent._pending_cli_user_message.get("display_metadata"), "cli", agent._notification_config)
try:
self._reset_stream_state()
# Not part of _reset_stream_state: must persist across intermediate turn
# boundaries (tool-calling loops), reset once per user turn.
self._reasoning_shown_this_turn = False
self._streamed_text_this_turn = ""
self._chat_setup_turn_audio(turn, message, voice_input)
# Per-prompt elapsed timer — frozen when the agent thread finishes.
self._prompt_start_time = time.time()
self._prompt_duration = 0.0
# Daemon: closing the terminal tab (SIGHUP) must not be kept alive by it.
agent_thread = threading.Thread(target=self._chat_run_agent, args=(turn, message), daemon=True)
agent_thread.start()
interrupt_msg = self._chat_monitor_agent_thread(turn, agent_thread)
self._chat_settle_turn(turn)
return self._chat_render_turn(turn, agent_thread, interrupt_msg)
except Exception as e:
_cprint(t("gateway.model.error_prefix", error=e))
return None
finally:
self._chat_release_turn_audio(turn)
def _chat_release_turn_audio(self, turn):
"""Every exit path: stop the thinking sound, send the TTS sentinel, cut TTS only if abnormal."""
from cli import logger
if turn.thinking_started:
try:
from tools.voice_mode import stop_thinking_sound
stop_thinking_sound()
except Exception:
pass
# Safety-net sentinel for exception paths that skipped _chat_settle_turn's; a
# duplicate is harmless (stream_tts_to_speaker exits on the first None).
# stop_event only on abnormal exit: after a normal drain it would race the
# playback worker and cut the final sentence mid-audio.
if turn.text_queue is not None:
try:
turn.text_queue.put_nowait(None)
except Exception:
pass
if turn.stop_event is not None and not turn.tts_normal_exit:
logger.info("TTS CUT: exception finally block setting stop_event")
turn.stop_event.set()
if turn.tts_thread is not None and turn.tts_thread.is_alive():
turn.tts_thread.join(timeout=5)
def _chat_route_images(self, message, images):
"""Attach images natively (vision model) or pre-describe them as text; returns the message to send.
"native" → OpenAI-style content parts (adapters translate per provider); "text" →
vision_analyze each image and prepend the description. Decision: agent/image_routing.py.
"""
from cli import _DIM, _RST, _cprint, _split_model_config_default
if not images:
return message
text = message if isinstance(message, str) else ""
try:
from agent.image_routing import build_native_content_parts, decide_image_input_mode
from hermes_cli.config import load_config
_img_model = (_split_model_config_default(self.model)[0]
if isinstance(self.model, dict) else str(self.model or ""))
_img_provider = (_split_model_config_default(self.provider)[1]
if isinstance(self.provider, dict) else str(self.provider or ""))
_img_mode = decide_image_input_mode(
_img_provider.strip(), _img_model.strip(), load_config(),
requested_provider=(self.requested_provider or "").strip(),
)
except Exception as _img_exc:
logging.debug("image_routing decision failed, defaulting to text: %s", _img_exc)
_img_mode = "text"
if _img_mode == "native":
try:
_img_str_paths = [str(p) for p in images]
_parts, _skipped = build_native_content_parts(text, _img_str_paths)
if _skipped:
_cprint(f" {_DIM}{t('cli.chat.skipped_unreadable_images', count=len(_skipped))}{_RST}")
if any(p.get("type") == "image_url" for p in _parts):
_img_names = ", ".join(Path(p).name for p in _img_str_paths)
_cprint(f" {_DIM}{t('cli.chat.attaching_images_natively', count=len(images), names=_img_names)}{_RST}")
return _parts
# All images unreadable — fall back to text enrichment.
except Exception as _img_exc:
logging.warning("native image attach failed, falling back to text: %s", _img_exc)
return self._preprocess_images_with_vision(text, images)
def _chat_stage_user_message(self, agent, message):
"""Append the staged user dict to the transcript under the agent's persist lock."""
# Copy before appending: mutating ``agent._session_messages`` in this UI-only step
# would expose a duplicate-prone snapshot to terminal-close persistence.
if self.conversation_history is getattr(agent, "_session_messages", None):
self.conversation_history = list(self.conversation_history)
# Clear the prior turn's override before exposing the new staged input: a shutdown
# before the worker prologue would otherwise persist old API-local text as this message.
import contextlib
from agent.message_metadata import stamp_message_timestamp
persist_lock = getattr(agent, "_session_persist_lock", None)
with persist_lock if persist_lock is not None else contextlib.nullcontext():
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
staged_user_message = stamp_message_timestamp({"role": "user", "content": message})
from tools.process_registry_notifications import TimelineNotification
if isinstance(message, TimelineNotification):
staged_user_message.update(content=str(message), display_kind=message.display_kind,
display_metadata={"display_text": message.display_text,
"notification_category": message.notification_category})
agent._pending_cli_user_message = staged_user_message
self.conversation_history.append(staged_user_message)
def _chat_setup_turn_audio(self, turn, message, voice_input):
"""Arm the full-duplex listener and the streaming-TTS pipeline for this turn (voice mode only)."""
from cli import _ACCENT, _RST, _STREAM_PAD, _cprint, datetime
if getattr(turn, "mute_notification_reply", False):
return
# Continuous voice mode: arm the mic NOW (utterance-submit), not at TTS playback —
# it spans generation (speech interrupts the turn) and playback (speech cuts TTS)
# and disarms itself when the turn is done. See _voice_full_duplex_listener.
if self._voice_mode and self._voice_continuous:
self._voice_last_tts_text = ""
threading.Thread(target=self._voice_full_duplex_listener, daemon=True).start()
# Streaming TTS: any working provider speaks sentence-by-sentence as tokens arrive.
# Availability is check_tts_requirements() ALONE: the speaker side picks its own
# output route per platform (PortAudio stream where usable, tempfile -> afplay on
# macOS — sounddevice is deliberately never imported for output on Darwin, see
# tts_tool_speaker._device_usable), so probing it here disabled streaming TTS on
# macOS via the bare except below (#84046).
if self._voice_tts:
try:
from tools.tts_tool import check_tts_requirements
from tools.tts_tool_speaker import stream_tts_to_speaker
turn.use_streaming_tts = check_tts_requirements()
except Exception:
logging.debug("streaming TTS arm check failed", exc_info=True)
if turn.use_streaming_tts:
turn.text_queue = queue.Queue()
turn.stop_event = threading.Event()
# display_callback only when token streaming is off: with streaming on,
# _stream_delta already renders the text and this would print it twice.
def display_callback(sentence: str):
if not turn.box_opened and getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import answer_label
turn.box_opened = True
_cprint(answer_label())
if not turn.box_opened:
turn.box_opened = True
label = " ☤ Hermes "
if self.show_timestamps:
label = f"{label}{datetime.now().strftime(self.timestamp_format)} "
w = self._scrollback_box_width(getattr(self.console, "width", 80))
fill = w - 2 - self._status_bar_display_width(label)
_cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}")
_cprint(f"{_STREAM_PAD}{sentence.rstrip()}")
turn.tts_thread = threading.Thread(
target=stream_tts_to_speaker, args=(turn.text_queue, turn.stop_event, self._voice_tts_done),
kwargs={"display_callback": None if self.streaming_enabled else display_callback},
daemon=True,
)
turn.tts_thread.start()
# Barge-in paths (voice key, full-duplex listener) cut playback via this event.
self._voice_tts_stop = turn.stop_event
def stream_callback(delta: str):
turn.text_queue.put(delta)
# Track what is being spoken so a playback-phase barge capture can be
# checked against it (echo guard).
self._voice_last_tts_text = (self._voice_last_tts_text or "") + delta
turn.stream_callback = stream_callback
# API-call-local only — run_conversation persists the original clean user message.
if voice_input and isinstance(message, str):
turn.voice_prefix = ("[Voice input — respond concisely and conversationally, "
"2-3 sentences max. No code blocks or markdown.] ")
def _chat_run_agent(self, turn, message):
"""Agent-thread body: bind per-thread callbacks/approval key, prepend one-shot notes, run the turn."""
from cli import (
_prepend_note_to_message, set_approval_callback, set_secret_capture_callback,
set_sudo_password_callback,
)
from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback
# terminal_tool callbacks are thread-local: run()'s registration is invisible here.
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
set_secret_capture_callback(self._secret_capture_callback)
set_unlock_prompt_callback(self._vault_unlock_callback)
set_save_login_prompt_callback(self._vault_save_login_callback)
set_code_prompt_callback(self._vault_code_callback)
# Bind the approval session key so ``is_current_session_yolo_enabled()`` resolves
# against the same key ``/yolo`` toggles under (``enable_session_yolo(self.session_id)``).
try:
from tools.approval_context import reset_current_session_key, set_current_session_key
_approval_session_token = set_current_session_key(self.session_id or "default")
except Exception:
reset_current_session_key = None # type: ignore[assignment]
_approval_session_token = None
agent_message = turn.voice_prefix + message if turn.voice_prefix else message
self.agent._voice_turn_pending = bool(turn.voice_prefix) # auxiliary.voice_chat route
# One-shot /model and /reload-skills notes; _prepend_note_to_message also handles
# multimodal content-part lists (string concat raised TypeError with an image).
_taken_notes = {}
for _note_attr in ("_pending_model_switch_note", "_pending_skills_reload_note"):
_note = getattr(self, _note_attr, None)
if _note:
agent_message = _prepend_note_to_message(agent_message, _note)
_taken_notes[_note_attr] = _note
setattr(self, _note_attr, None)
# Barged mid-speech (VAD or record key)? Tell the model it was cut off.
from tools.tts_streaming import SPEECH_INTERRUPTED_NOTE, take_speech_interrupted
if take_speech_interrupted():
agent_message = _prepend_note_to_message(agent_message, SPEECH_INTERRUPTED_NOTE)
_moa_cfg = getattr(self, "_pending_moa_config", None)
self._pending_moa_config = None
# Notes and voice prefix are API-local: the staged input stays the durable transcript
# value so a close-path marker follows the same dict instead of a second user row.
_persist_clean_user_message = message if (turn.voice_prefix or agent_message != message) else None
_one_turn_model_restore = getattr(self, "_pending_one_turn_model_restore", None)
self._pending_one_turn_model_restore = None
# Set only by a dispatcher-spawned `-q` run; a human turn keeps today's call shape.
from agent.interrupt_compat import _accepts_keyword
_author = getattr(self, "_single_query_turn_author", None)
_author_kwargs = ({"turn_author": _author}
if _author is not None and _accepts_keyword(self.agent.run_conversation, "turn_author") else {})
try:
from agent.notification_presentation import notification_turn
muted = getattr(turn, "mute_notification_reply", False)
with notification_turn(self.agent, muted=muted, session_id=self.session_id):
turn.result = self.agent.run_conversation(
user_message=agent_message,
conversation_history=self.conversation_history[:-1],
stream_callback=None if muted else turn.stream_callback, task_id=self.session_id,
persist_user_message=_persist_clean_user_message, moa_config=_moa_cfg,
**_author_kwargs,
)
if getattr(self, "_pending_moa_disable_after_turn", False):
_restore = getattr(self, "_pending_moa_restore_model", None) or {}
for _key, _value in _restore.items():
if _value is not None:
setattr(self, _key, _value)
_retire_agent(self)
self._pending_moa_restore_model = None
self._pending_moa_disable_after_turn = False
except Exception as exc:
logging.error("run_conversation raised: %s", exc, exc_info=True)
_summary = getattr(self.agent, '_summarize_api_error', lambda e: str(e)[:300])(exc)
from hermes_cli.cli_chat_error_copy import chat_error_response
turn.result = {
"final_response": chat_error_response(
exc, provider=str(getattr(self.agent, "provider", "") or self.provider or ""),
model=str(getattr(self.agent, "model", "") or self.model or "")),
"messages": [], "api_calls": 0,
"completed": False, "failed": True, "error": _summary,
}
finally:
_unadmitted = (turn.result or {}).get("admitted") is False
if _unadmitted: # refused before the submit: the resend still owes the notes
for _note_attr, _note in _taken_notes.items():
setattr(self, _note_attr, _note)
if _one_turn_model_restore and (_unadmitted or (turn.result or {}).get("failure_reason") == "session_busy"):
# The owner admitted nothing: the resend is still the one turn the pick was for.
self._pending_one_turn_model_restore = _one_turn_model_restore
elif _one_turn_model_restore:
self._restore_model_runtime_snapshot(_one_turn_model_restore)
# Credit notices paint cleanly above the prompt here, not behind streamed output.
self._flush_credit_notices()
# A reused thread must never hold stale references to a disposed CLI instance.
try:
set_sudo_password_callback(None)
set_approval_callback(None)
set_secret_capture_callback(None)
set_unlock_prompt_callback(None)
set_save_login_prompt_callback(None)
set_code_prompt_callback(None)
except Exception:
pass
# Unbind the per-turn key; ``_session_yolo`` state itself persists across turns.
if _approval_session_token is not None and reset_current_session_key is not None:
try:
reset_current_session_key(_approval_session_token)
except Exception:
pass
def _chat_monitor_agent_thread(self, turn, agent_thread):
"""Poll the interrupt queue while the agent thread runs; returns the interrupting message (or None)."""
from cli import _cprint, _hermes_home, logger
# Ambient "thinking" blips in voice mode; skipped per-blip while TTS speaks, the mic
# records or a barge capture is live. voice.thinking_sound gates it (default on).
if self._voice_mode:
try:
from tools.voice_mode import start_thinking_sound
turn.thinking_started = start_thinking_sound(should_play=lambda: (
self._voice_tts_done.is_set() and not self._voice_recording
and not self._voice_barge_capture.is_set()))
except Exception:
turn.thinking_started = False
interrupt_msg = None
while agent_thread.is_alive():
try:
interrupt_msg = self._interrupt_queue.get(timeout=0.1)
except queue.Empty:
# Flush the StdoutProxy buffer: it otherwise only flushes on input-triggered
# renderer passes, so on macOS the CLI looks frozen until the user types.
# Force prompt_toolkit to flush any pending stdout output from the agent thread. (#1624)
self._invalidate(min_interval=0.15)
continue
if not interrupt_msg:
continue
# With a clarify question active, Enter routes to the clarify queue; anything
# landing here is a race — don't interrupt, park it as the next turn.
if self._clarify_state or self._clarify_freetext:
try:
self._pending_input.put(interrupt_msg)
except Exception:
pass
interrupt_msg = None
continue
_cprint(f"\n{t('cli.chat.new_message_interrupting')}")
if turn.stop_event is not None:
turn.stop_event.set()
self.agent.interrupt(interrupt_msg)
# Modal prompts gate input until reset — otherwise the CLI freezes after an
# interrupt until the prompt's own timeout.
self._clear_active_overlays_for_interrupt()
# Debug log to file (stdout may be devnull under redirect_stdout).
try:
with open(_hermes_home / "interrupt_debug.log", "a", encoding="utf-8") as _f:
_f.write(f"{time.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, "
f"children={len(self.agent._active_children)}, "
f"parent._interrupt={self.agent._interrupt_requested}\n")
_f.writelines(f" child[{_ci}]._interrupt={_ch._interrupt_requested}\n" for _ci, _ch in enumerate(self.agent._active_children))
except Exception:
pass
break
if interrupt_msg is not None:
# After an interrupt the agent may take seconds to clean up (kill
# subprocess, persist). Poll instead of a blocking join so another
# interrupt (Ctrl+C sets _should_exit) or a stuck agent can't freeze
# us; the thread is daemon and dies on process exit regardless.
for _ in range(50): # 50 * 0.2s = 10s max
agent_thread.join(timeout=0.2)
if not agent_thread.is_alive() or self._should_exit:
break
if agent_thread.is_alive():
logger.warning(
"Agent thread still alive after interrupt "
"(thread %s). Daemon thread will be cleaned up "
"on exit.",
agent_thread.ident,
)
else:
agent_thread.join(timeout=30) # should be done already; guard edge cases
return interrupt_msg
def _chat_settle_turn(self, turn):
"""After the agent thread ends: freeze timers, flush streams, drain TTS, sync history/session id."""
if self._prompt_start_time is not None:
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
self._prompt_start_time = None
self._last_turn_finished_at = time.time() # status bar idle time
self._last_turn_result = turn.result
# AsyncOpenAI clients bound to the worker's now-closed loop would crash
# prompt_toolkit's loop from __del__ on GC.
try:
from agent.auxiliary_client import cleanup_stale_async_clients
cleanup_stale_async_clients()
except Exception:
pass
self._flush_stream()
if turn.use_streaming_tts and turn.text_queue is not None:
turn.text_queue.put(None) # end-of-text sentinel
if turn.tts_thread is not None:
turn.tts_thread.join(timeout=120)
# A timed-out join leaves tts_normal_exit False so the release path's
# stop_event kills the runaway worker.
turn.tts_normal_exit = not turn.tts_thread.is_alive()
# Drain the StdoutProxy buffer so tool/status lines render ABOVE the response
# box; the sleep lets the renderer paint before we draw.
sys.stdout.flush()
time.sleep(0.15)
if turn.result:
self.conversation_history = turn.result.get("messages", self.conversation_history)
# Mid-turn auto-compression continues in a child session: sync so /status, /resume,
# titling and the exit summary target the live child, not the ended parent.
if (self.agent and getattr(self.agent, "session_id", None)
and self.agent.session_id != self.session_id):
from tools.approval_yolo import transfer_session_yolo
transfer_session_yolo(self.session_id, self.agent.session_id)
self.session_id = self.agent.session_id
self._write_terminal_breadcrumb()
self._pending_title = None
def _handle_discard_command(self, cmd_original: str):
"""/discard <admission_id>: acknowledge a turn the gateway lost in a restart without replaying
it (the owner's fenced ``prompt.resolve_unknown``), so prompts may run again."""
from cli import _cprint
from hermes_cli.gateway_chat_view import lost_admission
from hermes_cli.gateway_client import GatewayClientError
from hermes_cli.gateway_side import connection_for
admission_id = cmd_original.partition(" ")[2].strip()
if not admission_id:
_cprint(" Usage: /discard <admission_id>")
return
connection = connection_for(self)
if connection is None:
_cprint(" /discard: no gateway session is open")
return
try:
lost = lost_admission(connection.reattach(self.session_id), admission_id)
connection.call("prompt.resolve_unknown", session_id=self.session_id,
admission_id=lost["admission_id"], execution_generation=lost["execution_generation"])
# The cached pending list the next prompt checks: any other lost turn still refuses it.
snapshot = connection.reattach(self.session_id)
except GatewayClientError as exc:
_cprint(f" /discard: {exc}")
return
left = sum(row["status"] == "unknown" for row in snapshot.get("pending", []))
_cprint(f" Discarded {lost['admission_id']}; it was not re-run. "
+ (f"{left} more paused turn{'s' if left > 1 else ''} to /discard first." if left
else "You can send messages again."))
def _chat_render_turn(self, turn, agent_thread, interrupt_msg):
"""Post-turn display: errors, interrupt marker, reasoning/response panels, bell, re-queues.
Returns the response text.
"""
from cli import _DIM, _RST, _cprint, _suspend_output_history
response = turn.result.get("final_response", "") if turn.result else ""
if getattr(turn, "mute_notification_reply", False):
pending, _ = self._chat_resolve_interrupt(turn, agent_thread, interrupt_msg, response)
if pending:
self._pending_input.put(pending)
return None
# "failed"/"partial" with an empty final_response: no usable answer.
if turn.result and (turn.result.get("failed") or turn.result.get("partial")) and not response:
from hermes_cli.cli_chat_error_copy import chat_error_response
response = chat_error_response(
str(turn.result.get("error") or t("cli.chat.unknown_error")),
provider=str(getattr(self.agent, "provider", "") or self.provider or ""),
model=str(getattr(self.agent, "model", "") or self.model or ""),
failure_reason=turn.result.get("failure_reason"))
# Stop continuous voice on persistent errors (e.g. 429) — else error→record→error loops.
if self._voice_continuous:
self._voice_continuous = False
_cprint(f"\n{_DIM}{t('cli.chat.continuous_voice_stopped')}{_RST}")
pending_message, _show_interrupt_marker = self._chat_resolve_interrupt(
turn, agent_thread, interrupt_msg, response)
self._chat_print_reasoning_box(turn)
self._chat_print_response_panel(turn, response)
# History suppressed so the marker is never recorded in _OUTPUT_HISTORY
# (appending it to `response` duplicated it on redraw).
if _show_interrupt_marker:
with _suspend_output_history():
_cprint(f"\n{_DIM}{t('cli.chat.interrupted_marker')}{_RST}")
# Focus view: "⋯ N tool lines hidden" after the answer; resets the counter.
try:
self._emit_focus_recovery_line()
except Exception:
pass
self._ring_bell(context=t("cli.modal.bell_turn_complete")) # propagates over SSH
if turn.result and not turn.result.get("completed") and not turn.result.get("interrupted"):
_api_calls = turn.result.get("api_calls", 0)
_max_iter = getattr(self.agent, "max_iterations", 500)
if _api_calls >= _max_iter:
from gateway.warning_notifications import render_notification
render_notification(
lambda: _cprint(
f"\n{_DIM}{t('cli.chat.iteration_budget_reached', used=_api_calls, max=_max_iter)}{_RST}"
),
platform="cli", user_config=getattr(self.agent, "_notification_config", None))
# Batch TTS unless streaming TTS already spoke the response.
if self._voice_tts and response and not turn.use_streaming_tts:
self._voice_speak_response_async(response)
# Re-queue the interrupt message (plus any that arrived meanwhile) as the next
# prompt. Only reached in busy_input_mode == "interrupt"; "queue" mode routes
# Enter straight to _pending_input.
if pending_message:
all_parts = [pending_message]
while not self._interrupt_queue.empty():
try:
extra = self._interrupt_queue.get_nowait()
if extra:
all_parts.append(extra)
except queue.Empty:
break
# Payloads may be (text, images) tuples when the message carried image
# attachments (bundled at cli_tui_mixin._tui_on_enter); unpack them here —
# "\n".join(all_parts) raises TypeError on a tuple and the outer
# handler swallows it, silently dropping the interrupt (#110737).
text_parts: list[str] = []
image_parts: list = []
for part in all_parts:
if isinstance(part, tuple):
part_text, part_images = part
text_parts.append(part_text)
image_parts.extend(part_images or [])
else:
text_parts.append(part)
combined = "\n".join(text_parts)
payload = (combined, image_parts) if image_parts else combined
preview = combined[:50] + ("..." if len(combined) > 50 else "")
if len(all_parts) > 1:
_cprint(f"\n{t('cli.chat.sending_after_interrupt_multi', count=len(all_parts), preview=preview)}")
else:
_cprint(f"\n{t('cli.chat.sending_after_interrupt', preview=preview)}")
self._pending_input.put(payload)
# A /steer the agent finished before absorbing becomes the next user turn.
_leftover_steer = turn.result.get("pending_steer") if turn.result else None
if _leftover_steer:
preview = _leftover_steer[:60] + ("..." if len(_leftover_steer) > 60 else "")
_cprint(f"\n{t('cli.chat.delivering_leftover_steer', preview=preview)}")
self._pending_input.put(_leftover_steer)
return response
def _chat_resolve_interrupt(self, turn, agent_thread, interrupt_msg, response):
"""Return ``(pending_message, show_marker)``; clears a stale agent interrupt flag.
The marker is printed separately after the response Panel (history suppressed)
so a terminal redraw never duplicates it.
"""
pending_message = None
_show_interrupt_marker = False
_interrupted_this_turn = bool(turn.result and turn.result.get("interrupted"))
# Post-turn hooks (e.g. goal continuation) skip themselves on a user-cancelled turn.
self._last_turn_interrupted = _interrupted_this_turn
# ... and on a failed one (a refused turn, a provider error): nothing to judge.
self._last_turn_failed = bool(turn.result and turn.result.get("failed"))
if _interrupted_this_turn:
pending_message = turn.result.get("interrupt_message") or interrupt_msg
_show_interrupt_marker = bool(response and pending_message)
elif interrupt_msg:
# agent.interrupt() fired but the result doesn't acknowledge it (racy): either
# the thread had passed its last interrupt check so finalize_turn() never saw
# the flag, or the 10s post-interrupt wait expired and `result` is None. The
# user's message must NOT be dropped — re-queue it as the next turn.
pending_message = interrupt_msg
# An interrupt landing after finalize_turn()'s clear_interrupt() leaves a stale
# flag that would abort the NEXT turn at its first check. Clear it — but ONLY if
# the thread exited: on an abandoned thread the flag is what eventually unwinds
# the wedged tool.
try:
if (not agent_thread.is_alive() and self.agent
and getattr(self.agent, "_interrupt_requested", False)):
self.agent.clear_interrupt()
except Exception:
pass
return pending_message, _show_interrupt_marker
def _chat_print_reasoning_box(self, turn):
"""Collapsed reasoning box when show_reasoning is on and streaming did not already show it."""
from cli import _DIM, _RST, _cprint
# _reasoning_shown_this_turn, not _reasoning_stream_started: the latter resets at
# intermediate turn boundaries (tool loops) and re-rendered the box after the answer.
if self.show_reasoning and turn.result and not self._reasoning_shown_this_turn:
reasoning = turn.result.get("last_reasoning")
if reasoning and getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import thought_rows
for line in thought_rows(None, reasoning, self.reasoning_full):
_cprint(line)
elif reasoning:
w = self._scrollback_box_width()
r_label = f" {t('cli.chat.reasoning_label')} "
r_top = f"{_DIM}┌─{r_label}{'─' * max(w - 3 - len(r_label), 0)}┐{_RST}"
r_bot = f"{_DIM}└{'─' * (w - 2)}┘{_RST}"
# First 10 lines unless the user opted into /reasoning full.
lines = reasoning.strip().splitlines()
if len(lines) > 10 and not self.reasoning_full:
display_reasoning = "\n".join(lines[:10])
display_reasoning += f"\n{_DIM} {t('cli.chat.reasoning_more_lines', count=len(lines) - 10)}{_RST}"
else:
display_reasoning = reasoning.strip()
_cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}")
def _chat_print_response_panel(self, turn, response):
"""Response box (close TTS-drawn box / post-stream transform / Rich Panel), then billing CTA."""
from cli import (
ChatConsole, _ACCENT, _RST, _cprint, _maybe_remap_for_light_mode, _post_stream_transform_output,
_render_final_assistant_content,
)
if response and not (turn.result and turn.result.get("response_previewed", False)):
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "☤ Hermes")
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
except Exception:
label = "☤ Hermes"
_resp_color = _maybe_remap_for_light_mode("#CD7F32")
_resp_text = _maybe_remap_for_light_mode("#FFF8DC")
is_error_response = turn.result and (turn.result.get("failed") or turn.result.get("partial"))
# An interrupted reply that streamed before a tool-call boundary reset the segment
# state is already on screen (#65666). Only suppress when the response IS that text:
# unstreamed interrupt/status messages and completed replies still get their Panel.
_interrupted_streamed = bool(
self._last_turn_interrupted and response.strip()
and " ".join(response.split()) in " ".join(self._streamed_text_this_turn.split())
)
already_streamed = (
(self._stream_started and self._stream_box_opened) or _interrupted_streamed
) and not is_error_response
modern = getattr(self, "_modern_layout", False)
# A gateway refusal is Hermes's own copy (quoted user text, a /discard line to copy):
# markdown stripping or rendering would rewrite the quote and merge the lines.
mode = ("raw" if turn.result and turn.result.get("failure_reason") == "gateway_connection"
else self.final_response_markdown)
if turn.use_streaming_tts and turn.box_opened and not is_error_response:
# Text already printed sentence-by-sentence; just close the box.
if modern:
from hermes_cli.cli_modern_layout import closing_separator
_cprint(closing_separator(True))
else:
_cprint(f"\n{_ACCENT}╰{'─' * (self._scrollback_box_width() - 2)}╯{_RST}")
elif already_streamed:
# _flush_stream() already closed the streamed box; a post-stream transform
# hook shows a suffix for append-only changes, else the full replacement.
_post_stream_text = _post_stream_transform_output(response, turn.result)
if _post_stream_text.strip():
_cprint(_post_stream_text)
elif modern:
from hermes_cli.cli_modern_layout import answer_label, closing_separator
_cprint(answer_label())
ChatConsole().print(_render_final_assistant_content(response, mode=mode),
style=_resp_text, soft_wrap=True) # terminal wraps; resize re-flows
_cprint(closing_separator(True))
else:
ChatConsole().print(Panel(
_render_final_assistant_content(response, mode=mode),
title=f"[{_resp_color} bold]{label}[/]", title_align="left", border_style=_resp_color,
style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 0),
width=self._scrollback_box_width(),
))
# Billing CTA pins the single action (Nous → /topup, others → billing page) so it
# stays visible instead of scrolling away inside the response prose.
_limit = (turn.result or {}).get("usage_limit") if turn.result else None
if isinstance(_limit, dict):
# Usage-window plan: what to do next, in commands that always work here. Hermes never
# switches on its own; the user picks.
try:
from agent.plan_limits import limit_next_steps_from_payload
ChatConsole().print(Panel(
limit_next_steps_from_payload(_limit, markup=True),
title="[#CD7F32 bold]⚡ Limit reached[/]", title_align="left", border_style="#CD7F32",
box=rich_box.HORIZONTALS, padding=(1, 4), width=self._scrollback_box_width(),
))
except Exception:
logging.getLogger(__name__).debug("Usage-limit panel rendering failed", exc_info=True)
elif (turn.result and turn.result.get("failure_reason") == "billing"
and not turn.result.get("usage_window_message")):
# (A window plan's refusal already reads as its own sentence with the way on; an
# "Out of credits" box under it would contradict it.)
_bb = turn.result.get("billing_block") or {}
if _bb.get("is_nous"):
_cta_lines = [t("cli.chat.billing_cta_nous")]
else:
_url = _bb.get("billing_url")
_cta_lines = [t("cli.chat.billing_cta_provider",
provider=_bb.get("provider_label") or t("cli.chat.your_provider"))
+ (f": [bold]{_url}[/]" if _url else ".")]
_cta_lines.append(t("cli.chat.billing_cta_switch"))
try:
ChatConsole().print(Panel(
"\n".join(_cta_lines), title=f"[#CD7F32 bold]{t('cli.chat.out_of_credits_title')}[/]",
title_align="left", border_style="#CD7F32", box=rich_box.HORIZONTALS,
padding=(1, 4), width=self._scrollback_box_width(),
))
except Exception:
pass