{"repo":"NousResearch/hermes-agent","public_id":"43899","display_label":"Cron job model resolution and error handling","summary":"These issues and pull requests address a bug where cron jobs fail when no model is explicitly set, focusing on resolving the default model from config and surfacing a clear error when no model can be determined.","subsystem":"cron","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50011},"resolution":{"state":"resolved","by":"pr-50011"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T07:07:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":23979,"pr_number":50011,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":43899,"pr_number":50011,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":43899,"title":"[Bug]: Cron jobs fail with 'Model parameter is required' when model is not explicitly set on the job","url":null,"state":"closed"},{"kind":"pr","number":43952,"title":"fix(cron): prevent empty model from reaching the API — fail early with clear error","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50011","number":50011},{"kind":"pr","value":"#50011","number":50011}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45411,"title":"fix(cron): resolve model.default from config.yaml when job has no explicit model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43952","number":43952},{"kind":"pr","value":"#43899","number":43899}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45501,"title":"fix: cron model.default fallback + VIRTUAL_ENV leak into subprocesses (P1 bugs)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50011","number":50011}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45550,"title":"fix(cron): resolve model from config.yaml when not set on job","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50011","number":50011},{"kind":"pr","value":"#50011","number":50011}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":46072,"title":"fix(cron): report missing default model clearly","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50011","number":50011},{"kind":"pr","value":"#45550","number":45550}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50011,"title":"fix(cron): resolve model.default + fail fast on missing model","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","scripts/release.py","tests/cron/conftest.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -1652,6 +1652,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n                 else str(delivery_target[\"thread_id\"])\n             )\n \n+        # Model resolution precedence: per-job override > HERMES_MODEL env >\n+        # config.yaml ``model:`` (string or ``{default: ...}``). The per-job\n+        # value is intentionally re-read from storage every tick so a\n+        # ``cronjob action=update model=...`` after a failed run takes effect\n+        # on the next tick — there is no in-memory cache.\n         model = job.get(\"model\") or os.getenv(\"HERMES_MODEL\") or \"\"\n \n         # Load config.yaml for model, reasoning, prefill, toolsets, provider routing","@@ -1672,15 +1677,34 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n                 except Exception:\n                     pass\n                 _cfg = _expand_env_vars(_cfg)\n-                _model_cfg = _cfg.get(\"model\", {})\n+                # Coerce null/missing to {} so a falsy default never\n+                # clobbers an already-resolved env value with ``None``.\n+                _model_cfg = _cfg.get(\"model\") or {}\n                 if not job.get(\"model\"):\n                     if isinstance(_model_cfg, str):\n                         model = _model_cfg\n                     elif isinstance(_model_cfg, dict):\n-                        model = _model_cfg.get(\"default\", model)\n+                        # Mirror the CLI/oneshot resolution: prefer ``default``,\n+                        # accept a ``model`` alias, overwrite only when truthy.\n+                        _default = _model_cfg.get(\"default\") or _model_cfg.get(\"model\")\n+                        if _default:\n+                            model = _default\n         except Exception as e:\n             logger.warning(\"Job '%s': failed to load config.yaml, using defaults: %s\", job_id, e)\n \n+        # Fail fast if no model resolved from job / env / config.yaml: an empty\n+        # model otherwise reaches the provider as an opaque 400 (#23979).\n+        if not (isinstance(model, str) and model.strip()):\n+            raise RuntimeError(\n+                f\"Cron job '{job_name}' has no model configured \"\n+                f\"(job.model={job.get('model')!r}, \"\n+                f\"HERMES_MODEL={os.getenv('HERMES_MODEL', '')!r}, \"\n+                \"config.yaml model.default missing or empty). \"\n+                f\"Set a per-job model via \"\n+                f\"`cronjob action=update job_id={job_id} model=<name>` or set a \"\n+                \"default with `hermes model <name>`.\"\n+            )\n+\n         # Apply IPv4 preference if configured.\n         try:\n             from hermes_constants import apply_ipv4_preference"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,8 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"szzhoujiarui-sketch\",  # cron model.default salvage co-author (#45550)\n+    \"[redacted:email]\": \"rayjun\",  # cron model.default salvage co-author (#43952)\n     \"[redacted:email]\": \"izumi0uu\",  # PR #49544 salvage (native rich reply echo; #49534)\n     \"[redacted:email]\": \"w31rdm4ch1nZ\",\n     \"[redacted:email]\": \"x7peeps\","]},{"file":"tests/cron/conftest.py","hunks":["@@ -0,0 +1,21 @@\n+\"\"\"Cron-test fixtures.\n+\n+Provides a default ``HERMES_MODEL`` for cron run_job tests so each one\n+doesn't have to spell out a model. The global conftest blanks\n+HERMES_MODEL hermetically; without this autouse fixture every cron test\n+that exercises ``run_job`` would hit the fail-fast guard added in\n+``cron/scheduler.py`` (see issue #23979) and have to be rewritten.\n+\n+Tests that specifically need ``HERMES_MODEL`` unset — model-resolution\n+edge cases — call ``monkeypatch.delenv(\"HERMES_MODEL\", raising=False)``\n+inside the test, which overrides this fixture's value for that scope.\n+\"\"\"\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def _default_cron_test_model(monkeypatch):\n+    \"\"\"Pin a default HERMES_MODEL so cron run_job tests have a resolvable model.\"\"\"\n+    monkeypatch.setenv(\"HERMES_MODEL\", \"test-cron-default-model\")\n+    yield"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -1616,6 +1616,7 @@ def test_legacy_agent_prefill_messages_file_is_loaded(self, tmp_path, monkeypatc\n     def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):\n         \"\"\"${VAR} in config.yaml fallback_providers model: is expanded.\"\"\"\n         (tmp_path / \"config.yaml\").write_text(\n+            \"model: primary-model\\n\"\n             \"fallback_providers:\\n\"\n             \"  - provider: openrouter\\n\"\n             \"    model: ${_HERMES_TEST_CRON_FALLBACK}\\n\"","@@ -1672,6 +1673,238 @@ def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch):\n         assert kwargs[\"model\"] == \"${_HERMES_TEST_CRON_UNSET_VAR}\"\n \n \n+class TestRunJobModelResolution:\n+    \"\"\"Verify defensive model resolution for jobs stored with ``model: null``.\n+\n+    Issue #23979: a cron job created without an explicit model is stored as\n+    ``model: null``. At fire time the scheduler must:\n+      1. fall back to ``HERMES_MODEL`` env if set,\n+      2. else fall back to config.yaml ``model.default`` if set,\n+      3. else fail fast with an actionable error — never let an empty string\n+         reach the provider where it surfaces as an opaque 400.\n+    \"\"\"\n+\n+    _RUNTIME = {\n+        \"api_key\": \"test-key\",\n+        \"base_url\": \"https://example.invalid/v1\",\n+        \"provider\": \"openrouter\",\n+        \"api_mode\": \"chat_completions\",\n+    }\n+\n+    def test_null_job_model_falls_back_to_env(self, tmp_path, monkeypatch):\n+        \"\"\"``model: null`` on the job uses HERMES_MODEL when set.\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"\")\n+        monkeypatch.setenv(\"HERMES_MODEL\", \"env-model\")\n+\n+        job = {\"id\": \"null-model-job\", \"name\": \"null model\", \"prompt\": \"hi\", \"model\": None}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+            mock_agent_cls.return_value = mock_agent\n+            success, _, _, error = run_job(job)\n+\n+        assert success is True\n+        assert error is None\n+        assert mock_agent_cls.call_args.kwargs[\"model\"] == \"env-model\"\n+\n+    def test_null_job_model_falls_back_to_config_default(self, tmp_path, monkeypatch):\n+        \"\"\"``model: null`` on the job uses config.yaml model.default when env is empty.\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"model:\\n  default: config-default-model\\n\")\n+        monkeypatch.delenv(\"HERMES_MODEL\", raising=False)\n+\n+        job = {\"id\": \"cfg-default-job\", \"name\": \"cfg default\", \"prompt\": \"hi\", \"model\": None}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+            mock_agent_cls.return_value = mock_agent\n+            success, _, _, error = run_job(job)\n+\n+        assert success is True\n+        assert error is None\n+        assert mock_agent_cls.call_args.kwargs[\"model\"] == \"config-default-model\"\n+\n+    def test_explicit_null_model_block_in_config_does_not_overwrite_env(self, tmp_path, monkeypatch):\n+        \"\"\"``model: null`` in config.yaml must not overwrite a resolved HERMES_MODEL.\n+\n+        Regression: before #23979 the resolver coerced ``model: null`` to\n+        ``{}`` only via the ``.get(\"model\", {})`` default — which does not\n+        fire when the key is present with a None value. The resolver then\n+        skipped both branches and kept the env value, but a similar\n+        ``model: {default: null}`` shape would call ``.get(\"default\", model)``\n+        which returns ``None`` and clobbered ``model``.\n+        \"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"model:\\n  default: null\\n\")\n+        monkeypatch.setenv(\"HERMES_MODEL\", \"env-model\")\n+\n+        job = {\"id\": \"null-default-job\", \"name\": \"null default\", \"prompt\": \"hi\", \"model\": None}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+            mock_agent_cls.return_value = mock_agent\n+            success, _, _, error = run_job(job)\n+\n+        assert success is True\n+        assert mock_agent_cls.call_args.kwargs[\"model\"] == \"env-model\"\n+\n+    def test_no_model_anywhere_fails_with_actionable_error(self, tmp_path, monkeypatch):\n+        \"\"\"All three sources empty → fail fast with a clear message, not an opaque 400.\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"\")\n+        monkeypatch.delenv(\"HERMES_MODEL\", raising=False)\n+\n+        job = {\"id\": \"no-model-job\", \"name\": \"no model anywhere\", \"prompt\": \"hi\", \"model\": None}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            success, _, _, error = run_job(job)\n+\n+        assert success is False\n+        assert error is not None\n+        assert \"no model configured\" in error\n+        # AIAgent must never be constructed with an empty model — that's\n+        # precisely the bug we're guarding against.\n+        mock_agent_cls.assert_not_called()\n+\n+    def test_job_model_update_takes_effect_on_next_run(self, tmp_path, monkeypatch):\n+        \"\"\"The per-job model is re-read every tick — no in-memory cache.\n+\n+        This is the property the original bug report asked for. We verify\n+        it by calling run_job twice with the same job dict mutated between\n+        calls, simulating the storage update flow.\n+        \"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"\")\n+        monkeypatch.delenv(\"HERMES_MODEL\", raising=False)\n+\n+        job = {\"id\": \"updated-model-job\", \"name\": \"updated\", \"prompt\": \"hi\", \"model\": \"first-model\"}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+            mock_agent_cls.return_value = mock_agent\n+\n+            run_job(job)\n+            assert mock_agent_cls.call_args.kwargs[\"model\"] == \"first-model\"\n+\n+            job[\"model\"] = \"second-model\"  # simulates jobs.json being rewritten\n+            run_job(job)\n+            assert mock_agent_cls.call_args.kwargs[\"model\"] == \"second-model\"\n+\n+    def test_config_model_as_plain_string(self, tmp_path, monkeypatch):\n+        \"\"\"config.yaml ``model:`` given as a bare string is used directly.\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"model: string-form-model\\n\")\n+        monkeypatch.delenv(\"HERMES_MODEL\", raising=False)\n+\n+        job = {\"id\": \"string-cfg-job\", \"name\": \"string cfg\", \"prompt\": \"hi\", \"model\": None}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+            mock_agent_cls.return_value = mock_agent\n+            success, _, _, error = run_job(job)\n+\n+        assert success is True\n+        assert error is None\n+        assert mock_agent_cls.call_args.kwargs[\"model\"] == \"string-form-model\"\n+\n+    def test_config_model_alias_key_resolves(self, tmp_path, monkeypatch):\n+        \"\"\"A ``model: {model: ...}`` alias key resolves like the CLI sibling.\n+\n+        ``hermes_cli/oneshot.py``, ``fallback_cmd.py`` and ``prompt_size.py``\n+        all accept ``model.model`` as an alias for ``model.default``. The cron\n+        resolver mirrors that so a config that works in the CLI also works in\n+        cron.\n+        \"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"model:\\n  model: alias-key-model\\n\")\n+        monkeypatch.delenv(\"HERMES_MODEL\", raising=False)\n+\n+        job = {\"id\": \"alias-job\", \"name\": \"alias\", \"prompt\": \"hi\", \"model\": None}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+            mock_agent_cls.return_value = mock_agent\n+            success, _, _, error = run_job(job)\n+\n+        assert success is True\n+        assert error is None\n+        assert mock_agent_cls.call_args.kwargs[\"model\"] == \"alias-key-model\"\n+\n+    def test_corrupt_config_yaml_does_not_crash_with_job_model(self, tmp_path, monkeypatch):\n+        \"\"\"A malformed config.yaml degrades gracefully when the job has a model.\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(\"{{{invalid yaml!!!\")\n+        monkeypatch.delenv(\"HERMES_MODEL\", raising=False)\n+\n+        job = {\"id\": \"corrupt-job\", \"name\": \"corrupt\", \"prompt\": \"hi\", \"model\": \"explicit-model\"}\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                   return_value=self._RUNTIME), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\"final_response\": \"ok\"}\n+            mock_agent_cls.return_value = mock_agent\n+            success, _, _, error = run_job(job)\n+\n+        # Explicit job model survives the corrupt-config fall-through.\n+        assert success is True\n+        assert error is None\n+        assert mock_agent_cls.call_args.kwargs[\"model\"] == \"explicit-model\"\n+\n+\n class TestRunJobSkillBacked:\n     def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path):\n         job = {\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}