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