Skip to content

Commit 75bce31

Browse files
briandevansclaude
authored andcommitted
fix(cron): expand \${VAR} refs in config.yaml during job execution (#15890)
The cron scheduler's run_job() loaded config.yaml with yaml.safe_load() but never called _expand_env_vars(), so ${HERMES_MODEL} and similar references in model:, fallback_providers:, and other config.yaml fields were forwarded to the LLM API as literal strings, causing HTTP 400 errors. The normal CLI path has always called _expand_env_vars() via load_config(), so this was a cron-only gap. The .env load at the top of run_job() already populates os.environ before config.yaml is read, so the expansion sees the correct values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fd9c32c commit 75bce31

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

cron/scheduler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
sys.path.insert(0, str(Path(__file__).parent.parent))
3636

3737
from hermes_constants import get_hermes_home
38-
from hermes_cli.config import load_config
38+
from hermes_cli.config import load_config, _expand_env_vars
3939
from hermes_time import now as _hermes_now
4040

4141
logger = logging.getLogger(__name__)
@@ -1082,6 +1082,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
10821082
if os.path.exists(_cfg_path):
10831083
with open(_cfg_path) as _f:
10841084
_cfg = yaml.safe_load(_f) or {}
1085+
_cfg = _expand_env_vars(_cfg)
10851086
_model_cfg = _cfg.get("model", {})
10861087
if not job.get("model"):
10871088
if isinstance(_model_cfg, str):

tests/cron/test_scheduler.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,103 @@ def test_bad_prefill_messages_is_logged(self, caplog, tmp_path):
13071307
f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"
13081308

13091309

1310+
class TestRunJobConfigEnvVarExpansion:
1311+
"""Verify that ${VAR} references in config.yaml are expanded when running cron jobs."""
1312+
1313+
_RUNTIME = {
1314+
"api_key": "test-key",
1315+
"base_url": "https://example.invalid/v1",
1316+
"provider": "openrouter",
1317+
"api_mode": "chat_completions",
1318+
}
1319+
1320+
def test_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):
1321+
"""${VAR} in config.yaml model: is expanded using env after .env is loaded."""
1322+
(tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_MODEL}\n")
1323+
monkeypatch.setenv("_HERMES_TEST_CRON_MODEL", "gpt-4o-mini-cron-test")
1324+
1325+
job = {"id": "env-job", "name": "env test", "prompt": "hi"}
1326+
fake_db = MagicMock()
1327+
1328+
with patch("cron.scheduler._hermes_home", tmp_path), \
1329+
patch("cron.scheduler._resolve_origin", return_value=None), \
1330+
patch("dotenv.load_dotenv"), \
1331+
patch("hermes_state.SessionDB", return_value=fake_db), \
1332+
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
1333+
return_value=self._RUNTIME), \
1334+
patch("run_agent.AIAgent") as mock_agent_cls:
1335+
mock_agent = MagicMock()
1336+
mock_agent.run_conversation.return_value = {"final_response": "ok"}
1337+
mock_agent_cls.return_value = mock_agent
1338+
success, _, _, error = run_job(job)
1339+
1340+
assert success is True
1341+
assert error is None
1342+
kwargs = mock_agent_cls.call_args.kwargs
1343+
assert kwargs["model"] == "gpt-4o-mini-cron-test", (
1344+
f"Expected model='gpt-4o-mini-cron-test', got {kwargs['model']!r}. "
1345+
"config.yaml ${VAR} was not expanded in the cron execution path."
1346+
)
1347+
1348+
def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):
1349+
"""${VAR} in config.yaml fallback_providers model: is expanded."""
1350+
(tmp_path / "config.yaml").write_text(
1351+
"fallback_providers:\n"
1352+
" - provider: openrouter\n"
1353+
" model: ${_HERMES_TEST_CRON_FALLBACK}\n"
1354+
)
1355+
monkeypatch.setenv("_HERMES_TEST_CRON_FALLBACK", "gpt-4o-fallback-test")
1356+
1357+
job = {"id": "fb-job", "name": "fallback test", "prompt": "hi"}
1358+
fake_db = MagicMock()
1359+
1360+
with patch("cron.scheduler._hermes_home", tmp_path), \
1361+
patch("cron.scheduler._resolve_origin", return_value=None), \
1362+
patch("dotenv.load_dotenv"), \
1363+
patch("hermes_state.SessionDB", return_value=fake_db), \
1364+
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
1365+
return_value=self._RUNTIME), \
1366+
patch("run_agent.AIAgent") as mock_agent_cls:
1367+
mock_agent = MagicMock()
1368+
mock_agent.run_conversation.return_value = {"final_response": "ok"}
1369+
mock_agent_cls.return_value = mock_agent
1370+
run_job(job)
1371+
1372+
kwargs = mock_agent_cls.call_args.kwargs
1373+
fb = kwargs.get("fallback_model") or []
1374+
fb_list = fb if isinstance(fb, list) else [fb]
1375+
expanded = [e.get("model") for e in fb_list if isinstance(e, dict)]
1376+
assert "gpt-4o-fallback-test" in expanded, (
1377+
f"Expected expanded fallback model in {expanded!r}. "
1378+
"config.yaml ${VAR} in fallback_providers was not expanded."
1379+
)
1380+
1381+
def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch):
1382+
"""When the env var is not set, the literal ${VAR} is kept verbatim (not crashed)."""
1383+
(tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_UNSET_VAR}\n")
1384+
monkeypatch.delenv("_HERMES_TEST_CRON_UNSET_VAR", raising=False)
1385+
1386+
job = {"id": "unset-job", "name": "unset var test", "prompt": "hi"}
1387+
fake_db = MagicMock()
1388+
1389+
with patch("cron.scheduler._hermes_home", tmp_path), \
1390+
patch("cron.scheduler._resolve_origin", return_value=None), \
1391+
patch("dotenv.load_dotenv"), \
1392+
patch("hermes_state.SessionDB", return_value=fake_db), \
1393+
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
1394+
return_value=self._RUNTIME), \
1395+
patch("run_agent.AIAgent") as mock_agent_cls:
1396+
mock_agent = MagicMock()
1397+
mock_agent.run_conversation.return_value = {"final_response": "ok"}
1398+
mock_agent_cls.return_value = mock_agent
1399+
success, _, _, error = run_job(job)
1400+
1401+
assert success is True
1402+
kwargs = mock_agent_cls.call_args.kwargs
1403+
# Unresolved refs are kept verbatim — _expand_env_vars contract
1404+
assert kwargs["model"] == "${_HERMES_TEST_CRON_UNSET_VAR}"
1405+
1406+
13101407
class TestRunJobSkillBacked:
13111408
def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path):
13121409
job = {

0 commit comments

Comments
 (0)