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:
committed by
Teknium
parent
952d1cef18
commit
4e1dd1a74b
@@ -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
@@ -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).
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
),
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
:::
|
||||
|
||||
Reference in New Issue
Block a user