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

0open issues
0open PRs
27items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #1194 fix: handle YAML null values in session reset policy + configurable API timeout

    merged to main

    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.md

    Show 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

    merged to main

    files: gateway/config.py, tests/gateway/test_config.py

  • #3552 fix(cli): guard .strip() against None values from YAML config (salvage #3217)

    merged to main

    files: cli.py, hermes_cli/main.py, hermes_cli/runtime_provider.py

    Show 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)

    merged to main

    files: cli.py, hermes_cli/config.py, tests/hermes_cli/test_config.py, tests/hermes_cli/test_config_env_expansion.py

    Show 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)

    merged to main

    files: agent/model_metadata.py, tests/agent/test_model_metadata.py

    Show 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)

    merged to main

    files: tools/transcription_tools.py, tools/tts_tool.py

    Show 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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#1194, #1488, #3552, #61733, #61779, #61799
Closed issues
7
Attempts
14 PRs closed without merging
Fix link checked
10 Jul 2026

For your agent

The same facts, typed.