{"repo":"NousResearch/hermes-agent","public_id":"17876","display_label":"hermes config set corrupts YAML list fields","summary":"A bug where `hermes config set` destroys list-typed YAML fields by incorrectly assuming all nested nodes are dicts. Includes the issue report and associated fix attempts.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17893,"merged_at":"2026-04-30T11:32:18+00:00"},"resolution":{"state":"resolved","by":"pr-17893"},"freshness":{"state":"stale","last_verified_at":"2026-04-30T11:32:18+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":17876,"pr_number":17893,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-30T11:32:18+00:00"}],"members":[{"kind":"issue","number":17876,"title":"hermes config set destroys YAML list fields (e.g. custom_providers) — _set_nested assumes all nodes are dict","url":null,"state":"closed"},{"kind":"pr","number":17892,"title":"fix(config): preserve list-typed YAML fields in `hermes config set` (#17876)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17893","number":17893},{"kind":"pr","value":"#17876","number":17876}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":17893,"title":"fix(config): preserve YAML lists in hermes config set (#17876)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/hermes_cli/test_set_config_value.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -2305,19 +2305,55 @@ def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n     return missing\n \n \n-def _set_nested(config: dict, dotted_key: str, value):\n+def _set_nested(config, dotted_key: str, value):\n     \"\"\"Set a value at an arbitrarily nested dotted key path.\n \n-    Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n-    ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n+    Supports both dict and list navigation:\n+      _set_nested(c, \"a.b.c\", 1)     → c[\"a\"][\"b\"][\"c\"] = 1\n+      _set_nested(c, \"a.0.b\", 1)     → c[\"a\"][0][\"b\"] = 1\n+      _set_nested(c, \"providers.1\", \"x\") → c[\"providers\"][1] = \"x\"\n+\n+    Intermediate dicts are created on demand.  List indices are parsed\n+    from numeric path segments; the referenced index must already exist\n+    (we do not grow lists — the user is navigating into structure they\n+    wrote themselves).  If a segment targets a non-container leaf\n+    (scalar), the leaf is replaced with a fresh dict so the write can\n+    proceed — this preserves the pre-existing behavior for bare scalar\n+    overrides (e.g. setting ``a.b.c`` where ``a.b`` was previously a\n+    string).\n+\n+    Guards against #17876: before this fix the code unconditionally\n+    replaced any non-dict value (including lists) with ``{}``, silently\n+    destroying list-typed config like ``custom_providers`` whenever a\n+    caller used an indexed path.\n     \"\"\"\n     parts = dotted_key.split(\".\")\n     current = config\n     for part in parts[:-1]:\n-        if part not in current or not isinstance(current.get(part), dict):\n-            current[part] = {}\n-        current = current[part]\n-    current[parts[-1]] = value\n+        if isinstance(current, list):\n+            try:\n+                idx = int(part)\n+            except (TypeError, ValueError):\n+                raise TypeError(\n+                    f\"Cannot navigate into list at key {dotted_key!r}: \"\n+                    f\"segment {part!r} is not a numeric index\"\n+                )\n+            current = current[idx]\n+        elif isinstance(current, dict):\n+            existing = current.get(part)\n+            # Preserve dicts and lists; replace missing/scalar with a fresh dict.\n+            if part not in current or not isinstance(existing, (dict, list)):\n+                current[part] = {}\n+            current = current[part]\n+        else:\n+            raise TypeError(\n+                f\"Cannot navigate into {type(current).__name__} at key {dotted_key!r}\"\n+            )\n+    last = parts[-1]\n+    if isinstance(current, list):\n+        current[int(last)] = value\n+    else:\n+        current[last] = value\n \n \n def get_missing_config_fields() -> List[Dict[str, Any]]:","@@ -4421,15 +4457,11 @@ def set_config_value(key: str, value: str):\n         except Exception:\n             user_config = {}\n     \n-    # Handle nested keys (e.g., \"tts.provider\")\n-    parts = key.split('.')\n-    current = user_config\n-    \n-    for part in parts[:-1]:\n-        if part not in current or not isinstance(current.get(part), dict):\n-            current[part] = {}\n-        current = current[part]\n-    \n+    # Handle nested keys (e.g., \"tts.provider\") including numeric list\n+    # indices (e.g., \"custom_providers.0.api_key\").  Delegates to\n+    # _set_nested which preserves list-typed nodes; before #17876 the\n+    # inline navigation here silently overwrote lists with dicts.\n+\n     # Convert value to appropriate type\n     if value.lower() in ('true', 'yes', 'on'):\n         value = True","@@ -4439,8 +4471,8 @@ def set_config_value(key: str, value: str):\n         value = int(value)\n     elif value.replace('.', '', 1).isdigit():\n         value = float(value)\n-    \n-    current[parts[-1]] = value\n+\n+    _set_nested(user_config, key, value)\n     \n     # Write only user config back (not the full merged defaults)\n     ensure_hermes_home()"]},{"file":"tests/hermes_cli/test_set_config_value.py","hunks":["@@ -172,3 +172,88 @@ def test_config_command_accepts_empty_string(self, _isolated_hermes_home):\n         config_command(args)\n         config = _read_config(_isolated_hermes_home)\n         assert \"model\" in config\n+\n+\n+# ---------------------------------------------------------------------------\n+# List navigation — regression tests for #17876\n+# ---------------------------------------------------------------------------\n+\n+class TestListNavigation:\n+    \"\"\"hermes config set must preserve YAML list fields when using numeric\n+    indices.  Before #17876, _set_nested would silently replace the entire\n+    list with a dict, destroying every sibling entry.\n+    \"\"\"\n+\n+    def _write_config(self, tmp_path, body):\n+        (tmp_path / \"config.yaml\").write_text(body)\n+\n+    def test_indexed_set_preserves_sibling_list_entries(self, _isolated_hermes_home):\n+        \"\"\"Setting custom_providers.0.api_key must not destroy entry 1.\"\"\"\n+        self._write_config(_isolated_hermes_home, (\n+            \"custom_providers:\\n\"\n+            \"- name: provider-a\\n\"\n+            \"  api_key: [redacted:secret-assignment]\"\n+            \"  base_url: https://a.example.com\\n\"\n+            \"- name: provider-b\\n\"\n+            \"  api_key: [redacted:secret-assignment]\"\n+            \"  base_url: https://b.example.com\\n\"\n+        ))\n+\n+        set_config_value(\"custom_providers.0.api_key\", \"new-a\")\n+\n+        import yaml\n+        reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))\n+        # The list must still be a list\n+        assert isinstance(reloaded[\"custom_providers\"], list)\n+        assert len(reloaded[\"custom_providers\"]) == 2\n+        # Entry 0 was updated\n+        assert reloaded[\"custom_providers\"][0][\"api_key\"] == \"new-a\"\n+        assert reloaded[\"custom_providers\"][0][\"name\"] == \"provider-a\"\n+        assert reloaded[\"custom_providers\"][0][\"base_url\"] == \"https://a.example.com\"\n+        # Entry 1 is untouched\n+        assert reloaded[\"custom_providers\"][1][\"name\"] == \"provider-b\"\n+        assert reloaded[\"custom_providers\"][1][\"api_key\"] == \"old-b\"\n+        assert reloaded[\"custom_providers\"][1][\"base_url\"] == \"https://b.example.com\"\n+\n+    def test_indexed_set_preserves_non_targeted_fields(self, _isolated_hermes_home):\n+        \"\"\"Setting one field in a list entry must not drop other fields.\"\"\"\n+        self._write_config(_isolated_hermes_home, (\n+            \"custom_providers:\\n\"\n+            \"- name: provider-a\\n\"\n+            \"  api_key: old\\n\"\n+            \"  base_url: https://a.example.com\\n\"\n+            \"  models:\\n\"\n+            \"    foo: {}\\n\"\n+            \"    bar: {}\\n\"\n+        ))\n+\n+        set_config_value(\"custom_providers.0.api_key\", \"rotated\")\n+\n+        import yaml\n+        reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))\n+        entry = reloaded[\"custom_providers\"][0]\n+        assert entry[\"api_key\"] == \"rotated\"\n+        assert entry[\"name\"] == \"provider-a\"\n+        assert entry[\"base_url\"] == \"https://a.example.com\"\n+        assert set(entry[\"models\"].keys()) == {\"foo\", \"bar\"}\n+\n+    def test_deeper_nesting_through_list(self, _isolated_hermes_home):\n+        \"\"\"Navigation path mixing dict → list → dict → scalar.\"\"\"\n+        self._write_config(_isolated_hermes_home, (\n+            \"platforms:\\n\"\n+            \"  telegram:\\n\"\n+            \"    allowlist:\\n\"\n+            \"    - name: alice\\n\"\n+            \"      role: admin\\n\"\n+            \"    - name: bob\\n\"\n+            \"      role: user\\n\"\n+        ))\n+\n+        set_config_value(\"platforms.telegram.allowlist.1.role\", \"admin\")\n+\n+        import yaml\n+        reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))\n+        allowlist = reloaded[\"platforms\"][\"telegram\"][\"allowlist\"]\n+        assert isinstance(allowlist, list)\n+        assert allowlist[0] == {\"name\": \"alice\", \"role\": \"admin\"}\n+        assert allowlist[1] == {\"name\": \"bob\", \"role\": \"admin\"}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}