From ef5eaf8d8757a5e75fa571042abc287430192f53 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:48:50 -0700 Subject: [PATCH] feat(cron): honor `hermes tools` config for the cron platform (#14798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cron now resolves its toolset from the same per-platform config the gateway uses — `_get_platform_tools(cfg, 'cron')` — instead of blindly loading every default toolset. Existing cron jobs without a per-job override automatically lose `moa`, `homeassistant`, and `rl` (the `_DEFAULT_OFF_TOOLSETS` set), which stops the "surprise $4.63 mixture_of_agents run" class of bug (Norbert, Discord). Precedence inside `run_job`: 1. per-job `enabled_toolsets` (PR #14767 / #6130) — wins if set 2. `_get_platform_tools(cfg, 'cron')` — new, the blanket gate 3. `None` fallback (legacy) — only on resolver exception Changes: - hermes_cli/platforms.py: register 'cron' with default_toolset 'hermes-cron' - toolsets.py: add 'hermes-cron' toolset (mirrors 'hermes-cli'; `_get_platform_tools` then filters via `_DEFAULT_OFF_TOOLSETS`) - cron/scheduler.py: add `_resolve_cron_enabled_toolsets(job, cfg)`, call it at the `AIAgent(...)` kwargs site - tests/cron/test_scheduler.py: replace the 'None when not set' test (outdated contract) with an invariant ('moa not in default cron toolset') + new per-job-wins precedence test - tests/hermes_cli/test_tools_config.py: mark 'cron' as non-messaging in the gateway-toolset-coverage test --- cron/scheduler.py | 33 +++++++++++++++++++- hermes_cli/platforms.py | 1 + tests/cron/test_scheduler.py | 44 +++++++++++++++++++++++++-- tests/hermes_cli/test_tools_config.py | 2 +- toolsets.py | 13 +++++++- 5 files changed, 88 insertions(+), 5 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 9797703744..d051a7ab36 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -40,6 +40,37 @@ from hermes_time import now as _hermes_now logger = logging.getLogger(__name__) + +def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: + """Resolve the toolset list for a cron job. + + Precedence: + 1. Per-job ``enabled_toolsets`` (set via ``cronjob`` tool on create/update). + Keeps the agent's job-scoped toolset override intact — #6130. + 2. Per-platform ``hermes tools`` config for the ``cron`` platform. + Mirrors gateway behavior (``_get_platform_tools(cfg, platform_key)``) + so users can gate cron toolsets globally without recreating every job. + 3. ``None`` on any lookup failure — AIAgent loads the full default set + (legacy behavior before this change, preserved as the safety net). + + _DEFAULT_OFF_TOOLSETS ({moa, homeassistant, rl}) are removed by + ``_get_platform_tools`` for unconfigured platforms, so fresh installs + get cron WITHOUT ``moa`` by default (issue reported by Norbert — + surprise $4.63 run). + """ + per_job = job.get("enabled_toolsets") + if per_job: + return per_job + try: + from hermes_cli.tools_config import _get_platform_tools # lazy: avoid heavy import at cron module load + return sorted(_get_platform_tools(cfg or {}, "cron")) + except Exception as exc: + logger.warning( + "Cron toolset resolution failed, falling back to full default toolset: %s", + exc, + ) + return None + # Valid delivery platforms — used to validate user-supplied platform names # in cron delivery targets, preventing env var enumeration via crafted names. _KNOWN_DELIVERY_PLATFORMS = frozenset({ @@ -886,7 +917,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: providers_ignored=pr.get("ignore"), providers_order=pr.get("order"), provider_sort=pr.get("sort"), - enabled_toolsets=job.get("enabled_toolsets") or None, + enabled_toolsets=_resolve_cron_enabled_toolsets(job, _cfg), disabled_toolsets=["cronjob", "messaging", "clarify"], quiet_mode=True, skip_context_files=True, # Don't inject SOUL.md/AGENTS.md from scheduler cwd diff --git a/hermes_cli/platforms.py b/hermes_cli/platforms.py index 1fc3a3a850..05507eaced 100644 --- a/hermes_cli/platforms.py +++ b/hermes_cli/platforms.py @@ -38,6 +38,7 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([ ("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")), ("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")), ("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")), + ("cron", PlatformInfo(label="⏰ Cron", default_toolset="hermes-cron")), ]) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 421d6859d9..4cd4b7cd75 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -710,7 +710,15 @@ class TestRunJobSessionPersistence: kwargs = mock_agent_cls.call_args.kwargs assert kwargs["enabled_toolsets"] == ["web", "terminal", "file"] - def test_run_job_enabled_toolsets_none_when_not_set(self, tmp_path): + def test_run_job_enabled_toolsets_resolves_from_platform_config_when_not_set(self, tmp_path): + """When a job has no explicit enabled_toolsets, the scheduler now + resolves them from ``hermes tools`` platform config for ``cron`` + (PR #14xxx — blanket fix for Norbert's surprise ``moa`` run). + + The legacy "pass None → AIAgent loads full default" path is still + reachable, but only when ``_get_platform_tools`` raises (safety net + for any unexpected config shape). + """ job = { "id": "no-toolset-job", "name": "test", @@ -725,7 +733,39 @@ class TestRunJobSessionPersistence: run_job(job) kwargs = mock_agent_cls.call_args.kwargs - assert kwargs["enabled_toolsets"] is None + # Resolution happened — not None, is a list. + assert isinstance(kwargs["enabled_toolsets"], list) + # The cron default is _HERMES_CORE_TOOLS with _DEFAULT_OFF_TOOLSETS + # (``moa``, ``homeassistant``, ``rl``) removed. The most important + # invariant: ``moa`` is NOT in the default cron toolset, so a cron + # run cannot accidentally spin up frontier models. + assert "moa" not in kwargs["enabled_toolsets"] + + def test_run_job_per_job_toolsets_win_over_platform_config(self, tmp_path): + """Per-job enabled_toolsets (via cronjob tool) always take precedence + over the platform-level ``hermes tools`` config.""" + job = { + "id": "override-job", + "name": "test", + "prompt": "hello", + "enabled_toolsets": ["terminal"], + } + fake_db, patches = self._make_run_job_patches(tmp_path) + # Even if the user has ``hermes tools`` configured to enable web+file + # for cron, the per-job override wins. + with patches[0], patches[1], patches[2], patches[3], patches[4], \ + patch("run_agent.AIAgent") as mock_agent_cls, \ + patch( + "hermes_cli.tools_config._get_platform_tools", + return_value={"web", "file"}, + ): + 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 + assert kwargs["enabled_toolsets"] == ["terminal"] def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path): """Empty final_response should stay empty for delivery logic (issue #2234). diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 9fb2745acd..b134fc98b3 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -463,7 +463,7 @@ class TestPlatformToolsetConsistency: gateway_includes = set(TOOLSETS["hermes-gateway"]["includes"]) # Exclude non-messaging platforms from the check - non_messaging = {"cli", "api_server"} + non_messaging = {"cli", "api_server", "cron"} for platform, meta in PLATFORMS.items(): if platform in non_messaging: continue diff --git a/toolsets.py b/toolsets.py index f1dc7fca1c..975d8883c2 100644 --- a/toolsets.py +++ b/toolsets.py @@ -295,7 +295,18 @@ TOOLSETS = { "tools": _HERMES_CORE_TOOLS, "includes": [] }, - + + "hermes-cron": { + # Mirrors hermes-cli so cron's "default" toolset is the same set of + # core tools users see interactively — then `hermes tools` filters + # them down per the platform config. _DEFAULT_OFF_TOOLSETS (moa, + # homeassistant, rl) are excluded by _get_platform_tools() unless + # the user explicitly enables them. + "description": "Default cron toolset - same core tools as hermes-cli; gated by `hermes tools`", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-telegram": { "description": "Telegram bot toolset - full access for personal use (terminal has safety checks)", "tools": _HERMES_CORE_TOOLS,