{"repo":"NousResearch/hermes-agent","public_id":"17572","display_label":"curator auxiliary model config not being respected","summary":"These issues and PRs focus on ensuring the curator reads and applies its own auxiliary config (provider, model, api_key, base_url) rather than falling back to the main model.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17868},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-04-30T09:46:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":17572,"pr_number":17868,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":17572,"title":"[Bug]: curator.auxiliary config is dead — curator always runs on main model","url":null,"state":"closed"},{"kind":"pr","number":17584,"title":"Fix curator LLM review to honor curator-specific auxiliary model config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0da968e52","number":0},{"kind":"pr","value":"#17868","number":17868},{"kind":"pr","value":"#17572","number":17572}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":17591,"title":"fix(curator): read curator.auxiliary config for reviewer model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17584","number":17584},{"kind":"pr","value":"#17572","number":17572}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":17781,"title":"fix(curator): honor curator.auxiliary.{provider,model} override (#17572)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0da968e52","number":0},{"kind":"pr","value":"#17868","number":17868},{"kind":"pr","value":"#17572","number":17572}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":17868,"title":"fix(curator): unify under auxiliary.curator (hermes model, dashboard)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/curator.py","hermes_cli/config.py","hermes_cli/main.py","hermes_cli/web_server.py","tests/agent/test_curator.py","web/src/pages/ModelsPage.tsx","website/docs/user-guide/features/curator.md"],"fix_hunks":[{"file":"agent/curator.py","hunks":["@@ -714,6 +714,49 @@ def _llm_pass():\n     }\n \n \n+def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:\n+    \"\"\"Pick (provider, model) for the curator review fork.\n+\n+    Curator is a regular auxiliary task slot — ``auxiliary.curator.{provider,model}``\n+    — so it participates in the canonical aux-model plumbing (``hermes model`` →\n+    auxiliary picker, the dashboard Models tab, ``auxiliary.curator.{timeout,\n+    base_url,api_key,extra_body}``). ``provider: \"auto\"`` with an empty model\n+    means \"use the main chat model\" — same default as every other aux task.\n+\n+    Legacy fallback: users who configured ``curator.auxiliary.{provider,model}``\n+    under the previous one-off schema still work. Precedence:\n+      1. ``auxiliary.curator.{provider,model}`` when both are set non-auto\n+      2. Legacy ``curator.auxiliary.{provider,model}`` when both are set\n+      3. Main ``model.{provider,default/model}`` pair\n+    \"\"\"\n+    _main = cfg.get(\"model\", {}) if isinstance(cfg.get(\"model\"), dict) else {}\n+    _main_provider = _main.get(\"provider\") or \"auto\"\n+    _main_model = _main.get(\"default\") or _main.get(\"model\") or \"\"\n+\n+    # 1. Canonical aux task slot\n+    _aux = cfg.get(\"auxiliary\", {}) if isinstance(cfg.get(\"auxiliary\"), dict) else {}\n+    _cur_task = _aux.get(\"curator\", {}) if isinstance(_aux.get(\"curator\"), dict) else {}\n+    _task_provider = (_cur_task.get(\"provider\") or \"\").strip() or None\n+    _task_model = (_cur_task.get(\"model\") or \"\").strip() or None\n+    if _task_provider and _task_provider != \"auto\" and _task_model:\n+        return _task_provider, _task_model\n+\n+    # 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)\n+    _cur = cfg.get(\"curator\", {}) if isinstance(cfg.get(\"curator\"), dict) else {}\n+    _legacy = _cur.get(\"auxiliary\", {}) if isinstance(_cur.get(\"auxiliary\"), dict) else {}\n+    _legacy_provider = _legacy.get(\"provider\") or None\n+    _legacy_model = _legacy.get(\"model\") or None\n+    if _legacy_provider and _legacy_model:\n+        logger.info(\n+            \"curator: using deprecated curator.auxiliary.{provider,model} \"\n+            \"config — please migrate to auxiliary.curator.{provider,model}\"\n+        )\n+        return _legacy_provider, _legacy_model\n+\n+    # 3. Fall through to the main chat model\n+    return _main_provider, _main_model\n+\n+\n def _run_llm_review(prompt: str) -> Dict[str, Any]:\n     \"\"\"Spawn an AIAgent fork to run the curator review prompt.\n ","@@ -749,6 +792,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:\n     # \"No models provided\"). AIAgent() without explicit provider/model\n     # arguments hits an auto-resolution path that fails for OAuth-only\n     # providers and for pool-backed credentials.\n+    #\n+    # `_resolve_review_model()` honors `auxiliary.curator.{provider,model}`\n+    # (canonical aux-task slot, wired through `hermes model` → auxiliary\n+    # picker and the dashboard Models tab), with a legacy fallback to\n+    # `curator.auxiliary.{provider,model}`. See docs/user-guide/features/curator.md.\n     _api_key = None\n     _base_url = None\n     _api_mode = None","@@ -758,9 +806,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:\n         from hermes_cli.config import load_config\n         from hermes_cli.runtime_provider import resolve_runtime_provider\n         _cfg = load_config()\n-        _m = _cfg.get(\"model\", {}) if isinstance(_cfg.get(\"model\"), dict) else {}\n-        _provider = _m.get(\"provider\") or \"auto\"\n-        _model_name = _m.get(\"default\") or _m.get(\"model\") or \"\"\n+        _provider, _model_name = _resolve_review_model(_cfg)\n         _rp = resolve_runtime_provider(\n             requested=_provider, target_model=_model_name\n         )"]},{"file":"hermes_cli/config.py","hunks":["@@ -709,6 +709,19 @@ def _ensure_hermes_home_managed(home: Path):\n             \"timeout\": 30,\n             \"extra_body\": {},\n         },\n+        # Curator — skill-usage review fork. Timeout is generous because the\n+        # review pass can take several minutes on reasoning models (umbrella\n+        # building over hundreds of candidate skills). \"auto\" = use main chat\n+        # model; override via `hermes model` → auxiliary → Curator to route\n+        # to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).\n+        \"curator\": {\n+            \"provider\": \"auto\",\n+            \"model\": \"\",\n+            \"base_url\": \"\",\n+            \"api_key\": \"\",\n+            \"timeout\": 600,\n+            \"extra_body\": {},\n+        },\n     },\n     \n     \"display\": {","@@ -949,12 +962,6 @@ def _ensure_hermes_home_managed(home: Path):\n         # Archive a skill (move to skills/.archive/) after this many days\n         # without use. Archived skills are recoverable — no auto-deletion.\n         \"archive_after_days\": 90,\n-        # Optional per-task override for the curator's aux model. Leave null\n-        # to use Hermes' main auxiliary client resolution.\n-        \"auxiliary\": {\n-            \"provider\": None,\n-            \"model\": None,\n-        },\n     },\n \n     # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth."]},{"file":"hermes_cli/main.py","hunks":["@@ -1927,6 +1927,7 @@ def _clear_stale_openai_base_url():\n     (\"mcp\",              \"MCP\",              \"MCP tool reasoning\"),\n     (\"title_generation\", \"Title generation\", \"session titles\"),\n     (\"skills_hub\",       \"Skills hub\",       \"skills search/install\"),\n+    (\"curator\",          \"Curator\",          \"skill-usage review pass\"),\n ]\n \n "]},{"file":"hermes_cli/web_server.py","hunks":["@@ -952,6 +952,7 @@ def get_model_info():\n     \"approval\",\n     \"mcp\",\n     \"title_generation\",\n+    \"curator\",\n )\n \n "]},{"file":"tests/agent/test_curator.py","hunks":["@@ -485,3 +485,153 @@ class _A:\n     captured = capsys.readouterr()\n     assert rc == 1\n     assert \"bundled\" in captured.out.lower() or \"hub\" in captured.out.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# curator review-model resolution (canonical auxiliary.curator slot)\n+#\n+# Curator was unified with the rest of the aux task system in Apr 2026 so\n+# `hermes model` → auxiliary picker, the dashboard Models tab, and the full\n+# per-task config (timeout, base_url, api_key, extra_body) all work for it.\n+# Voscko report: curator.auxiliary.{provider,model} was advertised but never\n+# read. Fix wires curator through auxiliary.curator with a legacy fallback.\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_review_model_defaults_to_main_when_slot_is_auto(curator_env):\n+    \"\"\"auxiliary.curator absent (or auto/empty) → use main model.provider/model.\"\"\"\n+    curator = curator_env[\"curator\"]\n+    cfg = {\n+        \"model\": {\"provider\": \"openrouter\", \"default\": \"openai/gpt-5.5\"},\n+    }\n+    assert curator._resolve_review_model(cfg) == (\"openrouter\", \"openai/gpt-5.5\")\n+\n+    # Explicit auto/empty slot — still main model.\n+    cfg[\"auxiliary\"] = {\"curator\": {\"provider\": \"auto\", \"model\": \"\"}}\n+    assert curator._resolve_review_model(cfg) == (\"openrouter\", \"openai/gpt-5.5\")\n+\n+\n+def test_review_model_honors_auxiliary_curator_slot(curator_env):\n+    \"\"\"auxiliary.curator.{provider,model} fully set → that pair wins.\"\"\"\n+    curator = curator_env[\"curator\"]\n+    cfg = {\n+        \"model\": {\"provider\": \"openrouter\", \"default\": \"openai/gpt-5.5\"},\n+        \"auxiliary\": {\n+            \"curator\": {\n+                \"provider\": \"openrouter\",\n+                \"model\": \"openai/gpt-5.4-mini\",\n+            },\n+        },\n+    }\n+    assert curator._resolve_review_model(cfg) == (\n+        \"openrouter\", \"openai/gpt-5.4-mini\",\n+    )\n+\n+\n+def test_review_model_auxiliary_curator_partial_override_falls_back(curator_env):\n+    \"\"\"Only one of slot provider/model set → fall back to the main pair.\n+\n+    Prevents half-configured overrides from sending an empty side to\n+    resolve_runtime_provider.\n+    \"\"\"\n+    curator = curator_env[\"curator\"]\n+    base_main = {\"provider\": \"openrouter\", \"default\": \"openai/gpt-5.5\"}\n+\n+    cfg_provider_only = {\n+        \"model\": dict(base_main),\n+        \"auxiliary\": {\"curator\": {\"provider\": \"openrouter\", \"model\": \"\"}},\n+    }\n+    assert curator._resolve_review_model(cfg_provider_only) == (\n+        \"openrouter\", \"openai/gpt-5.5\",\n+    )\n+\n+    cfg_model_only = {\n+        \"model\": dict(base_main),\n+        \"auxiliary\": {\"curator\": {\"provider\": \"auto\", \"model\": \"gpt-5.4-mini\"}},\n+    }\n+    assert curator._resolve_review_model(cfg_model_only) == (\n+        \"openrouter\", \"openai/gpt-5.5\",\n+    )\n+\n+\n+def test_review_model_legacy_curator_auxiliary_still_works(curator_env, caplog):\n+    \"\"\"Pre-unification users set curator.auxiliary.{provider,model} — honor it.\n+\n+    Emits a deprecation log line but keeps their config working.\n+    \"\"\"\n+    curator = curator_env[\"curator\"]\n+    cfg = {\n+        \"model\": {\"provider\": \"openrouter\", \"default\": \"openai/gpt-5.5\"},\n+        \"curator\": {\n+            \"auxiliary\": {\n+                \"provider\": \"openrouter\",\n+                \"model\": \"openai/gpt-5.4-mini\",\n+            },\n+        },\n+    }\n+    import logging\n+    with caplog.at_level(logging.INFO, logger=\"agent.curator\"):\n+        result = curator._resolve_review_model(cfg)\n+    assert result == (\"openrouter\", \"openai/gpt-5.4-mini\")\n+    assert any(\n+        \"deprecated curator.auxiliary\" in rec.message for rec in caplog.records\n+    ), \"expected deprecation warning when legacy curator.auxiliary is used\"\n+\n+\n+def test_review_model_new_slot_wins_over_legacy(curator_env):\n+    \"\"\"When BOTH new and legacy are set, the canonical slot wins.\"\"\"\n+    curator = curator_env[\"curator\"]\n+    cfg = {\n+        \"model\": {\"provider\": \"openrouter\", \"default\": \"openai/gpt-5.5\"},\n+        \"auxiliary\": {\n+            \"curator\": {\"provider\": \"nous\", \"model\": \"new-winner\"},\n+        },\n+        \"curator\": {\n+            \"auxiliary\": {\"provider\": \"openrouter\", \"model\": \"legacy-loser\"},\n+        },\n+    }\n+    assert curator._resolve_review_model(cfg) == (\"nous\", \"new-winner\")\n+\n+\n+def test_review_model_handles_missing_sections(curator_env):\n+    \"\"\"Missing auxiliary/curator sections never raise — fall back cleanly.\"\"\"\n+    curator = curator_env[\"curator\"]\n+    cfg = {\"model\": {\"provider\": \"anthropic\", \"model\": \"claude-sonnet-4-6\"}}\n+    assert curator._resolve_review_model(cfg) == (\n+        \"anthropic\", \"claude-sonnet-4-6\",\n+    )\n+\n+    # Completely empty config → (\"auto\", \"\") — resolve_runtime_provider\n+    # handles the auto-detection chain from there.\n+    assert curator._resolve_review_model({}) == (\"auto\", \"\")\n+\n+\n+def test_curator_slot_is_canonical_aux_task():\n+    \"\"\"Curator must be a first-class slot in every aux-task registry.\n+\n+    Four sources of truth, all checked by the shared registry test\n+    (test_aux_config.py) for the main tasks — this test pins `curator`\n+    specifically so the unification doesn't silently regress.\n+    \"\"\"\n+    from hermes_cli.config import DEFAULT_CONFIG\n+    from hermes_cli.main import _AUX_TASKS\n+    from hermes_cli.web_server import _AUX_TASK_SLOTS\n+\n+    # 1. DEFAULT_CONFIG.auxiliary — schema source\n+    assert \"curator\" in DEFAULT_CONFIG[\"auxiliary\"], \\\n+        \"curator missing from DEFAULT_CONFIG['auxiliary']\"\n+    slot = DEFAULT_CONFIG[\"auxiliary\"][\"curator\"]\n+    assert slot[\"provider\"] == \"auto\"\n+    assert slot[\"model\"] == \"\"\n+    assert slot[\"timeout\"] > 0, \"curator timeout should be set (reviews run long)\"\n+\n+    # 2. hermes_cli/main.py _AUX_TASKS — CLI picker\n+    aux_keys = {k for k, _name, _desc in _AUX_TASKS}\n+    assert \"curator\" in aux_keys, \"curator missing from _AUX_TASKS (CLI picker)\"\n+\n+    # 3. hermes_cli/web_server.py _AUX_TASK_SLOTS — REST API allowlist\n+    assert \"curator\" in _AUX_TASK_SLOTS, \\\n+        \"curator missing from _AUX_TASK_SLOTS (dashboard REST API)\"\n+\n+    # 4. web/src/pages/ModelsPage.tsx is checked at build time; the tsx\n+    #    array and this tuple share a ``Must match _AUX_TASK_SLOTS`` comment."]},{"file":"web/src/pages/ModelsPage.tsx","hunks":["@@ -44,6 +44,7 @@ const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [\n   { key: \"approval\", label: \"Approval\", hint: \"Smart auto-approve\" },\n   { key: \"mcp\", label: \"MCP\", hint: \"MCP tool routing\" },\n   { key: \"title_generation\", label: \"Title Gen\", hint: \"Session titles\" },\n+  { key: \"curator\", label: \"Curator\", hint: \"Skill-usage review\" },\n ] as const;\n \n function formatTokens(n: number): string {"]},{"file":"website/docs/user-guide/features/curator.md","hunks":["@@ -41,14 +41,38 @@ curator:\n   min_idle_hours: 2\n   stale_after_days: 30\n   archive_after_days: 90\n-  auxiliary:\n-    provider: null             # null = use main auxiliary client resolution\n-    model: null\n ```\n \n To disable entirely, set `curator.enabled: false`.\n \n-To use a cheaper aux model for the LLM review pass instead of your main model, set `curator.auxiliary.provider` and `curator.auxiliary.model` to something specific (e.g. `openrouter` + `google/gemini-3-flash-preview`).\n+### Running the review on a cheaper aux model\n+\n+The curator's LLM review pass is a regular auxiliary task slot — `auxiliary.curator` — alongside Vision, Compression, Session Search, etc. \"Auto\" means \"use my main chat model\"; override the slot to pin a specific provider + model for the review pass instead.\n+\n+**Easiest — `hermes model`:**\n+\n+```bash\n+hermes model                   # → \"Auxiliary models — side-task routing\"\n+                               # → pick \"Curator\" → pick provider → pick model\n+```\n+\n+The same picker is available in the web dashboard under the **Models** tab.\n+\n+**Direct config.yaml (equivalent):**\n+\n+```yaml\n+auxiliary:\n+  curator:\n+    provider: openrouter\n+    model: google/gemini-3-flash-preview\n+    timeout: 600               # generous — reviews can take several minutes\n+```\n+\n+Leaving `provider: auto` (the default) routes the review pass through whatever your main chat model is, matching the behavior of every other auxiliary task.\n+\n+:::note Legacy config\n+Earlier releases used a one-off `curator.auxiliary.{provider,model}` block. That path still works but emits a deprecation log line — please migrate to `auxiliary.curator` above so the curator shares the same plumbing (`hermes model`, dashboard Models tab, `base_url`, `api_key`, `timeout`, `extra_body`) as every other aux task.\n+:::\n \n ## CLI\n \n"]}]},{"kind":"pr","number":18039,"title":"fix(curator): respect curator.auxiliary config for review model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17868","number":17868}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}