# NousResearch/hermes-agent · Null YAML values crashing multiple config subsystems

## Open now

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

## TL;DR

- 27 member(s): 0 open issue(s), 7 closed issue(s), 0 open PR(s), 6 merged PR(s), 14 PR(s) closed without merging.
- A fix was merged to main in PR #1194. Verify it addresses your case.
- **Recommended:** read PR #1194 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-61733,pr-61779,pr-61799`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #1119: \[Bug\]: Null Values in SessionResetPolicy Configuration Cause TypeError
- #3214: fix(cli): NoneType crash on .strip() when config has null YAML values
- #3407: CLI crashes with AttributeError on YAML null config values
- #47135: fix(agent): handle YAML null value in context_length_cache
- #47318: local: null / edge: null in config.yaml crashes STT/TTS with 'NoneType' object has no attribute 'get'
- #58277: \[Bug\]: Empty YAML key (terminal:) in profile config.yaml crashes load_cli_config with TypeError
- #59403: \[Bug\]: Failed to initialize agent: 'NoneType' object has no attribute 'get'\` when \`context_length_cache.yaml\` has an empty \`context_lengths:\` key

## Prior & competing PRs

- **#1194: fix: handle YAML null values in session reset policy + configurable API timeout**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `agent/context_compressor.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/config.py`, `tests/gateway/test_config.py`
- **#3408: fix: guard config.get() against YAML null values in _get_model_config**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#3217`, `#3217`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#3552: fix(cli): guard .strip() against None values from YAML config (salvage #3217)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `hermes_cli/main.py`, `hermes_cli/runtime_provider.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `hermes_cli/config.py`, `tests/hermes_cli/test_config.py`, `tests/hermes_cli/test_config_env_expansion.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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():
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/model_metadata.py`, `tests/agent/test_model_metadata.py`
  - fix hunks:
  - `agent/model_metadata.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tools/transcription_tools.py`, `tools/tts_tool.py`
  - fix hunks:
  - `tools/transcription_tools.py`

    ```diff
    @@ -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 {}
     
    ```

    ```diff
    @@ -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
             )
    ```

    ```diff
    @@ -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
         )
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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)
             )
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 {}
    ```

    ```diff
    @@ -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)))
     
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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))
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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))
    ```
- **#1120: fix: default reset policy null values in gateway config**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#1488`
- **#1139: fix: Handle explicit null values in SessionResetPolicy config (#1119)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#1194`
- **#1148: fix(gateway): handle explicit null values in SessionResetPolicy config**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#1488`
- **#3217: fix(cli): guard .strip() against None values from YAML config**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#3552`
- **#47147: fix(agent): handle YAML null value in context_length_cache**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `b2e227d24`, `#61779`, `#47135`
- **#47161: fix(agent): handle YAML null in context_length_cache**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#47147`
- **#47185: fix(agent): handle YAML null value in context_length_cache (#47135)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#47147`, `#61779`
- **#47334: fix(stt/tts): guard against null config subsections crashing with AttributeError**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `89371216b`, `#61799`
- **#47356: fix: guard STT/TTS config reads against YAML null values**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#47334`, `#61799`, `#47334`
- **#47387: fix(agent): handle YAML null value in context_length_cache**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#47147`, `#47135`, `#47147`, `#47185`, `#47161`
- **#47438: fix(voice): handle YAML null values in STT/TTS config (#47318)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#47334`, `#47318`, `#61799`
- **#58306: fix(cli): ignore empty config sections**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `bdecf0ab9`, `#61733`
- **#58361: fix(cli): handle empty/null 'terminal:' YAML key in load_cli_config**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#58306`, `#61733`

## Fix status

- Merged to main in PR #1194 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-10`.
- Subsystem: `agent`
