Null YAML values crashing multiple config subsystems
NousResearch/hermes-agent · cluster 1119 · agent
Data as of
Nothing open on GitHub. Fix merged in #1194. Check it covers your case.
The short answer
- Known?
- Yes. 27 related items grouped here, 7 issues already closed.
- Fix event
- A fix was merged to main in PR #1194. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #61733, #61779, #61799. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A cluster of bug reports and fixes addressing crashes caused by null or empty YAML values across CLI, STT/TTS, agent, and gateway config loading. The common theme is missing null-guards when calling methods like .get() or .strip() on None values parsed from YAML.
Open issues0
Open PRs0
Fixes on record
-
#1194 fix: handle YAML null values in session reset policy + configurable API timeout
files:
agent/context_compressor.py,gateway/config.py,hermes_cli/config.py,pyproject.toml,run_agent.py,tests/agent/test_context_compressor.py,tests/tools/test_transcription.py,tools/transcription_tools.py,website/docs/reference/environment-variables.md,website/docs/user-guide/features/tts.mdShow the diff
-
agent/context_compressor.py@@ -132,7 +132,11 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optiona if self.summary_model: call_kwargs["model"] = self.summary_model response = call_llm(**call_kwargs) - summary = response.choices[0].message.content.strip() + content = response.choices[0].message.content + # Handle cases where content is not a string (e.g., dict from llama.cpp) + if not isinstance(content, str): + content = str(content) if content else "" + summary = content.strip() if not summary.startswith("[CONTEXT SUMMARY]:"): summary = "[CONTEXT SUMMARY]: " + summary return summary -
gateway/config.py@@ -83,10 +83,13 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + # Handle both missing keys and explicit null values (YAML null → None) + at_hour = data.get("at_hour") + idle_minutes = data.get("idle_minutes") return cls( mode=data.get("mode", "both"), - at_hour=data.get("at_hour", 4), - idle_minutes=data.get("idle_minutes", 1440), + at_hour=at_hour if at_hour is not None else 4, + idle_minutes=idle_minutes if idle_minutes is not None else 1440, ) -
hermes_cli/config.py@@ -194,8 +194,13 @@ def ensure_hermes_home(): }, "stt": { - "enabled": True, - "model": "whisper-1", + "provider": "local", # "local" (free, faster-whisper) | "openai" (Whisper API) + "local": { + "model": "base", # tiny, base, small, medium, large-v3 + }, + "openai": { + "model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe + }, }, "human_delay": { -
pyproject.toml@@ -30,6 +30,7 @@ dependencies = [ "fal-client", # Text-to-speech (Edge TTS is free, no API key needed) "edge-tts", + "faster-whisper>=1.0.0", # mini-swe-agent deps (terminal tool) "litellm>=1.75.5", "typer", -
run_agent.py@@ -2729,7 +2729,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: "model": self.model, "messages": api_messages, "tools": self.tools if self.tools else None, - "timeout": 900.0, + "timeout": float(os.getenv("HERMES_API_TIMEOUT", 900.0)), } if self.max_tokens is not None: -
tests/agent/test_context_compressor.py@@ -153,6 +153,47 @@ def test_none_content_in_system_message_compress(self): assert len(result) < len(msgs) +class TestNonStringContent: + """Regression: content as dict (e.g., llama.cpp tool calls) must not crash.""" + + def test_dict_content_coerced_to_string(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = {"text": "some summary"} + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True) + + messages = [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "ok"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + summary = c._generate_summary(messages) + assert isinstance(summary, str) + assert "CONTEXT SUMMARY" in summary + + def test_none_content_coerced_to_empty(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = None + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True) + + messages = [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "ok"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + summary = c._generate_summary(messages) + # None content → empty string → "[CONTEXT SUMMARY]: " prefix added + assert summary is not None + assert "CONTEXT SUMMARY" in summary + + class TestCompressWithClient: def test_summarization_path(self): mock_client = MagicMock() -
tests/tools/test_transcription.py@@ -0,0 +1,223 @@ +"""Tests for transcription_tools.py — local (faster-whisper) and OpenAI providers. + +Tests cover provider selection, config loading, validation, and transcription +dispatch. All external dependencies (faster_whisper, openai) are mocked. +""" + +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch, mock_open + +import pytest + + +# --------------------------------------------------------------------------- +# Provider selection +# --------------------------------------------------------------------------- + + +class TestGetProvider: + """_get_provider() picks the right backend based on config + availability.""" + + def test_local_when_available(self): + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True): + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "local"}) == "local" + + def test_local_fallback_to_openai(self, monkeypatch): + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("tools.transcription_tools._HAS_OPENAI", True): + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "local"}) == "openai" + + def test_local_nothing_available(self, monkeypatch): + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("tools.transcription_tools._HAS_OPENAI", False): + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "local"}) == "none" + + def test_openai_when_key_set(self, monkeypatch): + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") + with patch("tools.transcription_tools._HAS_OPENAI", True): + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "openai"}) == "openai" + + def test_openai_fallback_to_local(self, monkeypatch): + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + patch("tools.transcription_tools._HAS_OPENAI", True): + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "openai"}) == "local" + + def test_default_provider_is_local(self): + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True): + from tools.transcription_tools import _get_provider + assert _get_provider({}) == "local" + + +# --------------------------------------------------------------------------- +# File validation +# --------------------------------------------------------------------------- + + +class TestValidateAudioFile: + + def test_missing_file(self, tmp_path): + from tools.transcription_tools import _validate_audio_file + result = _validate_audio_file(str(tmp_path / "nope.ogg")) + assert result is not None + assert "not found" in result["error"] + + def test_unsupported_format(self, tmp_path): + f = tmp_path / "test.xyz" + f.write_bytes(b"data") + from tools.transcription_tools import _validate_audio_file + result = _validate_audio_file(str(f)) + assert result is not None + assert "Unsupported" in result["error"] + + def test_valid_file_returns_none(self, tmp_path): + f = tmp_path / "test.ogg" + f.write_bytes(b"fake audio data") + from tools.transcription_tools import _validate_audio_file + assert _validate_audio_file(str(f)) is None + + def test_too_large(self, tmp_path): + import stat as stat_mod + f = tmp_path / "big.ogg" + f.write_bytes(b"x") + from tools.transcription_tools import _validate_audio_file, MAX_FILE_SIZE + real_stat = f.stat() + with patch.object(type(f), "stat", return_value=os.stat_result(( + real_stat.st_mode, real_stat.st_ino, real_stat.st_dev, + real_stat.st_nlink, real_stat.st_uid, real_stat.st_gid, + MAX_FILE_SIZE + 1, # st_size + real_stat.st_atime, real_stat.st_mtime, real_stat.st_ctime, + ))): + result = _validate_audio_file(str(f)) + assert result is not None + assert "too large" in result["error"] + + +# --------------------------------------------------------------------------- +# Local transcription +# --------------------------------------------------------------------------- + + +class TestTranscribeLocal: + + def test_successful_transcription(self, tmp_path): + audio_file = tmp_path / "test.ogg" + audio_file.write_bytes(b"fake audio") + + mock_segment = MagicMock() + mock_segment.text = "Hello world" + mock_info = MagicMock() + mock_info.language = "en" + mock_info.duration = 2.5 + + mock_model = MagicMock() + mock_model.transcribe.return_value = ([mock_segment], mock_info) + + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + patch("tools.transcription_tools.WhisperModel", return_value=mock_model), \ + patch("tools.transcription_tools._local_model", None): + from tools.transcription_tools import _transcribe_local + result = _transcribe_local(str(audio_file), "base") + + assert result["success"] is True + assert result["transcript"] == "Hello world" + + def test_not_installed(self): + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False): + from tools.transcription_tools import _transcribe_local + result = _transcribe_local("/tmp/test.ogg", "base") + assert result["success"] is False + assert "not installed" in result["error"] + + +# --------------------------------------------------------------------------- +# OpenAI transcription +# --------------------------------------------------------------------------- + + +class TestTranscribeOpenAI: + + def test_no_key(self, monkeypatch): + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + from tools.transcription_tools import _transcribe_openai + result = _transcribe_openai("/tmp/test.ogg", "whisper-1") + assert result["success"] is False + assert "VOICE_TOOLS_OPENAI_KEY" in result["error"] + + def test_successful_transcription(self, monkeypatch, tmp_path): + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") + audio_file = tmp_path / "test.ogg" + audio_file.write_bytes(b"fake audio") + + mock_client = MagicMock() + mock_client.audio.transcriptions.create.return_value = "Hello from OpenAI" + + with patch("tools.transcription_tools._HAS_OPENAI", True), \ + patch("tools.transcription_tools.OpenAI", return_value=mock_client): + from tools.transcription_tools import _transcribe_openai + result = _transcribe_openai(str(audio_file), "whisper-1") + + assert result["success"] is True + assert result["transcript"] == "Hello from OpenAI" + + +# --------------------------------------------------------------------------- +# Main transcribe_audio() dispatch +# --------------------------------------------------------------------------- + + +class TestTranscribeAudio: + + def test_dispatches_to_local(self, tmp_path): + audio_file = tmp_path / "test.ogg" + audio_file.write_bytes(b"fake audio") + + with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "local"}), \ + patch("tools.transcription_tools._get_provider", return_value="local"), \ + patch("tools.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local: + from tools.transcription_tools import transcribe_audio + result = transcribe_audio(str(audio_file)) + + assert result["success"] is True + mock_local.assert_called_once() + + def test_dispatches_to_openai(self, tmp_path): + audio_file = tmp_path / "test.ogg" + audio_file.write_bytes(b"fake audio") + + with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \ + patch("tools.transcription_tools._get_provider", return_value="openai"), \ + patch("tools.transcription_tools._transcribe_openai", return_value={"success": True, "transcript": "hi"}) as mock_openai: + from tools.transcription_tools import transcribe_audio + result = transcribe_audio(str(audio_file)) + + assert result["success"] is True + mock_openai.assert_called_once() + + def test_no_provider_returns_error(self, tmp_path): + audio_file = tmp_path / "test.ogg" + audio_file.write_bytes(b"fake audio") + + with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + patch("tools.transcription_tools._get_provider", return_value="none"): + from tools.transcription_tools import transcribe_audio + result = transcribe_audio(str(audio_file)) + + assert result["success"] is False + assert "No STT provider" in result["error"] + + def test_invalid_file_returns_error(self): + from tools.transcription_tools import transcribe_audio + result = transcribe_audio("/nonexistent/file.ogg") + assert result["success"] is False + assert "not found" in result["error"] -
tools/transcription_tools.py@@ -2,18 +2,19 @@ """ Transcription Tools Module -Provides speech-to-text transcription using OpenAI's Whisper API. -Used by the messaging gateway to automatically transcribe voice messages -sent by users on Telegram, Discord, WhatsApp, and Slack. +Provides speech-to-text transcription with two providers: + + - **local** (default, free) — faster-whisper running locally, no API key needed. + Auto-downloads the model (~150 MB for ``base``) on first use. + - **openai** — OpenAI Whisper API, requires ``VOICE_TOOLS_OPENAI_KEY``. -Supported models: - - whisper-1 (cheapest, good quality) - - gpt-4o-mini-transcribe (better quality, higher cost) - - gpt-4o-transcribe (best quality, highest cost) +Used by the messaging gateway to automatically transcribe voice messages +sent by users on Telegram, Discord, WhatsApp, Slack, and Signal. Supported input formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg -Usage: +Usage:: + from tools.transcription_tools import transcribe_audio result = transcribe_audio("/path/to/audio.ogg") @@ -28,142 +29,237 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Optional imports — graceful degradation +# --------------------------------------------------------------------------- + +try: + from faster_whisper import WhisperModel + _HAS_FASTER_WHISPER = True +except ImportError: + _HAS_FASTER_WHISPER = False + WhisperModel = None # type: ignore[assignment,misc] + +try: + from openai import OpenAI, APIError, APIConnectionError, APITimeoutError + _HAS_OPENAI = True +except ImportError: + _HAS_OPENAI = False + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- -# Default STT model -- cheapest and widely available -DEFAULT_STT_MODEL = "whisper-1" +DEFAULT_PROVIDER = "local" +DEFAULT_LOCAL_MODEL = "base" +DEFAULT_OPENAI_MODEL = "whisper-1" -# Supported audio formats SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg"} +MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB -# Maximum file size (25MB - OpenAI limit) -MAX_FILE_SIZE = 25 * 1024 * 1024 +# Singleton for the local model — loaded once, reused across calls +_local_model: Optional["WhisperModel"] = None +_local_model_name: Optional[str] = None +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- -def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, Any]: - """ - Transcribe an audio file using OpenAI's Whisper API. - This function calls the OpenAI Audio Transcriptions endpoint directly - (not via OpenRouter, since Whisper isn't available there). +def _load_stt_config() -> dict: + """Load the ``stt`` section from user config, falling back to defaults.""" + try: + from hermes_cli.config import load_config + return load_config().get("stt", {}) + except Exception: + return {} - Args: - file_path: Absolute path to the audio file to transcribe. - model: Whisper model to use. Defaults to config or "whisper-1". - Returns: - dict with keys: - - "success" (bool): Whether transcription succeeded - - "transcript" (str): The transcribed text (empty on failure) - - "error" (str, optional): Error message if success is False +def _get_provider(stt_config: dict) -> str: + """Determine which STT provider to use. + + Priority: + 1. Explicit config value (``stt.provider``) + 2. Auto-detect: local if faster-whisper available, else openai if key set + 3. Disabled (returns "none") """ - api_key = [redacted:secret-assignment]"VOICE_TOOLS_OPENAI_KEY") - if not api_key: - return { - "success": False, - "transcript": "", - "error": "VOICE_TOOLS_OPENAI_KEY not set", - } + provider = stt_config.get("provider", DEFAULT_PROVIDER) + + if provider == "local": + if _HAS_FASTER_WHISPER: + return "local" + # Local requested but not available — fall back to openai if possible + if _HAS_OPENAI and os.getenv("VOICE_TOOLS_OPENAI_KEY"): + logger.info("faster-whisper not installed, falling back to OpenAI Whisper API") + return "openai" + return "none" + + if provider == "openai": + if _HAS_OPENAI and os.getenv("VOICE_TOOLS_OPENAI_KEY"): + return "openai" + # OpenAI requested but no key — fall back to local if possible + if _HAS_FASTER_WHISPER: + logger.info("VOICE_TOOLS_OPENAI_KEY not set, falling back to local faster-whisper") + return "local" + return "none" + return provider # Unknown — let it fail downstream + +# --------------------------------------------------------------------------- +# Shared validation +# --------------------------------------------------------------------------- + + +def _validate_audio_file(file_path: str) -> Optional[Dict[str, Any]]: + """Validate the audio file. Returns an error dict or None if OK.""" audio_path = Path(file_path) - - # Validate file exists + if not audio_path.exists(): - return { - "success": False, - "transcript": "", - "error": f"Audio file not found: {file_path}", - } - + return {"success": False, "transcript": "", "error": f"Audio file not found: {file_path}"} if not audio_path.is_file(): - return { - "success": False, - "transcript": "", - "error": f"Path is not a file: {file_path}", - } - - # Validate file extension + return {"success": False, "transcript": "", "error": f"Path is not a file: {file_path}"} if audio_path.suffix.lower() not in SUPPORTED_FORMATS: return { "success": False, "transcript": "", - "error": f"Unsupported file format: {audio_path.suffix}. Supported formats: {', '.join(sorted(SUPPORTED_FORMATS))}", + "error": f"Unsupported format: {audio_path.suffix}. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}", } - - # Validate file size try: file_size = audio_path.stat().st_size if file_size > MAX_FILE_SIZE: return { "success": False, "transcript": "", - "error": f"File too large: {file_size / (1024*1024):.1f}MB (max {MAX_FILE_SIZE / (1024*1024)}MB)", + "error": f"File too large: {file_size / (1024*1024):.1f}MB (max {MAX_FILE_SIZE / (1024*1024):.0f}MB)", } except OSError as e: - logger.error("Failed to get file size for %s: %s", file_path, e, exc_info=True) - return { - "success": False, - "transcript": "", - "error": f"Failed to access file: {e}", - } + return {"success": False, "transcript": "", "error": f"Failed to access file: {e}"} + + return None + +# --------------------------------------------------------------------------- +# Provider: local (faster-whisper) +# --------------------------------------------------------------------------- + - # Use provided model, or fall back to default - if model is None: - model = DEFAULT_STT_MODEL +def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]: + """Transcribe using faster-whisper (local, free).""" + global _local_model, _local_model_name + + if not _HAS_FASTER_WHISPER: + return {"success": False, "transcript": "", "error": "faster-whisper not installed"} try: - from openai import OpenAI, APIError, APIConnectionError, APITimeoutError + # Lazy-load the model (downloads on first use, ~150 MB for 'base') + if _local_model is None or _local_model_name != model_name: + logger.info("Loading faster-whisper model '%s' (first load downloads the model)...", model_name) + _local_model = WhisperModel(model_name, device="auto", compute_type="auto") + _local_model_name = model_name + + segments, info = _local_model.transcribe(file_path, beam_size=5) + transcript = " ".join(segment.text.strip() for segment in segments) + + logger.info( + "Transcribed %s via local whisper (%s, lang=%s, %.1fs audio)", + Path(file_path).name, model_name, info.language, info.duration, + ) + + return {"success": True, "transcript": transcript} + + except Exception as e: + logger.error("Local transcription failed: %s", e, exc_info=True) + return {"success": False, "transcript": "", "error": f"Local transcription failed: {e}"} +# --------------------------------------------------------------------------- +# Provider: openai (Whisper API) +# --------------------------------------------------------------------------- + + +def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: + """Transcribe using OpenAI Whisper API (paid).""" + api_key = [redacted:secret-assignment]"VOICE_TOOLS_OPENAI_KEY") + if not api_key: + return {"success": False, "transcript": "", "error": "VOICE_TOOLS_OPENAI_KEY not set"} + + if not _HAS_OPENAI: + return {"success": False, "transcript": "", "error": "openai package not installed"} + + try: client = OpenAI(api_key=[redacted:secret-assignment] base_url="https://api.openai.com/v1") with open(file_path, "rb") as audio_file: transcription = client.audio.transcriptions.create( - model=model, + model=model_name, file=audio_file, response_format="text", ) - # The response is a plain string when response_format="text" transcript_text = str(transcription).strip() + logger.info("Transcribed %s via OpenAI API (%s, %d chars)", + Path(file_path).name, model_name, len(transcript_text)) - logger.info("Transcribed %s (%d chars)", audio_path.name, len(transcript_text)) - - return { - "success": True, - "transcript": transcript_text, - } + return {"success": True, "transcript": transcript_text} except PermissionError: - logger.error("Permission denied accessing file: %s", file_path, exc_info=True) - return { - "success": False, - "transcript": "", - "error": f"Permission denied: {file_path}", - } + return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"} except APIConnectionError as e: - logger.error("API connection error during transcription: %s", e, exc_info=True) - return { - "success": False, - "transcript": "", - "error": f"Connection error: {e}", - } + return {"success": False, "transcript": "", "error": f"Connection error: {e}"} except APITimeoutError as e: - logger.error("API timeout during transcription: %s", e, exc_info=True) - return { - "success": False, - "transcript": "", - "error": f"Request timeout: {e}", - } + return {"success": False, "transcript": "", "error": f"Request timeout: {e}"} except APIError as e: - logger.error("OpenAI API error during transcription: %s", e, exc_info=True) - return { - "success": False, - "transcript": "", - "error": f"API error: {e}", - } + return {"success": False, "transcript": "", "error": f"API error: {e}"} except Exception as e: - logger.error("Unexpected error during transcription: %s", e, exc_info=True) - return { - "success": False, - "transcript": "", - "error": f"Transcription failed: {e}", - } + logger.error("OpenAI transcription failed: %s", e, exc_info=True) + return {"success": False, "transcript": "", "error": f"Transcription failed: {e}"} + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, Any]: + """ + Transcribe an audio file using the configured STT provider. + + Provider priority: + 1. User config (``stt.provider`` in config.yaml) + 2. Auto-detect: local faster-whisper if available, else OpenAI API + + Args: + file_path: Absolute path to the audio file to transcribe. + model: Override the model. If None, uses config or provider default. + + Returns: + dict with keys: + - "success" (bool): Whether transcription succeeded + - "transcript" (str): The transcribed text (empty on failure) + - "error" (str, optional): Error message if success is False + """ + # Validate input + error = _validate_audio_file(file_path) + if error: + return error + + # Load config and determine provider + stt_config = _load_stt_config() + provider = _get_provider(stt_config) + + if provider == "local": + local_cfg = stt_config.get("local", {}) + model_name = model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) + return _transcribe_local(file_path, model_name) + + if provider == "openai": + openai_cfg = stt_config.get("openai", {}) + model_name = model or openai_cfg.get("model", DEFAULT_OPENAI_MODEL) + return _transcribe_openai(file_path, model_name) + + # No provider available + return { + "success": False, + "transcript": "", + "error": ( + "No STT provider available. Install faster-whisper for free local " + "transcription, or set VOICE_TOOLS_OPENAI_KEY for the OpenAI Whisper API." + ), + } -
website/docs/reference/environment-variables.md@@ -131,6 +131,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `HERMES_HUMAN_DELAY_MIN_MS` | Custom delay range minimum (ms) | | `HERMES_HUMAN_DELAY_MAX_MS` | Custom delay range maximum (ms) | | `HERMES_QUIET` | Suppress non-essential output (`true`/`false`) | +| `HERMES_API_TIMEOUT` | LLM API call timeout in seconds (default: `900`) | | `HERMES_EXEC_ASK` | Enable execution approval prompts in gateway mode (`true`/`false`) | ## Session Settings -
website/docs/user-guide/features/tts.md@@ -67,23 +67,48 @@ Without ffmpeg, Edge TTS audio is sent as a regular audio file (playable, but sh If you want voice bubbles without installing ffmpeg, switch to the OpenAI or ElevenLabs provider. ::: -## Voice Message Transcription +## Voice Message Transcription (STT) -Voice messages sent on Telegram, Discord, WhatsApp, or Slack are automatically transcribed and injected as text into the conversation. The agent sees the transcript as normal text. +Voice messages sent on Telegram, Discord, WhatsApp, Slack, or Signal are automatically transcribed and injected as text into the conversation. The agent sees the transcript as normal text. -| Provider | Model | Quality | Cost | -|----------|-------|---------|------| -| **OpenAI Whisper** | `whisper-1` (default) | Good | Low | -| **OpenAI GPT-4o** | `gpt-4o-mini-transcribe` | Better | Medium | -| **OpenAI GPT-4o** | `gpt-4o-transcribe` | Best | Higher | +| Provider | Quality | Cost | API Key | +|----------|---------|------|---------| +| **Local Whisper** (default) | Good | Free | None needed | +| **OpenAI Whisper API** | Good–Best | Paid | `VOICE_TOOLS_OPENAI_KEY` | -Requires `VOICE_TOOLS_OPENAI_KEY` in `~/.hermes/.env`. +:::info Zero Config +Local transcription works out of the box — no API key needed. The `faster-whisper` model (~150 MB for `base`) is auto-downloaded on first voice message. +::: ### Configuration ```yaml # In ~/.hermes/config.yaml stt: - enabled: true - model: "whisper-1" + provider: "local" # "local" (free, faster-whisper) | "openai" (API) + local: + model: "base" # tiny, base, small, medium, large-v3 + openai: + model: "whisper-1" # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe ``` + +### Provider Details + +**Local (faster-whisper)** — Runs Whisper locally via [faster-whisper](https://github.com/SYSTRAN/faster-whisper). Uses CPU by default, GPU if available. Model sizes: + +| Model | Size | Speed | Quality | +|-------|------|-------|---------| +| `tiny` | ~75 MB | Fastest | Basic | +| `base` | ~150 MB | Fast | Good (default) | +| `small` | ~500 MB | Medium | Better | +| `medium` | ~1.5 GB | Slower | Great | +| `large-v3` | ~3 GB | Slowest | Best | + +**OpenAI API** — Requires `VOICE_TOOLS_OPENAI_KEY`. Supports `whisper-1`, `gpt-4o-mini-transcribe`, and `gpt-4o-transcribe`. + +### Fallback Behavior + +If your configured provider isn't available, Hermes automatically falls back: +- **Local not installed** → Falls back to OpenAI API (if key is set) +- **OpenAI key not set** → Falls back to local Whisper (if installed) +- **Neither available** → Voice messages pass through with a note to the user
-
-
#1488 fix(gateway): null-coalesce mode in SessionResetPolicy.from_dict
files:
gateway/config.py,tests/gateway/test_config.py -
#3552 fix(cli): guard .strip() against None values from YAML config (salvage #3217)
files:
cli.py,hermes_cli/main.py,hermes_cli/runtime_provider.pyShow the diff
-
cli.py@@ -1083,7 +1083,7 @@ def __init__( self.model = model or _config_model or _FALLBACK_MODEL # Auto-detect model from local server if still on fallback if self.model == _FALLBACK_MODEL: - _base_url = _model_config.get("base_url", "") if isinstance(_model_config, dict) else "" + _base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else "" if "localhost" in _base_url or "127.0.0.1" in _base_url: from hermes_cli.runtime_provider import _auto_detect_local_model _detected = _auto_detect_local_model(_base_url) -
hermes_cli/main.py@@ -832,8 +832,8 @@ def cmd_model(args): for entry in custom_providers_cfg: if not isinstance(entry, dict): continue - name = entry.get("name", "").strip() - base_url = entry.get("base_url", "").strip() + name = (entry.get("name") or "").strip() + base_url = (entry.get("base_url") or "").strip() if not name or not base_url: continue # Generate a stable key from the name -
hermes_cli/runtime_provider.py@@ -63,8 +63,8 @@ def _get_model_config() -> Dict[str, Any]: model_cfg = config.get("model") if isinstance(model_cfg, dict): cfg = dict(model_cfg) - default = cfg.get("default", "").strip() - base_url = cfg.get("base_url", "").strip() + default = (cfg.get("default") or "").strip() + base_url = (cfg.get("base_url") or "").strip() is_local = "localhost" in base_url or "127.0.0.1" in base_url is_fallback = not default or default == "anthropic/claude-opus-4.6" if is_local and is_fallback and base_url:
-
-
#61733 fix(config): empty YAML section keys no longer replace default dicts (#58277)
files:
cli.py,hermes_cli/config.py,tests/hermes_cli/test_config.py,tests/hermes_cli/test_config_env_expansion.pyShow the diff
-
cli.py@@ -543,6 +543,8 @@ def load_cli_config() -> Dict[str, Any]: if key == "model": continue # Already handled above if key in file_config: + if isinstance(defaults[key], dict) and file_config[key] is None: + continue if isinstance(defaults[key], dict) and isinstance(file_config[key], dict): defaults[key].update(file_config[key]) else: -
hermes_cli/config.py@@ -6243,6 +6243,12 @@ def _deep_merge(base: dict, override: dict) -> dict: Keys in *override* take precedence. If both values are dicts the merge recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will keep the default ``tts.elevenlabs.model_id`` intact. + + An empty section key in config.yaml (``terminal:`` with no value) parses + as YAML ``None``; treating that as an override would replace the entire + default dict with ``None`` and crash every downstream consumer that + expects a mapping (#58277). A ``None`` override of a dict default is + ignored — same as the key being absent. """ result = base.copy() for key, value in override.items(): @@ -6252,6 +6258,8 @@ def _deep_merge(base: dict, override: dict) -> dict: and isinstance(value, dict) ): result[key] = _deep_merge(result[key], value) + elif key in result and isinstance(result[key], dict) and value is None: + continue else: result[key] = value return result -
tests/hermes_cli/test_config.py@@ -360,6 +360,34 @@ def test_last_known_good_cached_no_rewarn_spam(self, tmp_path, capsys): assert capsys.readouterr().err == "" +class TestEmptyConfigSections: + """Empty section keys (``terminal:`` with no value) parse as YAML None + and must not replace the default dict for that section (#58277).""" + + def test_null_section_keeps_defaults_in_load_config(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text( + "model:\n default: test/custom\n" + "terminal:\n" + "display:\n" + ) + config = load_config() + assert config["model"]["default"] == "test/custom" + assert isinstance(config["terminal"], dict) + assert config["terminal"] == DEFAULT_CONFIG["terminal"] + assert isinstance(config["display"], dict) + + def test_null_override_of_non_dict_default_still_applies(self, tmp_path): + """None only shields dict defaults — explicit null for a scalar + key remains an override (unchanged behavior).""" + from hermes_cli.config import _deep_merge + + merged = _deep_merge({"scalar": 5, "section": {"a": 1}}, + {"scalar": None, "section": None}) + assert merged["scalar"] is None + assert merged["section"] == {"a": 1} + + class TestSaveAndLoadRoundtrip: @staticmethod def _deny_config_reads(config_path): -
tests/hermes_cli/test_config_env_expansion.py@@ -150,6 +150,18 @@ def test_unchanged_env_still_serves_cache(self, tmp_path, monkeypatch): class TestLoadCliConfigExpansion: """Verify that load_cli_config() also expands ${VAR} references.""" + def test_cli_config_ignores_empty_terminal_section(self, tmp_path, monkeypatch): + config_file = tmp_path / "config.yaml" + config_file.write_text("terminal:\n") + + monkeypatch.setattr("cli._hermes_home", tmp_path) + + from cli import load_cli_config + config = load_cli_config() + + assert isinstance(config["terminal"], dict) + assert config["terminal"]["env_type"] == "local" + def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch): config_yaml = ( "auxiliary:\n"
-
-
#61779 fix(agent): handle YAML null value in context_length_cache (#47135)
files:
agent/model_metadata.py,tests/agent/test_model_metadata.pyShow the diff
-
agent/model_metadata.py@@ -1077,7 +1077,7 @@ def _load_context_cache() -> Dict[str, int]: try: with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - return data.get("context_lengths", {}) + return data.get("context_lengths") or {} except Exception as e: logger.debug("Failed to load context length cache: %s", e) return {} -
tests/agent/test_model_metadata.py@@ -1492,6 +1492,17 @@ def test_missing_cache_returns_none(self, tmp_path): with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file): assert get_cached_context_length("test/model", "http://x") is None + def test_null_context_lengths_key_returns_empty(self, tmp_path): + """``context_lengths:`` with no value parses as None — must behave + like an empty cache instead of crashing every caller (#47135).""" + cache_file = tmp_path / "cache.yaml" + cache_file.write_text("context_lengths:\n") + with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file): + assert get_cached_context_length("test/model", "http://x") is None + # save must also survive the null key and repair the file + save_context_length("test/model", "http://x", 32768) + assert get_cached_context_length("test/model", "http://x") == 32768 + def test_multiple_models_cached(self, tmp_path): cache_file = tmp_path / "cache.yaml" with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file):
-
-
#61799 fix(stt/tts): null provider subsections no longer crash voice tools (#47318)
files:
tools/transcription_tools.py,tools/tts_tool.pyShow the diff
-
tools/transcription_tools.py@@ -122,7 +122,7 @@ def _load_stt_config() -> dict: """Load the ``stt`` section from user config, falling back to defaults.""" try: from hermes_cli.config import load_config - return load_config().get("stt", {}) + return load_config().get("stt") or {} except Exception: return {} @@ -1130,7 +1130,7 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]: # Language: config.yaml (stt.local.language) > env var > auto-detect. _forced_lang = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or None ) @@ -1213,7 +1213,7 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] # Language: config.yaml (stt.local.language) > env var > "en" default. language = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or DEFAULT_LOCAL_STT_LANGUAGE ) @@ -1447,7 +1447,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: } stt_config = _load_stt_config() - xai_config = stt_config.get("xai", {}) + xai_config = stt_config.get("xai") or {} base_url = str( xai_config.get("base_url") or get_env_value("XAI_STT_BASE_URL") @@ -1542,7 +1542,7 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": "ELEVENLABS_API_KEY not set"} stt_config = _load_stt_config() - elevenlabs_config = stt_config.get("elevenlabs", {}) + elevenlabs_config = stt_config.get("elevenlabs") or {} base_url = str( elevenlabs_config.get("base_url") or get_env_value("ELEVENLABS_STT_BASE_URL") @@ -1657,14 +1657,14 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A provider = _get_provider(stt_config) if provider == "local": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) return _transcribe_local(file_path, model_name) if provider == "local_command": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_command_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) @@ -1675,12 +1675,12 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_groq(file_path, model_name) if provider == "openai": - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} model_name = model or openai_cfg.get("model", DEFAULT_STT_MODEL) return _transcribe_openai(file_path, model_name) if provider == "mistral": - mistral_cfg = stt_config.get("mistral", {}) + mistral_cfg = stt_config.get("mistral") or {} model_name = model or mistral_cfg.get("model", DEFAULT_MISTRAL_STT_MODEL) return _transcribe_mistral(file_path, model_name) @@ -1690,7 +1690,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_xai(file_path, model_name) if provider == "elevenlabs": - elevenlabs_cfg = stt_config.get("elevenlabs", {}) + elevenlabs_cfg = stt_config.get("elevenlabs") or {} model_name = model or elevenlabs_cfg.get("model_id", DEFAULT_ELEVENLABS_STT_MODEL) return _transcribe_elevenlabs(file_path, model_name) @@ -1754,7 +1754,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A def _resolve_openai_audio_client_config() -> tuple[str, str]: """Return direct OpenAI audio config or a managed gateway fallback.""" stt_config = _load_stt_config() - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} cfg_api_key = [redacted:secret-assignment]"api_key", "") cfg_base_url = openai_cfg.get("base_url", "") if cfg_api_key: -
tools/tts_tool.py@@ -341,7 +341,7 @@ def _load_tts_config() -> Dict[str, Any]: try: from hermes_cli.config import load_config config = load_config() - return config.get("tts", {}) + return config.get("tts") or {} except ImportError: logger.debug("hermes_cli.config not available, using default TTS config") return {} @@ -949,7 +949,7 @@ async def _generate_edge_tts(text: str, output_path: str, tts_config: Dict[str, Path to the saved audio file. """ _edge_tts = _import_edge_tts() - edge_config = tts_config.get("edge", {}) + edge_config = tts_config.get("edge") or {} voice = edge_config.get("voice", DEFAULT_EDGE_VOICE) speed = float(edge_config.get("speed", tts_config.get("speed", 1.0))) @@ -982,7 +982,7 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any] if not api_key: raise ValueError("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/") - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", DEFAULT_ELEVENLABS_VOICE_ID) model_id = el_config.get("model_id", DEFAULT_ELEVENLABS_MODEL_ID) @@ -1026,7 +1026,7 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] """ api_key, base_url, is_managed = _resolve_openai_audio_client_config() - oai_config = tts_config.get("openai", {}) + oai_config = tts_config.get("openai") or {} model = oai_config.get("model", DEFAULT_OPENAI_MODEL) voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) custom_base_url = oai_config.get("base_url") @@ -1204,7 +1204,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - if not api_key: raise ValueError("No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.") - xai_config = tts_config.get("xai", {}) + xai_config = tts_config.get("xai") or {} voice_id = str(xai_config.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID language = str(xai_config.get("language", DEFAULT_XAI_LANGUAGE)).strip() or DEFAULT_XAI_LANGUAGE sample_rate = int(xai_config.get("sample_rate", DEFAULT_XAI_SAMPLE_RATE)) @@ -1443,7 +1443,7 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any if not api_key: raise ValueError("MISTRAL_API_KEY not set. Get one at https://console.mistral.ai/") - mi_config = tts_config.get("mistral", {}) + mi_config = tts_config.get("mistral") or {} model = mi_config.get("model", DEFAULT_MISTRAL_TTS_MODEL) voice_id = mi_config.get("voice_id") or DEFAULT_MISTRAL_TTS_VOICE_ID @@ -1694,7 +1694,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] "GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey" ) - raw_gemini_config = tts_config.get("gemini", {}) + raw_gemini_config = tts_config.get("gemini") or {} gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {} model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE @@ -1858,7 +1858,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> """ import sys - neutts_config = tts_config.get("neutts", {}) + neutts_config = tts_config.get("neutts") or {} ref_audio = neutts_config.get("ref_audio", "") or _default_neutts_ref_audio() ref_text = neutts_config.get("ref_text", "") or _default_neutts_ref_text() model = neutts_config.get("model", "neuphonic/neutts-air-q4-gguf") @@ -1996,7 +1996,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) PiperVoice = _import_piper() import wave - piper_config = tts_config.get("piper", {}) if isinstance(tts_config, dict) else {} + piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {} voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE download_dir = Path(piper_config.get("voices_dir") or _get_piper_voices_dir()).expanduser() download_dir.mkdir(parents=True, exist_ok=True) @@ -2619,7 +2619,7 @@ def stream_tts_to_speaker( model_id = DEFAULT_ELEVENLABS_STREAMING_MODEL_ID tts_config = _load_tts_config() - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", voice_id) model_id = el_config.get("streaming_model_id", el_config.get("model_id", model_id))
-
14 PRs closed without merging
From the close comments: done elsewhere 5 · merged via another change 4 · duplicate 2 · superseded by another PR 2 · other 1
-
#1120 fix: default reset policy null values in gateway config · other →
#1488 -
#1139 fix: Handle explicit null values in SessionResetPolicy config (#1119) · superseded by another PR →
#1194 -
#1148 fix(gateway): handle explicit null values in SessionResetPolicy config · done elsewhere →
#1488 -
#3217 fix(cli): guard .strip() against None values from YAML config · merged via another change →
#3552 -
#3408 fix: guard config.get() against YAML null values in _get_model_config · superseded by another PR →
#3217
Show 9 more
-
#47147 fix(agent): handle YAML null value in context_length_cache · merged via another change →
b2e227d,#61779,#47135 -
#47161 fix(agent): handle YAML null in context_length_cache · duplicate →
#47147 -
#47185 fix(agent): handle YAML null value in context_length_cache (#47135) · done elsewhere →
#47147,#61779 -
#47334 fix(stt/tts): guard against null config subsections crashing with AttributeError · merged via another change →
8937121,#61799 -
#47356 fix: guard STT/TTS config reads against YAML null values · done elsewhere →
#47334,#61799 -
#47387 fix(agent): handle YAML null value in context_length_cache · duplicate →
#47147,#47135,#47185,#47161 -
#47438 fix(voice): handle YAML null values in STT/TTS config (#47318) · done elsewhere →
#47334,#47318,#61799 -
#58306 fix(cli): ignore empty config sections · merged via another change →
bdecf0a,#61733 -
#58361 fix(cli): handle empty/null 'terminal:' YAML key in load_cli_config · done elsewhere →
#58306,#61733
Closed issues · 7
- Closed#1119[Bug]: Null Values in SessionResetPolicy Configuration Cause TypeError
- Closed#3214fix(cli): NoneType crash on .strip() when config has null YAML values
- Closed#3407CLI crashes with AttributeError on YAML null config values
- Closed#47135fix(agent): handle YAML null value in context_length_cache
- Closed#47318local: null / edge: null in config.yaml crashes STT/TTS with 'NoneType' object has no attribute 'get'
- Closed#58277[Bug]: Empty YAML key (terminal:) in profile config.yaml crashes load_cli_config with TypeError
- Closed#59403[Bug]: Failed to initialize agent: 'NoneType' object has no attribute 'get'` when `context_length_cache.yaml` has an empty `context_lengths:` key
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab