mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
Merge upstream main into feat/hermes-relay-shared-metrics
# Conflicts: # MANIFEST.in # pyproject.toml # tests/test_project_metadata.py
This commit is contained in:
commit
b4e105031a
506 changed files with 33840 additions and 5678 deletions
312
cli.py
312
cli.py
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue