From 4e1dd1a74b2d57ef2960fa219b8ae6599f41dd1c Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Thu, 20 Aug 2026 18:27:14 +0000 Subject: [PATCH] feat(cron): per-job reasoning_effort override in job definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cron job can now pin its own reasoning (thinking) effort, independent of the global agent.reasoning_effort and per-model reasoning_overrides. Heavy scheduled analyses can run at high while cheap recurring jobs run at minimal, without touching the fleet-wide default. - cron/jobs.py: new optional job field, validated at the storage choke point against the canonical grammar via the shared hermes_constants.parse_reasoning_effort (spelling-only; capability clamping stays owned by the provider transports at send time, same as config-set effort). Empty string clears on update; invalid values raise ValueError before anything persists. Not a drift-guard axis. - cron/scheduler.py: _resolve_job_reasoning_config resolves per-job pin > agent.reasoning_overrides > agent.reasoning_effort at fire time, after the auth-fallback model swap (the pin is model-independent by design). A stored value that no longer parses warns and falls back to config resolution instead of killing the tick. - tools/cronjob_tools.py: reasoning_effort on BOTH mutation verbs (create and update), conditional key in _format_job, schema documents grammar/precedence/transport clamping/clear semantics. Agent-settable, unlike model/provider pins: it cannot redirect spend to a different model. - hermes cron create/edit --reasoning-effort (empty string clears). - Docs: cron feature page tip + CLI reference rows. Tests: tests/cron/test_cron_reasoning_effort.py (32) — store contract, scheduler precedence incl. byte-identical absent-field behavior and garbage fallback, tool create/update/clear/error paths, schema surface. --- cron/jobs.py | 60 ++++++ cron/scheduler.py | 49 ++++- hermes_cli/cron.py | 2 + hermes_cli/subcommands/cron.py | 19 ++ tests/cron/test_cron_reasoning_effort.py | 242 +++++++++++++++++++++++ tools/cronjob_tools.py | 17 ++ website/docs/reference/cli-commands.md | 4 +- website/docs/user-guide/features/cron.md | 4 + 8 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 tests/cron/test_cron_reasoning_effort.py diff --git a/cron/jobs.py b/cron/jobs.py index 68cfacb922..b13393f108 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -1692,6 +1692,41 @@ def _normalize_job_optional_text(value: Any, *, strip_trailing_slash: bool = Fal return text or None +def _normalize_reasoning_effort(value: Any) -> Optional[str]: + """Validate a per-job reasoning effort against the canonical grammar. + + Spelling-only validation at the storage choke point: the SAME parser + every other effort surface uses (``hermes_constants.parse_reasoning_effort``) + decides validity, so the cron knob can never be stricter or looser than + its config.yaml sibling. Capability (whether the resolved model supports + the level) is intentionally NOT checked here — the model is not knowable + at create time (unpinned jobs, auth fallback), and the provider + transports already clamp/omit at send time. + + Returns None for unset (None/empty string), the normalized lowercase + level for valid input, and raises ValueError otherwise so nothing + invalid ever persists for a fire-and-forget job. + """ + if value is None: + return None + text = str(value).strip().lower() + if not text: + return None + from hermes_constants import parse_reasoning_effort + + if parse_reasoning_effort(text) is None: + raise ValueError( + f"Invalid reasoning_effort {value!r}. Valid levels: " + "none, minimal, low, medium, high, xhigh, max, ultra " + "(empty string clears the override)." + ) + # parse_reasoning_effort accepts disable aliases ("false", "disabled"); + # store the canonical spelling so the record reads unambiguously. + if text in {"false", "disabled"}: + return "none" + return text + + def _compute_provider_model_snapshots( *, provider: Any, @@ -1797,6 +1832,7 @@ def create_job( attach_to_session: Optional[bool] = None, monitor_script: Optional[str] = None, monitor_url: Optional[str] = None, + reasoning_effort: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -1854,6 +1890,16 @@ def create_job( monitor_url: Optional http(s) URL used as the monitor source instead of a script — fetched with a bounded GET each tick. Same hash-suppression semantics as ``monitor_script``. + reasoning_effort: Optional per-job reasoning effort pin. One of the + canonical Hermes levels (none|minimal|low|medium|high|xhigh| + max|ultra, case-insensitive). When set, it wins over BOTH the + global ``agent.reasoning_effort`` and per-model + ``agent.reasoning_overrides`` at fire time. Capability is NOT + validated here: levels above what the resolved model supports + are clamped or omitted by the provider transport at send time, + exactly like config-set effort. Inert with ``no_agent=True`` + (no LLM call to configure). None/empty = unset (job follows + config resolution, pre-existing behavior). Returns: The created job dict @@ -1886,6 +1932,7 @@ def create_job( normalized_workdir = _normalize_workdir(workdir) normalized_no_agent = bool(no_agent) normalized_attach = attach_to_session if isinstance(attach_to_session, bool) else None + normalized_reasoning_effort = _normalize_reasoning_effort(reasoning_effort) normalized_monitor_script = str(monitor_script).strip() if isinstance(monitor_script, str) else None normalized_monitor_script = normalized_monitor_script or None normalized_monitor_url = str(monitor_url).strip() if isinstance(monitor_url, str) else None @@ -1995,6 +2042,10 @@ def create_job( # global cron.mirror_delivery config, default off). if normalized_attach is not None: job["attach_to_session"] = normalized_attach + # Same conditional-persist rule for the per-job reasoning effort pin: + # absent key = job follows config resolution (pre-feature behavior). + if normalized_reasoning_effort is not None: + job["reasoning_effort"] = normalized_reasoning_effort with _jobs_lock(): jobs = load_jobs() @@ -2101,6 +2152,15 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] _mv = str(_mv).strip() if isinstance(_mv, str) else None updates[_mon_field] = _mv or None + # Validate/normalize the per-job reasoning effort pin the same + # way create_job does: canonical grammar only, empty string (or + # None) clears. Invalid values raise BEFORE the merge so the + # stored value stays untouched. + if "reasoning_effort" in updates: + updates["reasoning_effort"] = _normalize_reasoning_effort( + updates["reasoning_effort"] + ) + previous_inference_axes = _normalized_inference_axes(job) updated = _apply_skill_fields({**job, **updates}) diff --git a/cron/scheduler.py b/cron/scheduler.py index c0cf76aa73..53704dbcf4 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -454,6 +454,48 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: ) return None + +def _resolve_job_reasoning_config(job: dict, cfg: dict, model: str) -> dict | None: + """Resolve the effective reasoning config for a cron run. + + Precedence: per-job ``reasoning_effort`` pin (validated at the store + choke point, ``cron/jobs.py::_normalize_reasoning_effort``) wins outright + over config resolution — both the global ``agent.reasoning_effort`` and + per-model ``agent.reasoning_overrides``. The pin is model-independent by + design: it also governs an auth-fallback model swap, and capability + clamping for the model that actually runs stays owned by the provider + transports at send time (exactly like config-set effort). + + A value that no longer parses (hand-edited jobs.json) logs a warning and + falls back to config resolution — a bad pin must degrade the run's + thinking level, never kill the tick. + + Absent/None pin returns ``resolve_reasoning_config(cfg, model)`` + byte-identical, preserving pre-feature behavior. + """ + from hermes_constants import parse_reasoning_effort, resolve_reasoning_config + + pinned = job.get("reasoning_effort") + if pinned is not None: + parsed = parse_reasoning_effort(pinned) + if parsed is not None: + logger.info( + "Job '%s': using per-job reasoning_effort '%s'", + job.get("id", "?"), + pinned, + ) + return parsed + logger.warning( + "Job '%s': invalid stored reasoning_effort %r — ignoring the pin " + "and falling back to config resolution. Fix with `cronjob " + "action=update job_id=%s reasoning_effort=` (valid: none, " + "minimal, low, medium, high, xhigh, max, ultra).", + job.get("id", "?"), + pinned, + job.get("id", "?"), + ) + return resolve_reasoning_config(cfg if isinstance(cfg, dict) else {}, str(model)) + # 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({ @@ -5385,7 +5427,8 @@ def run_job( # Reasoning config is resolved after provider authentication so an auth # fallback can first replace the primary model with its configured model. - from hermes_constants import resolve_reasoning_config + # Resolution itself happens via _resolve_job_reasoning_config below + # (per-job pin > agent.reasoning_overrides > agent.reasoning_effort). # Prefill messages from env or config.yaml. The top-level # prefill_messages_file key is canonical; agent.prefill_messages_file is @@ -5603,8 +5646,8 @@ def run_job( if runtime is None: raise RuntimeError(format_runtime_provider_error(resolve_exc)) from resolve_exc - reasoning_config = resolve_reasoning_config( - _cfg if isinstance(_cfg, dict) else {}, str(model) + reasoning_config = _resolve_job_reasoning_config( + job, _cfg if isinstance(_cfg, dict) else {}, str(model) ) # Provider/model-drift fail-closed guard (#44585). diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 7d08ebfd1e..16b73330bf 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -396,6 +396,7 @@ def cron_create(args): monitor_script=getattr(args, "monitor_script", None), monitor_url=getattr(args, "monitor_url", None), continuity=getattr(args, "continuity", None), + reasoning_effort=getattr(args, "reasoning_effort", None), ) if not result.get("success"): print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -470,6 +471,7 @@ def cron_edit(args): monitor_script=getattr(args, "monitor_script", None), monitor_url=getattr(args, "monitor_url", None), continuity=getattr(args, "continuity", None), + reasoning_effort=getattr(args, "reasoning_effort", None), ) if not result.get("success"): print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py index 07c24006e2..73acc073c6 100644 --- a/hermes_cli/subcommands/cron.py +++ b/hermes_cli/subcommands/cron.py @@ -105,6 +105,16 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: dest="model_provider", help="Inference provider paired with --model (e.g. 'openrouter', 'nous').", ) + cron_create.add_argument( + "--reasoning-effort", + dest="reasoning_effort", + help=( + "Pin this job's reasoning (thinking) effort: none, minimal, low, " + "medium, high, xhigh, max, or ultra. Overrides agent.reasoning_effort " + "and agent.reasoning_overrides for this job; unsupported levels are " + "clamped by the provider at request time. Omit to follow config." + ), + ) cron_create.add_argument( "--continuity", dest="continuity", @@ -231,6 +241,15 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: dest="model_provider", help="Inference provider paired with --model. Pass empty string to clear.", ) + cron_edit.add_argument( + "--reasoning-effort", + dest="reasoning_effort", + help=( + "Pin this job's reasoning (thinking) effort: none, minimal, low, " + "medium, high, xhigh, max, or ultra. Pass empty string to clear " + "the pin and follow config resolution." + ), + ) # lifecycle actions cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") diff --git a/tests/cron/test_cron_reasoning_effort.py b/tests/cron/test_cron_reasoning_effort.py new file mode 100644 index 0000000000..4ca0a37d63 --- /dev/null +++ b/tests/cron/test_cron_reasoning_effort.py @@ -0,0 +1,242 @@ +"""Per-job reasoning_effort override: store contract + scheduler precedence. + +A cron job may pin its own reasoning effort (Coatue request, NS-696). +Contract under test: + +- Job store (cron/jobs.py): the field is validated at the storage choke + point against the canonical Hermes effort grammar (parse_reasoning_effort + in hermes_constants — the SAME parser every other effort surface uses). + Garbage never persists; absent field keeps the job record byte-identical + to pre-feature behavior. Capability clamping (xhigh on a model that caps + at high, etc.) is intentionally NOT validated here — that is owned by the + provider transports at send time, same as config-set effort. +- Scheduler resolution (cron/scheduler.py::_resolve_job_reasoning_config): + a job-pinned effort wins outright over BOTH the global + agent.reasoning_effort and per-model agent.reasoning_overrides; an absent + field yields a result byte-identical to resolve_reasoning_config(cfg, + model); a garbage value in a hand-edited store warns and falls back to + config resolution instead of killing the tick. +""" + +import pytest + +from cron.jobs import create_job, load_jobs, update_job + + +@pytest.fixture() +def tmp_cron_dir(tmp_path, monkeypatch): + """Isolate the cron store (same pattern as tests/cron/test_jobs.py).""" + monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron") + monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json") + monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output") + return tmp_path / "cron" + + +def _create(**kw): + kw.setdefault("prompt", "say hi") + kw.setdefault("schedule", "every 1h") + return create_job(**kw) + + +class TestJobStoreReasoningEffort: + def test_absent_field_stores_none_and_shape_unchanged(self, tmp_cron_dir): + """No reasoning_effort arg => None in the record; the rest of the job + dict keeps exactly the keys pre-feature jobs had (plus the new field), + so existing consumers see no shape drift.""" + job = _create() + assert job.get("reasoning_effort") is None + # The new field must not perturb sibling inference axes. + assert job["model"] is None + assert job["provider"] is None + + @pytest.mark.parametrize( + "level", + ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"], + ) + def test_valid_levels_stored_normalized(self, tmp_cron_dir, level): + job = _create(reasoning_effort=level) + assert job["reasoning_effort"] == level + # And it round-trips through the store. + assert load_jobs()[0]["reasoning_effort"] == level + + @pytest.mark.parametrize("raw,expected", [("HIGH", "high"), (" high ", "high"), ("None", "none")]) + def test_spelling_normalized_lowercase_stripped(self, tmp_cron_dir, raw, expected): + job = _create(reasoning_effort=raw) + assert job["reasoning_effort"] == expected + + @pytest.mark.parametrize("garbage", ["turbo", "11", "hgih", "medium-plus"]) + def test_garbage_rejected_nothing_persisted(self, tmp_cron_dir, garbage): + with pytest.raises(ValueError) as exc: + _create(reasoning_effort=garbage) + # Actionable message: names the bad value and the valid grammar. + msg = str(exc.value) + assert garbage in msg + assert "minimal" in msg and "ultra" in msg + assert load_jobs() == [] + + @pytest.mark.parametrize("empty", [None, ""]) + def test_empty_means_unset(self, tmp_cron_dir, empty): + job = _create(reasoning_effort=empty) + assert job.get("reasoning_effort") is None + + def test_update_sets_field(self, tmp_cron_dir): + job = _create() + updated = update_job(job["id"], {"reasoning_effort": "xhigh"}) + assert updated["reasoning_effort"] == "xhigh" + assert load_jobs()[0]["reasoning_effort"] == "xhigh" + + def test_update_empty_string_clears(self, tmp_cron_dir): + job = _create(reasoning_effort="high") + updated = update_job(job["id"], {"reasoning_effort": ""}) + assert updated["reasoning_effort"] is None + + def test_update_garbage_rejected_stored_value_untouched(self, tmp_cron_dir): + job = _create(reasoning_effort="high") + with pytest.raises(ValueError): + update_job(job["id"], {"reasoning_effort": "warp9"}) + assert load_jobs()[0]["reasoning_effort"] == "high" + + def test_effort_change_does_not_trigger_snapshot_recompute(self, tmp_cron_dir): + """Effort is NOT a drift-guard axis (#44585): updating it alone must + not touch provider_snapshot/model_snapshot.""" + job = _create() + before = (job.get("provider_snapshot"), job.get("model_snapshot")) + updated = update_job(job["id"], {"reasoning_effort": "low"}) + assert (updated.get("provider_snapshot"), updated.get("model_snapshot")) == before + + +class TestSchedulerJobReasoningPrecedence: + """Contract for cron/scheduler.py::_resolve_job_reasoning_config.""" + + CFG = { + "model": {"default": "anthropic/claude-opus-4.5"}, + "agent": { + "reasoning_effort": "low", + "reasoning_overrides": {"anthropic/claude-opus-4.5": "xhigh"}, + }, + } + + def test_job_effort_beats_global_and_per_model_override(self): + from cron.scheduler import _resolve_job_reasoning_config + + job = {"reasoning_effort": "high"} + result = _resolve_job_reasoning_config(job, self.CFG, "anthropic/claude-opus-4.5") + assert result == {"enabled": True, "effort": "high"} + + def test_job_none_disables_thinking_never_reenabled_by_config(self): + from cron.scheduler import _resolve_job_reasoning_config + + job = {"reasoning_effort": "none"} + result = _resolve_job_reasoning_config(job, self.CFG, "anthropic/claude-opus-4.5") + assert result == {"enabled": False} + + def test_absent_field_byte_identical_to_config_resolution(self): + from hermes_constants import resolve_reasoning_config + from cron.scheduler import _resolve_job_reasoning_config + + for model in ("anthropic/claude-opus-4.5", "gpt-5", ""): + expected = resolve_reasoning_config(self.CFG, model) + assert _resolve_job_reasoning_config({}, self.CFG, model) == expected + assert _resolve_job_reasoning_config({"reasoning_effort": None}, self.CFG, model) == expected + + def test_garbage_in_store_warns_and_falls_back(self, caplog): + """A hand-edited jobs.json with an invalid level must not kill the + tick: warn, then resolve from config exactly as if unset.""" + import logging + + from hermes_constants import resolve_reasoning_config + from cron.scheduler import _resolve_job_reasoning_config + + job = {"id": "abc123", "reasoning_effort": "turbo"} + with caplog.at_level(logging.WARNING, logger="cron.scheduler"): + result = _resolve_job_reasoning_config(job, self.CFG, "gpt-5") + assert result == resolve_reasoning_config(self.CFG, "gpt-5") + assert any("turbo" in r.message for r in caplog.records) + + def test_job_effort_is_model_independent(self): + """Pinned effort governs whichever model actually runs (auth fallback + can swap the model after resolution) — the job pins intent, the + transport clamps capability.""" + from cron.scheduler import _resolve_job_reasoning_config + + job = {"reasoning_effort": "ultra"} + for model in ("gpt-5.6-sol", "x-ai/grok-4", "gemini-3-pro", ""): + assert _resolve_job_reasoning_config(job, self.CFG, model) == { + "enabled": True, + "effort": "ultra", + } + + +class TestCronjobToolReasoningEffort: + """The model tool covers BOTH mutation verbs (create AND update) and + surfaces the field in job listings — the mutation-verb symmetry rule.""" + + def test_create_via_tool_persists_pin(self, tmp_cron_dir, monkeypatch): + import json + + from tools.cronjob_tools import cronjob + + out = json.loads( + cronjob( + action="create", + prompt="daily digest", + schedule="every 1h", + reasoning_effort="high", + ) + ) + assert out["success"] is True + assert out["job"]["reasoning_effort"] == "high" + assert load_jobs()[0]["reasoning_effort"] == "high" + + def test_update_via_tool_sets_and_clears_pin(self, tmp_cron_dir): + import json + + from tools.cronjob_tools import cronjob + + job = _create() + set_out = json.loads( + cronjob(action="update", job_id=job["id"], reasoning_effort="XHIGH") + ) + assert set_out["success"] is True + assert load_jobs()[0]["reasoning_effort"] == "xhigh" + + clear_out = json.loads( + cronjob(action="update", job_id=job["id"], reasoning_effort="") + ) + assert clear_out["success"] is True + assert load_jobs()[0].get("reasoning_effort") is None + + def test_update_via_tool_garbage_is_clean_tool_error(self, tmp_cron_dir): + import json + + from tools.cronjob_tools import cronjob + + job = _create(reasoning_effort="low") + out = json.loads( + cronjob(action="update", job_id=job["id"], reasoning_effort="turbo") + ) + assert out["success"] is False + assert "turbo" in out["error"] + # Stored value untouched by the failed update. + assert load_jobs()[0]["reasoning_effort"] == "low" + + def test_format_job_omits_field_when_unset(self, tmp_cron_dir): + import json + + from tools.cronjob_tools import cronjob + + _create() + listed = json.loads(cronjob(action="list"))["jobs"][0] + assert "reasoning_effort" not in listed + + def test_schema_exposes_reasoning_effort(self): + from tools.cronjob_tools import CRONJOB_SCHEMA + + prop = CRONJOB_SCHEMA["parameters"]["properties"]["reasoning_effort"] + desc = prop["description"] + # Prompt-surface honesty: the description must state the full level + # grammar, precedence, transport clamping, and the clear semantics. + for level in ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"): + assert level in desc + assert "clamp" in desc + assert "empty string" in desc diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 1130174015..e01022b568 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -657,6 +657,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: } if job.get("script"): result["script"] = job["script"] + if job.get("reasoning_effort"): + result["reasoning_effort"] = job["reasoning_effort"] if job.get("monitor_script"): result["monitor_script"] = job["monitor_script"] if job.get("monitor_url"): @@ -1211,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: @@ -1311,6 +1314,7 @@ 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=reasoning_effort, ) except CronSchedulerRegistrationError as exc: _partial = exc.to_dict() @@ -1493,6 +1497,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: + # update_job validates against the canonical grammar and + # normalizes; 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 @@ -1747,6 +1755,10 @@ Scheduling from cron-run sessions is disabled by default and enabled via cron.al "type": "boolean", "description": "When True, this job becomes CONTINUABLE: the user can reply to its delivery and the agent has the brief in context instead of asking 'what is that?'. On thread-capable platforms (Telegram topics, Discord/Slack threads) a dedicated thread is opened for the job and its replies; on DM-only platforms (WhatsApp/Signal) the brief is mirrored into the origin DM session. Use this for conversational recurring jobs the user will reply to — daily briefings, reminders that kick off follow-up work. Leave unset for fire-and-forget alerts/watchdogs. Overrides the global cron.mirror_delivery config for this one job. Only the origin chat is touched (never fan-out targets); no effect when deliver='local'." }, + "reasoning_effort": { + "type": "string", + "description": "Optional per-job reasoning (thinking) effort pin. One of: none, minimal, low, medium, high, xhigh, max, ultra. When set, it overrides BOTH the global agent.reasoning_effort and per-model agent.reasoning_overrides for this job's runs; 'none' disables thinking. Levels above what the job's resolved model supports are clamped or omitted by the provider at request time, so pinning e.g. 'xhigh' on a model that caps at 'high' runs at 'high'. Inert with no_agent=True (no LLM call). Omit to follow config resolution. On update, pass empty string to clear the pin." + }, }, "required": ["action"] } @@ -1807,6 +1819,11 @@ registry.register( no_agent=args.get("no_agent"), monitor_script=args.get("monitor_script"), monitor_url=args.get("monitor_url"), + # reasoning_effort IS agent-settable (unlike model/provider above): + # it cannot redirect spend to a different model — it only tunes the + # thinking level within whatever model the job already resolves to, + # and the transports clamp unsupported levels at request time. + reasoning_effort=args.get("reasoning_effort"), task_id=kw.get("task_id"), session_id=kw.get("session_id"), ), diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 7d434df6db..d824e2811f 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -602,8 +602,8 @@ hermes cron | Subcommand | Description | |------------|-------------| | `list` | Show scheduled jobs. | -| `create` / `add` | Create a scheduled job from a prompt, optionally attaching one or more skills via repeated `--skill`. | -| `edit` | Update a job's schedule, prompt, name, delivery, repeat count, or attached skills. Supports `--clear-skills`, `--add-skill`, and `--remove-skill`. | +| `create` / `add` | Create a scheduled job from a prompt, optionally attaching one or more skills via repeated `--skill`. Supports a per-job reasoning pin via `--reasoning-effort `. | +| `edit` | Update a job's schedule, prompt, name, delivery, repeat count, or attached skills. Supports `--clear-skills`, `--add-skill`, and `--remove-skill`, plus `--reasoning-effort` (empty string clears the pin). | | `pause` | Pause a job without deleting it. | | `resume` | Resume a paused job and compute its next future run. | | `run` | Trigger a job on the next scheduler tick. | diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 56e31341cf..1ba6d0216e 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -31,6 +31,10 @@ All of this is available to Hermes itself through the `cronjob` tool, so you can `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal). ::: +:::tip +**Per-job reasoning effort.** A job can pin its own thinking level, independent of the model pin: one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. When set, it overrides both the global `agent.reasoning_effort` and per-model `agent.reasoning_overrides` for that job's runs (`none` disables thinking). Set it via the `cronjob` tool (`reasoning_effort: "high"`), or `hermes cron create/edit --reasoning-effort high`; pass an empty string on edit to clear the pin and follow config again. Levels a model doesn't support are clamped or omitted by the provider at request time — pinning `xhigh` on a model that caps at `high` runs at `high`. The pin has no effect on `no_agent` jobs (there is no LLM call to tune). Use it to run heavy scheduled analyses at `high` while cheap recurring jobs run at `minimal`, without touching your global default. +::: + :::warning Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops. :::