feat(cron): per-job reasoning_effort override in job definitions

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.
This commit is contained in:
Victor Kyriazakos
2026-08-20 18:27:14 +00:00
committed by Teknium
parent 952d1cef18
commit 4e1dd1a74b
8 changed files with 392 additions and 5 deletions
+60
View File
@@ -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})
+46 -3
View File
@@ -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=<level>` (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).
+2
View File
@@ -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))
+19
View File
@@ -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")
+242
View File
@@ -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
+17
View File
@@ -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"),
),
+2 -2
View File
@@ -602,8 +602,8 @@ hermes cron <list|create|edit|pause|resume|run|remove|status|tick>
| 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 <none\|minimal\|low\|medium\|high\|xhigh\|max\|ultra>`. |
| `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. |
+4
View File
@@ -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.
:::