diff --git a/cron/scheduler.py b/cron/scheduler.py index 979770374..d051a7ab3 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 1fc3a3a85..05507eace 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 421d6859d..4cd4b7cd7 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 9fb2745ac..b134fc98b 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 f1dc7fca1..975d8883c 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,