diff --git a/tests/cron/test_cron_reasoning_effort.py b/tests/cron/test_cron_reasoning_effort.py index d9f257439c..cea6d11230 100644 --- a/tests/cron/test_cron_reasoning_effort.py +++ b/tests/cron/test_cron_reasoning_effort.py @@ -191,13 +191,42 @@ class TestCronjobToolReasoningEffort: listed = json.loads(cronjob(action="list"))["jobs"][0] assert "reasoning_effort" not in listed + def _tool_handler(self): + import tools.cronjob_tools as mod + + return mod.registry._tools["cronjob"].handler + def test_schema_does_not_expose_reasoning_effort(self): - """Policy pin: the model-facing tool schema must NOT offer the + """Policy pin: the model-facing surface must NOT offer the reasoning_effort knob. Models never choose model config; the CLI is - the only mutation surface for this field.""" + the only mutation surface for this field. The cronjob() function + keeps the parameter for the CLI lane (hermes_cli/cron.py), but the + tool schema and the registry dispatch drop it — same pattern as + model/provider/base_url.""" import inspect - from tools.cronjob_tools import CRONJOB_SCHEMA, cronjob + import tools.cronjob_tools as mod - assert "reasoning_effort" not in CRONJOB_SCHEMA["parameters"]["properties"] - assert "reasoning_effort" not in inspect.signature(cronjob).parameters + assert "reasoning_effort" not in mod.CRONJOB_SCHEMA["parameters"]["properties"] + # The registry handler lambda must not forward the agent's args to + # the parameter (mirrors the intentional model/provider omission). + source = inspect.getsource(self._tool_handler()) + assert 'args.get("reasoning_effort")' not in source + + def test_tool_dispatch_drops_reasoning_effort_arg(self, tmp_cron_dir): + """Even if a model hallucinates the argument, dispatch ignores it: + the created job must carry NO pin.""" + import json + + out = json.loads( + self._tool_handler()( + { + "action": "create", + "prompt": "daily digest", + "schedule": "every 1h", + "reasoning_effort": "max", + } + ) + ) + assert out["success"] is True + assert load_jobs()[0].get("reasoning_effort") is None diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index b224b87c4e..ae507b75c7 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -1213,6 +1213,7 @@ def cronjob( attach_to_session: Optional[bool] = None, monitor_script: Optional[str] = None, monitor_url: Optional[str] = None, + reasoning_effort: Optional[str] = None, task_id: str = None, session_id: Optional[str] = None, ) -> str: @@ -1313,6 +1314,12 @@ def cronjob( attach_to_session=attach_to_session, monitor_script=_normalize_optional_job_value(monitor_script), monitor_url=_normalize_optional_job_value(monitor_url), + # reasoning_effort reaches here from the CLI + # (hermes cron create --reasoning-effort) ONLY — it is + # deliberately absent from CRONJOB_SCHEMA and the model + # dispatch below: models do not make model-config + # decisions (standing policy). + reasoning_effort=reasoning_effort, ) except CronSchedulerRegistrationError as exc: _partial = exc.to_dict() @@ -1495,6 +1502,10 @@ def cronjob( updates["provider"] = _normalize_optional_job_value(provider) if base_url is not None: updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True) + if reasoning_effort is not None: + # CLI-only lane (see create above): update_job validates + # against the canonical grammar; empty string clears the pin. + updates["reasoning_effort"] = reasoning_effort # Re-validate the EFFECTIVE provider/base_url on EVERY update, not # only when this update supplies provider/base_url. A job persisted # before this guard (or written directly to the jobs store) may