Merge upstream main into feat/hermes-relay-shared-metrics

# Conflicts:
#	MANIFEST.in
#	pyproject.toml
#	tests/test_project_metadata.py
This commit is contained in:
Alex Fournier 2026-07-23 07:22:03 -07:00
commit b4e105031a
506 changed files with 33840 additions and 5678 deletions

312
cli.py
View file

@ -3815,7 +3815,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
enabled=CLI_CONFIG["display"].get("persistent_output", True),
max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200),
)
# busy_input_mode: "interrupt" (Enter interrupts current run),
# busy_input_mode: "interrupt" (Enter redirects current run),
# "queue" (Enter queues for next turn), or "steer" (Enter injects
# mid-run via /steer, arriving after the next tool call).
_bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower()
@ -4204,6 +4204,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_continuous = False
self._voice_tts_done = threading.Event()
self._voice_tts_done.set()
self._voice_tts_stop = None # active streaming pipeline's stop event
self._voice_barge_capture = threading.Event() # barge monitor is capturing the interruption
# Status bar visibility (toggled via /statusbar)
self._status_bar_visible = True
@ -8085,12 +8087,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
choice = self._normalize_slash_confirm_choice(raw, choices)
return choice == "once"
def _confirm_and_apply_model_switch_result(self, result, persist_global: bool) -> None:
def _confirm_and_apply_model_switch_result(
self, result, persist_global: bool, custom_providers=None
) -> None:
try:
if result.success and not self._confirm_expensive_model_switch(result):
_cprint(" Model switch cancelled.")
return
self._apply_model_switch_result(result, persist_global)
self._apply_model_switch_result(
result, persist_global, custom_providers=custom_providers
)
except Exception as exc:
_cprint(f" ✗ Model selection failed: {exc}")
@ -8210,7 +8216,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
save_config_value("model.context_length", None)
def _apply_model_switch_result(self, result, persist_global: bool) -> None:
def _apply_model_switch_result(
self, result, persist_global: bool, custom_providers=None
) -> None:
if not result.success:
_cprint(f" ✗ {result.error_message}")
return
@ -8219,10 +8227,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
try:
from hermes_cli.context_switch_guard import merge_preflight_compression_warning
# Prefer the fresh inventory list (same source as switch_model /
# TUI); fall back to the agent-init snapshot.
_cp = (
custom_providers
if custom_providers is not None
else getattr(self.agent, "_custom_providers", None)
)
merge_preflight_compression_warning(
result,
agent=self.agent,
messages=list(self.conversation_history or []),
custom_providers=_cp,
config_context_length=getattr(self.agent, "_config_context_length", None),
)
except Exception as exc:
@ -8330,8 +8346,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if persist_global:
HermesCLI._clear_persisted_context_for_model_switch(self, result)
save_config_value("model.default", result.new_model)
if result.provider_changed:
save_config_value("model.provider", result.target_provider)
save_config_value("model.provider", result.target_provider)
# base_url/api_mode were previously never persisted here, so a
# global switch left the OLD provider's endpoint/wire-protocol in
# config.yaml. result.base_url/api_mode are always freshly
@ -8402,15 +8417,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
user_providers=state.get("user_provs"),
custom_providers=state.get("custom_provs"),
)
# Capture before close — picker state is cleared on close.
_picker_custom_provs = state.get("custom_provs")
self._close_model_picker()
if getattr(self, "_app", None):
threading.Thread(
target=self._confirm_and_apply_model_switch_result,
args=(result, persist_global),
args=(result, persist_global, _picker_custom_provs),
daemon=True,
).start()
else:
self._confirm_and_apply_model_switch_result(result, persist_global)
self._confirm_and_apply_model_switch_result(
result, persist_global, custom_providers=_picker_custom_provs
)
return
self._close_model_picker()
@ -8555,6 +8574,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
result,
agent=self.agent,
messages=list(self.conversation_history or []),
# Same fresh inventory list passed to switch_model above.
custom_providers=custom_provs
if custom_provs is not None
else getattr(self.agent, "_custom_providers", None),
config_context_length=getattr(self.agent, "_config_context_length", None),
)
except Exception as exc:
@ -8677,8 +8700,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if persist_global:
HermesCLI._clear_persisted_context_for_model_switch(self, result)
save_config_value("model.default", result.new_model)
if result.provider_changed:
save_config_value("model.provider", result.target_provider)
save_config_value("model.provider", result.target_provider)
# See _apply_model_switch_result above for why base_url/api_mode
# must be synced on every global switch (#25106).
save_config_value("model.base_url", result.base_url or None)
@ -9934,9 +9956,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
print("(._.) No active agent -- send a message first.")
return
if not self.agent.compression_enabled:
print("(._.) Compression is disabled in config.")
return
# No compression_enabled gate here: the config flag disables
# *automatic* compaction only. Manual /compress is an explicit user
# action — the context-overflow error path (conversation_loop.py)
# directs users here when auto-compaction is off, and the gateway's
# /compress handler has never gated on the flag.
from hermes_cli.partial_compress import (
extract_compress_flags,
@ -11106,6 +11130,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
time.sleep(0.15)
threading.Thread(target=_refresh_level, daemon=True).start()
def _voice_stt_model(self) -> Optional[str]:
"""STT model override from config, or None for the provider default."""
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
return stt_config.get("model") if isinstance(stt_config, dict) else None
except Exception:
return None
def _voice_restart_recording_async(self) -> None:
"""Restart continuous-mode recording off-thread (start() can block)."""
def _restart_recording():
try:
self._voice_start_recording()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
except Exception as e:
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()
def _voice_stop_and_transcribe(self):
"""Stop recording, transcribe via STT, and queue the transcript as input."""
# Atomic guard: only one thread can enter stop-and-transcribe.
@ -11143,17 +11187,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._app.invalidate()
_cprint(f"{_DIM}Transcribing...{_RST}")
# Get STT model from config
stt_model = None
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
stt_model = stt_config.get("model")
except Exception:
pass
from tools.voice_mode import transcribe_recording
result = transcribe_recording(wav_path, model=stt_model)
result = transcribe_recording(wav_path, model=self._voice_stt_model())
if result.get("success") and result.get("transcript", "").strip():
transcript = result["transcript"].strip()
@ -11204,14 +11239,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# (When transcript IS submitted, process_loop handles restart
# after chat() completes.)
if self._voice_continuous and not submitted and not self._voice_recording:
def _restart_recording():
try:
self._voice_start_recording()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
except Exception as e:
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()
self._voice_restart_recording_async()
def _voice_speak_response_async(self, text: str) -> None:
"""Schedule TTS and mark it pending before continuous recording can restart."""
@ -11277,6 +11305,70 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_tts_done.set()
def _voice_barge_in_monitor(self, stop_event: threading.Event) -> None:
"""VAD barge-in: cut streaming TTS the moment the user starts talking.
Runs for one turn alongside the streaming pipeline (continuous voice
mode only — the mic is otherwise idle during playback). On speech,
playback is cut immediately while the monitor KEEPS capturing (with
pre-roll, so the interruption is transcribed from its first syllable
— restarting the recorder after detection would lose the opening
words). ``_voice_barge_capture`` suppresses process_loop's auto-
restart until the captured utterance has been submitted.
"""
try:
from hermes_cli.config import load_config
voice_cfg = load_config().get("voice") or {}
if not (isinstance(voice_cfg, dict) and voice_cfg.get("barge_in", True)):
return
from tools.voice_mode import listen_for_speech, stop_playback
def _cut_playback():
if not self._voice_tts_done.is_set():
from tools.tts_streaming import mark_speech_interrupted
mark_speech_interrupted()
self._voice_barge_capture.set()
stop_event.set()
stop_playback()
wav_path = listen_for_speech(
lambda: stop_event.is_set() or self._voice_tts_done.is_set(),
capture=True,
on_trigger=_cut_playback,
)
if wav_path and self._voice_barge_capture.is_set():
self._voice_submit_barge_utterance(wav_path)
else:
self._voice_barge_capture.clear()
except Exception as e:
self._voice_barge_capture.clear()
logger.debug("Voice barge-in monitor failed: %s", e)
def _voice_submit_barge_utterance(self, wav_path: str) -> None:
"""Transcribe a barge-captured interruption and queue it as the next turn."""
submitted = False
try:
from tools.voice_mode import transcribe_recording
result = transcribe_recording(wav_path, model=self._voice_stt_model())
transcript = (result.get("transcript") or "").strip() if result.get("success") else ""
if transcript:
self._pending_input.put(transcript)
submitted = True
elif not result.get("success"):
_cprint(f"\n{_DIM}Transcription failed: {result.get('error', 'Unknown error')}{_RST}")
except Exception as e:
_cprint(f"\n{_DIM}Voice processing error: {e}{_RST}")
finally:
try:
if os.path.isfile(wav_path):
os.unlink(wav_path)
except OSError:
pass
self._voice_barge_capture.clear()
# No usable transcript: hand the mic back to the normal loop.
if not submitted and self._voice_mode and self._voice_continuous and not self._voice_recording:
self._voice_restart_recording_async()
def _voice_beeps_enabled(self) -> bool:
"""Return whether CLI voice mode should play record start/stop beeps."""
try:
@ -11370,8 +11462,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
threading.Thread(target=_bg_shutdown, daemon=True).start()
self._voice_recorder = None
# Stop any active TTS playback
# Stop any active TTS playback (file player + streaming pipeline)
try:
if self._voice_tts_stop is not None:
self._voice_tts_stop.set()
from tools.voice_mode import stop_playback
stop_playback()
except Exception:
@ -11446,7 +11540,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""
import time as _time
timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120)
from tools.clarify_gateway import resolve_clarify_timeout
# Canonical clarify timeout, shared with the gateway/TUI path. `<= 0`
# means unlimited (never auto-skip mid-think) → a null deadline.
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
is_open_ended = not choices
@ -11456,7 +11554,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"selected": 0,
"response_queue": response_queue,
}
self._clarify_deadline = _time.monotonic() + timeout
self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
# Open-ended questions skip straight to freetext input
self._clarify_freetext = is_open_ended
@ -11473,13 +11571,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
while True:
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = 0
self._clarify_deadline = None
self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
remaining = self._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
# None deadline = unlimited: never auto-skip, just keep polling.
if self._clarify_deadline is not None:
remaining = self._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 1.0:
_last_countdown_refresh = now
@ -11488,7 +11588,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Timed out — tear down the UI and let the agent decide
self._clarify_state = None
self._clarify_freetext = False
self._clarify_deadline = 0
self._clarify_deadline = None
self._paint_now()
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
@ -12013,6 +12113,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
(self.provider or "").strip(),
(self.model or "").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)
@ -12124,9 +12225,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._reasoning_shown_this_turn = False
# --- Streaming TTS setup ---
# When ElevenLabs is the TTS provider and sounddevice is available,
# we stream audio sentence-by-sentence as the agent generates tokens
# instead of waiting for the full response.
# Any working TTS provider streams sentence-by-sentence as the agent
# generates tokens: PCM-streaming providers (ElevenLabs, OpenAI) play
# chunks as they arrive, everything else synthesizes per sentence.
use_streaming_tts = False
_streaming_box_opened = False
text_queue = None
@ -12137,20 +12238,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if self._voice_tts:
try:
from tools.tts_tool import (
_load_tts_config as _load_tts_cfg,
_get_provider as _get_prov,
_import_elevenlabs,
_import_sounddevice,
check_tts_requirements,
stream_tts_to_speaker,
)
_tts_cfg = _load_tts_cfg()
if _get_prov(_tts_cfg) == "elevenlabs":
# Verify both ElevenLabs SDK and audio output are available
_import_elevenlabs()
_import_sounddevice()
use_streaming_tts = True
except (ImportError, OSError):
pass
_import_sounddevice()
use_streaming_tts = check_tts_requirements()
except Exception:
pass
@ -12178,6 +12271,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
daemon=True,
)
tts_thread.start()
# Expose the pipeline's stop event so barge-in paths (voice
# key, VAD monitor) can cut playback from outside this turn.
self._voice_tts_stop = stop_event
if self._voice_continuous:
threading.Thread(
target=self._voice_barge_in_monitor, args=(stop_event,), daemon=True
).start()
def stream_callback(delta: str):
if text_queue is not None:
@ -12240,6 +12340,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if _srn:
agent_message = _prepend_note_to_message(agent_message, _srn)
self._pending_skills_reload_note = None
# Barged mid-speech (VAD or record key)? Tell the model it was
# cut off — same one-shot, API-local note channel as above.
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
if _moa_cfg is None:
@ -12614,6 +12719,41 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
width=self._scrollback_box_width(),
))
# Durable, provider-agnostic billing CTA below the response. The
# response panel carries the full guidance; this pins the single
# action to take (Nous → /topup, other providers → their billing
# page) so it stays visible instead of scrolling away as prose.
if result and result.get("failure_reason") == "billing":
_bb = result.get("billing_block") or {}
_prov_label = _bb.get("provider_label") or "your provider"
if _bb.get("is_nous"):
_cta_lines = [
"Run [bold]/topup[/] to add credits, or "
"[bold]/subscription[/] to change plan.",
]
else:
_url = _bb.get("billing_url")
_cta_lines = [
f"Add credits with {_prov_label}"
+ (f": [bold]{_url}[/]" if _url else ".")
]
_cta_lines.append(
"Or switch providers with "
"[bold]/model <model> --provider <provider>[/]."
)
try:
ChatConsole().print(Panel(
"\n".join(_cta_lines),
title="[#CD7F32 bold]⚡ Out of credits[/]",
title_align="left",
border_style="#CD7F32",
box=rich_box.HORIZONTALS,
padding=(1, 4),
width=self._scrollback_box_width(),
))
except Exception:
pass
# Play terminal bell when agent finishes (if enabled).
# Works over SSH — the bell propagates to the user's terminal.
@ -13323,6 +13463,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_continuous = False # Whether to auto-restart after agent responds
self._voice_tts_done = threading.Event() # Signals TTS playback finished
self._voice_tts_done.set() # Initially "done" (no TTS pending)
self._voice_tts_stop = None # active streaming pipeline's stop event
self._voice_barge_capture = threading.Event() # barge monitor is capturing the interruption
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
self._install_tool_callbacks()
@ -13486,6 +13628,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
payload = (text, images) if images else text
if self._agent_running and not (text and _looks_like_slash_command(text)):
_effective_mode = self.busy_input_mode
redirected = False
if _effective_mode == "steer":
# Route Enter through /steer — inject mid-run after the
# next tool call. Images can't ride along (steer only
@ -13513,15 +13656,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
_cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
elif _effective_mode == "interrupt":
self._interrupt_queue.put(payload)
# Debug: log to file when message enters interrupt queue
try:
_dbg = _hermes_home / "interrupt_debug.log"
with open(_dbg, "a", encoding="utf-8") as _f:
_f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
f"agent_running={self._agent_running}\n")
except Exception:
pass
if not images and text:
try:
if (
self.agent is not None
and getattr(
self.agent,
"_supports_active_turn_redirect",
False,
)
is True
and hasattr(self.agent, "redirect")
):
redirected = bool(self.agent.redirect(text))
except Exception:
redirected = False
if redirected:
preview = text[:80] + ("..." if len(text) > 80 else "")
_cprint(f" {_ACCENT}↪ Redirected current turn: '{preview}'{_RST}")
else:
# Compatibility path for older agents, multimodal
# follow-ups, or a turn that finished in the race.
self._interrupt_queue.put(payload)
try:
_dbg = _hermes_home / "interrupt_debug.log"
with open(_dbg, "a", encoding="utf-8") as _f:
_f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
f"agent_running={self._agent_running}\n")
except Exception:
pass
# First-touch onboarding: on the very first busy-while-running
# event for this install, print a one-line tip explaining the
# /busy knob. Flag persists to config.yaml and never fires
@ -13535,7 +13698,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
mark_seen,
)
if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG):
_cprint(f" {_DIM}{busy_input_hint_cli(self.busy_input_mode)}{_RST}")
_hint_mode = "redirect" if redirected else _effective_mode
_cprint(f" {_DIM}{busy_input_hint_cli(_hint_mode)}{_RST}")
mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG)
CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True
except Exception:
@ -14089,9 +14253,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return
# Interrupt TTS if playing, so user can start talking.
# stop_playback() is fast (just terminates a subprocess).
# stop_playback() is fast (just terminates a subprocess);
# the stop event drains the streaming pipeline if one is live.
if not cli_ref._voice_tts_done.is_set():
try:
from tools.tts_streaming import mark_speech_interrupted
mark_speech_interrupted()
if cli_ref._voice_tts_stop is not None:
cli_ref._voice_tts_stop.set()
from tools.voice_mode import stop_playback
stop_playback()
cli_ref._voice_tts_done.set()
@ -14428,8 +14597,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
]
if cli_ref._clarify_state:
remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic()))
countdown = f' ({remaining}s)' if cli_ref._clarify_deadline else ''
# None deadline = unlimited wait → hide the countdown entirely.
if cli_ref._clarify_deadline is None:
countdown = ''
else:
remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic()))
countdown = f' ({remaining}s)'
if cli_ref._clarify_freetext:
return [
('class:hint', ' type your answer and press Enter'),
@ -15313,6 +15486,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if self._voice_tts:
self._voice_tts_done.wait(timeout=60)
time.sleep(0.3)
# A barge-in capture already owns the mic and
# will submit the interruption itself.
if self._voice_barge_capture.is_set():
return
self._voice_start_recording()
app.invalidate()
except Exception as e:
@ -16109,6 +16286,9 @@ def main(
(cli.provider or "").strip(),
(cli.model or "").strip(),
load_config(),
requested_provider=(
cli.requested_provider or ""
).strip(),
)
except Exception:
_img_mode = "text"