mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(cron): honor hermes tools config for the cron platform (#14798)
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
This commit is contained in:
parent
bf196a3fc0
commit
ef5eaf8d87
5 changed files with 88 additions and 5 deletions
|
|
@ -40,6 +40,37 @@ from hermes_time import now as _hermes_now
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Valid delivery platforms — used to validate user-supplied platform names
|
||||||
# in cron delivery targets, preventing env var enumeration via crafted names.
|
# in cron delivery targets, preventing env var enumeration via crafted names.
|
||||||
_KNOWN_DELIVERY_PLATFORMS = frozenset({
|
_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_ignored=pr.get("ignore"),
|
||||||
providers_order=pr.get("order"),
|
providers_order=pr.get("order"),
|
||||||
provider_sort=pr.get("sort"),
|
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"],
|
disabled_toolsets=["cronjob", "messaging", "clarify"],
|
||||||
quiet_mode=True,
|
quiet_mode=True,
|
||||||
skip_context_files=True, # Don't inject SOUL.md/AGENTS.md from scheduler cwd
|
skip_context_files=True, # Don't inject SOUL.md/AGENTS.md from scheduler cwd
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||||
("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")),
|
("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")),
|
||||||
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
|
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
|
||||||
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
|
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
|
||||||
|
("cron", PlatformInfo(label="⏰ Cron", default_toolset="hermes-cron")),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -710,7 +710,15 @@ class TestRunJobSessionPersistence:
|
||||||
kwargs = mock_agent_cls.call_args.kwargs
|
kwargs = mock_agent_cls.call_args.kwargs
|
||||||
assert kwargs["enabled_toolsets"] == ["web", "terminal", "file"]
|
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 = {
|
job = {
|
||||||
"id": "no-toolset-job",
|
"id": "no-toolset-job",
|
||||||
"name": "test",
|
"name": "test",
|
||||||
|
|
@ -725,7 +733,39 @@ class TestRunJobSessionPersistence:
|
||||||
run_job(job)
|
run_job(job)
|
||||||
|
|
||||||
kwargs = mock_agent_cls.call_args.kwargs
|
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):
|
def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
|
||||||
"""Empty final_response should stay empty for delivery logic (issue #2234).
|
"""Empty final_response should stay empty for delivery logic (issue #2234).
|
||||||
|
|
|
||||||
|
|
@ -463,7 +463,7 @@ class TestPlatformToolsetConsistency:
|
||||||
|
|
||||||
gateway_includes = set(TOOLSETS["hermes-gateway"]["includes"])
|
gateway_includes = set(TOOLSETS["hermes-gateway"]["includes"])
|
||||||
# Exclude non-messaging platforms from the check
|
# Exclude non-messaging platforms from the check
|
||||||
non_messaging = {"cli", "api_server"}
|
non_messaging = {"cli", "api_server", "cron"}
|
||||||
for platform, meta in PLATFORMS.items():
|
for platform, meta in PLATFORMS.items():
|
||||||
if platform in non_messaging:
|
if platform in non_messaging:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
11
toolsets.py
11
toolsets.py
|
|
@ -296,6 +296,17 @@ TOOLSETS = {
|
||||||
"includes": []
|
"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": {
|
"hermes-telegram": {
|
||||||
"description": "Telegram bot toolset - full access for personal use (terminal has safety checks)",
|
"description": "Telegram bot toolset - full access for personal use (terminal has safety checks)",
|
||||||
"tools": _HERMES_CORE_TOOLS,
|
"tools": _HERMES_CORE_TOOLS,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue