{"repo":"NousResearch/hermes-agent","public_id":"18946","display_label":"Stale CLI_CONFIG cache breaks delegation config reads","summary":"Multiple bug reports and fix attempts addressing that `_load_config()` in delegate_tool.py reads from a stale in-memory `CLI_CONFIG` snapshot instead of the current on-disk config, causing `delegation.*` settings to be silently ignored at runtime.","subsystem":"agent","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":4,"merged_prs":1,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60577,"merged_at":"2026-07-08T00:17:39+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-08T00:17:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14065934065934066},"fix_edges":[{"issue_number":18946,"pr_number":60577,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T00:17:39+00:00"},{"issue_number":51435,"pr_number":60577,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T00:17:39+00:00"},{"issue_number":55380,"pr_number":60577,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T00:17:39+00:00"},{"issue_number":57930,"pr_number":60577,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T00:17:39+00:00"}],"members":[{"kind":"issue","number":18946,"title":"[Bug]: hermes config set delegation.* silently has no effect on running process (CLI_CONFIG cache stale)","url":null,"state":"closed"},{"kind":"issue","number":32671,"title":"`_load_config()` in delegate_tool.py never reads persistent config — breaks all delegation.* settings in v0.14.0","url":null,"state":"closed"},{"kind":"issue","number":50199,"title":"[Bug]: delegation.base_url ignored at runtime — _load_config() returns empty CLI_CONFIG block over correct config.yaml","url":null,"state":"closed"},{"kind":"issue","number":57930,"title":"Bug: `_load_config()` in delegate_tool.py reads stale `cli.CLI_CONFIG` snapshot — `hermes config set delegation.*` ignored by running gateway","url":null,"state":"closed"},{"kind":"pr","number":18947,"title":"fix(delegate): always reload delegation config from disk, drop stale CLI_CONFIG cache (#18946)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60577","number":60577}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":18967,"title":"fix(delegate): read delegation config from disk first to avoid stale cache","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18947","number":18947}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":18978,"title":"fix(tools): prefer load_config() over stale CLI_CONFIG in delegate _load_config()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19007","number":19007}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21405,"title":"fix(tools): prefer load_config() over stale CLI_CONFIG in delegate _load_config()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60577","number":60577}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50253,"title":"fix(delegation): merge CLI_CONFIG with on-disk config to honor base_url (#50199)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50263","number":50263},{"kind":"pr","value":"#44759","number":44759}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50263,"title":"fix(delegation): merge CLI_CONFIG with on-disk config to honor base_url (#50199)","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":57951,"title":"fix(tools): refresh delegation config from disk","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15540","number":15540},{"kind":"pr","value":"#45301","number":45301},{"kind":"pr","value":"#57930","number":57930},{"kind":"pr","value":"#11999","number":11999}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":58060,"title":"fix(delegate): prefer current delegation config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15540","number":15540}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":58657,"title":"fix(delegate): prefer current delegation config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60577","number":60577},{"kind":"pr","value":"#59982","number":59982}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60577,"title":"fix(delegation): make active config authoritative for delegation limits","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_delegate.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"tests/tools/test_delegate.py","hunks":["@@ -13,6 +13,7 @@\n import os\n import threading\n import time\n+import types\n import unittest\n from unittest.mock import MagicMock, patch\n ","@@ -21,6 +22,7 @@\n     DELEGATE_TASK_SCHEMA,\n     DelegateEvent,\n     _get_max_concurrent_children,\n+    _load_config,\n     _LEGACY_EVENT_MAP,\n     MAX_DEPTH,\n     check_delegate_requirements,","@@ -2390,6 +2392,71 @@ def test_progress_callback_task_progress_not_misrendered(self):\n class TestConcurrencyDefaults(unittest.TestCase):\n     \"\"\"Tests for the concurrency default and no hard ceiling.\"\"\"\n \n+    def test_load_config_prefers_active_persistent_config_over_cli_defaults(self):\n+        stale_cli = types.ModuleType(\"cli\")\n+        stale_cli.CLI_CONFIG = {\n+            \"delegation\": {\n+                \"max_iterations\": 45,\n+                \"model\": \"\",\n+                \"provider\": \"\",\n+                \"base_url\": \"\",\n+                \"api_key\": \"\",\n+            }\n+        }\n+        active_config = {\n+            \"delegation\": {\n+                \"max_iterations\": 50,\n+                \"max_concurrent_children\": 50,\n+                \"max_spawn_depth\": 10,\n+            }\n+        }\n+\n+        with patch.dict(\"sys.modules\", {\"cli\": stale_cli}):\n+            with patch(\n+                \"hermes_cli.config.load_config_readonly\", return_value=active_config\n+            ):\n+                self.assertEqual(_load_config()[\"max_concurrent_children\"], 50)\n+                self.assertEqual(_get_max_concurrent_children(), 50)\n+\n+    def test_load_config_falls_back_to_cli_config_when_persistent_load_fails(self):\n+        fallback_cli = types.ModuleType(\"cli\")\n+        fallback_cli.CLI_CONFIG = {\n+            \"delegation\": {\n+                \"max_iterations\": 45,\n+                \"max_concurrent_children\": 8,\n+            }\n+        }\n+\n+        with patch.dict(\"sys.modules\", {\"cli\": fallback_cli}):\n+            with patch(\n+                \"hermes_cli.config.load_config_readonly\",\n+                side_effect=RuntimeError(\"boom\"),\n+            ):\n+                self.assertEqual(_load_config()[\"max_concurrent_children\"], 8)\n+\n+    def test_load_config_prefers_cli_config_when_user_config_ignored(self):\n+        # `hermes chat --ignore-user-config` sets HERMES_IGNORE_USER_CONFIG=1,\n+        # which only load_cli_config() honors. The delegation loader must keep\n+        # CLI_CONFIG authoritative under the flag so user config.yaml\n+        # delegation keys stay suppressed.\n+        ignoring_cli = types.ModuleType(\"cli\")\n+        ignoring_cli.CLI_CONFIG = {\n+            \"delegation\": {\n+                \"max_iterations\": 45,\n+                \"max_concurrent_children\": 4,\n+            }\n+        }\n+        user_config = {\"delegation\": {\"max_concurrent_children\": 50}}\n+\n+        with patch.dict(\"sys.modules\", {\"cli\": ignoring_cli}):\n+            with patch.dict(os.environ, {\"HERMES_IGNORE_USER_CONFIG\": \"1\"}):\n+                with patch(\n+                    \"hermes_cli.config.load_config_readonly\",\n+                    return_value=user_config,\n+                ) as mock_loader:\n+                    self.assertEqual(_load_config()[\"max_concurrent_children\"], 4)\n+                    mock_loader.assert_not_called()\n+\n     @patch(\"tools.delegate_tool._load_config\", return_value={})\n     def test_default_is_three(self, mock_cfg):\n         # Clear env var if set"]},{"file":"tools/delegate_tool.py","hunks":["@@ -3098,26 +3098,40 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:\n \n \n def _load_config() -> dict:\n-    \"\"\"Load delegation config from CLI_CONFIG or persistent config.\n-\n-    Checks the runtime config (cli.py CLI_CONFIG) first, then falls back\n-    to the persistent config (hermes_cli/config.py load_config()) so that\n-    ``delegation.model`` / ``delegation.provider`` are picked up regardless\n-    of the entry point (CLI, gateway, cron).\n+    \"\"\"Load delegation config from the active Hermes config.\n+\n+    Prefer the shared persistent loader because it follows the active\n+    HERMES_HOME/profile. ``cli.CLI_CONFIG`` is a legacy fallback for entry\n+    points that cannot import the shared loader; importing it first can return\n+    an old default ``delegation`` block and hide user-set keys such as\n+    ``max_concurrent_children``.\n+\n+    Uses ``load_config_readonly()``: every consumer of this dict is read-only\n+    (``.get()`` lookups), and this runs on each ``get_definitions()`` schema\n+    rebuild via ``_get_max_concurrent_children``, so skipping the defensive\n+    deepcopy matters. Do NOT mutate the returned dict.\n+\n+    ``HERMES_IGNORE_USER_CONFIG=1`` (``hermes chat --ignore-user-config``) is\n+    only honored by the legacy ``cli`` loader, not the shared one, so when the\n+    flag is set we keep ``cli.CLI_CONFIG`` authoritative to preserve the\n+    flag's contract of suppressing user config.yaml settings.\n     \"\"\"\n+    prefer_legacy = os.environ.get(\"HERMES_IGNORE_USER_CONFIG\") == \"1\"\n+    if not prefer_legacy:\n+        try:\n+            from hermes_cli.config import load_config_readonly\n+\n+            full = load_config_readonly()\n+            cfg = full.get(\"delegation\") or {}\n+            if isinstance(cfg, dict):\n+                return cfg\n+        except Exception:\n+            pass\n     try:\n         from cli import CLI_CONFIG\n \n         cfg = CLI_CONFIG.get(\"delegation\") or {}\n-        if cfg:\n-            return cfg\n-    except Exception:\n-        pass\n-    try:\n-        from hermes_cli.config import load_config\n-\n-        full = load_config()\n-        return full.get(\"delegation\") or {}\n+        return cfg if isinstance(cfg, dict) else {}\n     except Exception:\n         return {}\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}