diff --git a/cron/scheduler.py b/cron/scheduler.py index 81e256a329..c49370352c 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -35,7 +35,7 @@ from typing import List, Optional sys.path.insert(0, str(Path(__file__).parent.parent)) from hermes_constants import get_hermes_home -from hermes_cli.config import load_config +from hermes_cli.config import load_config, _expand_env_vars from hermes_time import now as _hermes_now logger = logging.getLogger(__name__) @@ -1082,6 +1082,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: if os.path.exists(_cfg_path): with open(_cfg_path) as _f: _cfg = yaml.safe_load(_f) or {} + _cfg = _expand_env_vars(_cfg) _model_cfg = _cfg.get("model", {}) if not job.get("model"): if isinstance(_model_cfg, str): diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 66df251a45..460c00add0 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1307,6 +1307,103 @@ class TestRunJobConfigLogging: f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}" +class TestRunJobConfigEnvVarExpansion: + """Verify that ${VAR} references in config.yaml are expanded when running cron jobs.""" + + _RUNTIME = { + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + } + + def test_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch): + """${VAR} in config.yaml model: is expanded using env after .env is loaded.""" + (tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_MODEL}\n") + monkeypatch.setenv("_HERMES_TEST_CRON_MODEL", "gpt-4o-mini-cron-test") + + job = {"id": "env-job", "name": "env test", "prompt": "hi"} + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=self._RUNTIME), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + success, _, _, error = run_job(job) + + assert success is True + assert error is None + kwargs = mock_agent_cls.call_args.kwargs + assert kwargs["model"] == "gpt-4o-mini-cron-test", ( + f"Expected model='gpt-4o-mini-cron-test', got {kwargs['model']!r}. " + "config.yaml ${VAR} was not expanded in the cron execution path." + ) + + def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch): + """${VAR} in config.yaml fallback_providers model: is expanded.""" + (tmp_path / "config.yaml").write_text( + "fallback_providers:\n" + " - provider: openrouter\n" + " model: ${_HERMES_TEST_CRON_FALLBACK}\n" + ) + monkeypatch.setenv("_HERMES_TEST_CRON_FALLBACK", "gpt-4o-fallback-test") + + job = {"id": "fb-job", "name": "fallback test", "prompt": "hi"} + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=self._RUNTIME), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + run_job(job) + + kwargs = mock_agent_cls.call_args.kwargs + fb = kwargs.get("fallback_model") or [] + fb_list = fb if isinstance(fb, list) else [fb] + expanded = [e.get("model") for e in fb_list if isinstance(e, dict)] + assert "gpt-4o-fallback-test" in expanded, ( + f"Expected expanded fallback model in {expanded!r}. " + "config.yaml ${VAR} in fallback_providers was not expanded." + ) + + def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch): + """When the env var is not set, the literal ${VAR} is kept verbatim (not crashed).""" + (tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_UNSET_VAR}\n") + monkeypatch.delenv("_HERMES_TEST_CRON_UNSET_VAR", raising=False) + + job = {"id": "unset-job", "name": "unset var test", "prompt": "hi"} + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=self._RUNTIME), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + success, _, _, error = run_job(job) + + assert success is True + kwargs = mock_agent_cls.call_args.kwargs + # Unresolved refs are kept verbatim — _expand_env_vars contract + assert kwargs["model"] == "${_HERMES_TEST_CRON_UNSET_VAR}" + + class TestRunJobSkillBacked: def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path): job = {