From ae5b2de2fa3cc0a1b0ce9137f1c7994d343d232f Mon Sep 17 00:00:00 2001 From: Vinoth Date: Wed, 20 May 2026 15:31:53 -0500 Subject: [PATCH] fix: expand skill bundles in cron jobs --- cron/scheduler.py | 26 ++++++++ .../cron/test_cron_prompt_injection_skill.py | 64 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/cron/scheduler.py b/cron/scheduler.py index d5bd571b7..91671b46e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1115,10 +1115,36 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: from tools.skills_tool import skill_view from tools.skill_usage import bump_use + from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key parts = [] skipped: list[str] = [] for skill_name in skill_names: + # Cron jobs historically accepted only skill names here, but the CLI/gateway + # slash-command path lets bundles shadow skills with the same slug. Mirror + # that behavior so `skills: ["my-bundle"]` expands bundle members instead + # of being treated as a missing skill. + bundle_key = resolve_bundle_command_key(skill_name.lstrip("/")) + if bundle_key: + bundle_payload = build_bundle_invocation_message( + bundle_key, + user_instruction="", + task_id=str(job.get("id") or "") or None, + ) + if bundle_payload: + bundle_message, _loaded_bundle_skills, _missing_bundle_skills = bundle_payload + if parts: + parts.append("") + parts.append(bundle_message) + continue + logger.warning( + "Cron job '%s': bundle '%s' could not load any skills, skipping", + job.get("name", job.get("id")), + skill_name, + ) + skipped.append(skill_name) + continue + try: loaded = json.loads(skill_view(skill_name)) except (json.JSONDecodeError, TypeError): diff --git a/tests/cron/test_cron_prompt_injection_skill.py b/tests/cron/test_cron_prompt_injection_skill.py index bab3acab5..4bb07d6d8 100644 --- a/tests/cron/test_cron_prompt_injection_skill.py +++ b/tests/cron/test_cron_prompt_injection_skill.py @@ -41,6 +41,7 @@ def cron_env(tmp_path, monkeypatch): (hermes_home / "cron").mkdir() (hermes_home / "cron" / "output").mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("HERMES_BUNDLES_DIR", str(hermes_home / "skill-bundles")) # Patch the module-level SKILLS_DIR snapshots that `skill_view()` # uses. Without this, the tool resolves against the real @@ -49,6 +50,11 @@ def cron_env(tmp_path, monkeypatch): monkeypatch.setattr(_skills_tool, "SKILLS_DIR", skills_dir) monkeypatch.setattr(_skills_tool, "HERMES_HOME", hermes_home) + # Reset bundle cache and make bundle discovery hit this test home. + import agent.skill_bundles as _skill_bundles + _skill_bundles._bundles_cache = {} + _skill_bundles._bundles_cache_mtime = None + # Return both the home dir and the scheduler module so tests use the # CURRENT module object (post any reload that happened in fixtures of # previously-executed tests in the same worker). @@ -66,6 +72,20 @@ def _plant_skill(hermes_home: Path, name: str, body: str) -> None: ) +def _plant_bundle(hermes_home: Path, name: str, skills: list[str], instruction: str = "") -> None: + """Drop a bundle YAML into ~/.hermes/skill-bundles/ and refresh cache.""" + bundles_dir = hermes_home / "skill-bundles" + bundles_dir.mkdir(parents=True, exist_ok=True) + lines = [f"name: {name}", "skills:"] + lines.extend(f" - {skill}" for skill in skills) + if instruction: + lines.append("instruction: |") + lines.extend(f" {line}" for line in instruction.splitlines()) + (bundles_dir / f"{name}.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + import agent.skill_bundles as _skill_bundles + _skill_bundles.scan_bundles() + + # --------------------------------------------------------------------------- # _scan_assembled_cron_prompt — isolated unit # --------------------------------------------------------------------------- @@ -255,3 +275,47 @@ class TestBuildJobPromptScansSkillContent: prompt = scheduler._build_job_prompt(job) assert prompt is not None assert "could not be found" in prompt + + def test_skill_bundle_in_job_skills_loads_referenced_skills(self, cron_env): + hermes_home, scheduler = cron_env + _plant_skill(hermes_home, "alpha-skill", "Alpha guidance for the cron task.") + _plant_skill(hermes_home, "beta-skill", "Beta guidance for the cron task.") + _plant_bundle( + hermes_home, + "article-pipeline", + ["alpha-skill", "beta-skill"], + instruction="Use the skills in order.", + ) + + job = { + "id": "job-bundle", + "name": "bundle cron", + "prompt": "write the report", + "skills": ["article-pipeline"], + } + + prompt = scheduler._build_job_prompt(job) + assert prompt is not None + assert '"article-pipeline" skill bundle' in prompt + assert "Alpha guidance for the cron task." in prompt + assert "Beta guidance for the cron task." in prompt + assert "Bundle instruction: Use the skills in order." in prompt + assert "skill(s) were listed for this job but could not be found" not in prompt + + def test_bundle_name_shadows_skill_name_for_cron_jobs(self, cron_env): + hermes_home, scheduler = cron_env + _plant_skill(hermes_home, "article-pipeline", "Standalone skill should not win.") + _plant_skill(hermes_home, "bundle-member", "Bundle member should win.") + _plant_bundle(hermes_home, "article-pipeline", ["bundle-member"]) + + job = { + "id": "job-bundle-shadow", + "name": "bundle shadows skill", + "prompt": "run", + "skills": ["article-pipeline"], + } + + prompt = scheduler._build_job_prompt(job) + assert prompt is not None + assert "Bundle member should win." in prompt + assert "Standalone skill should not win." not in prompt