From 871104a197c6cf18490ee96d6c9cdb818fd98852 Mon Sep 17 00:00:00 2001 From: herbalizer404 <8180647+herbalizer404@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:25:14 +0000 Subject: [PATCH 1/3] feat(cron): support per-job reasoning effort --- cron/jobs.py | 32 +++ cron/scheduler.py | 24 +- hermes_cli/cron.py | 19 ++ hermes_cli/subcommands/cron.py | 15 ++ tests/cron/test_cron_reasoning_effort.py | 241 +++++++++++++++++++ tests/hermes_cli/test_cron.py | 63 ++++- tests/hermes_cli/test_cron_parser_builder.py | 18 ++ tests/tools/test_cronjob_tools.py | 56 +++++ tools/cronjob_tools.py | 14 ++ website/docs/user-guide/features/cron.md | 17 ++ 10 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 tests/cron/test_cron_reasoning_effort.py diff --git a/cron/jobs.py b/cron/jobs.py index 90c318742e6f..36a125f02df8 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -419,6 +419,16 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]: state = "scheduled" if normalized.get("enabled", True) else "paused" normalized["state"] = state + try: + normalized["reasoning_effort"] = _normalize_reasoning_effort( + normalized.get("reasoning_effort") + ) + except ValueError as exc: + logger.warning( + "Job '%s': invalid reasoning_effort in stored record: %s", job_id, exc + ) + normalized["reasoning_effort"] = None + return normalized @@ -975,6 +985,20 @@ def _normalize_job_optional_text(value: Any, *, strip_trailing_slash: bool = Fal return text or None +def _normalize_reasoning_effort(value: Any) -> Optional[str]: + """Normalize and validate an optional per-job reasoning override.""" + text = str(value or "").strip().lower() + if not text: + return None + from hermes_constants import parse_reasoning_effort + + if parse_reasoning_effort(text) is None: + raise ValueError( + "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh" + ) + return text + + def _compute_provider_model_snapshots( *, provider: Any, @@ -1048,6 +1072,7 @@ def create_job( workdir: Optional[str] = None, no_agent: bool = False, attach_to_session: Optional[bool] = None, + reasoning_effort: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -1124,6 +1149,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) # no_agent jobs are meaningless without a script — the script IS the job. # Surface this as a clear ValueError at create time so bad configs never @@ -1213,6 +1239,7 @@ def create_job( "origin": origin, # Tracks where job was created for "origin" delivery "enabled_toolsets": normalized_toolsets, "workdir": normalized_workdir, + "reasoning_effort": normalized_reasoning_effort, } # Only persist attach_to_session when explicitly set, so existing jobs and # the common case stay byte-identical (absent key => fall back to the @@ -1309,6 +1336,11 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] else: updates["workdir"] = _normalize_workdir(_wd) + if "reasoning_effort" in updates: + updates["reasoning_effort"] = _normalize_reasoning_effort( + updates.get("reasoning_effort") + ) + previous_inference_axes = _normalized_inference_axes(job) updated = _apply_skill_fields({**job, **updates}) schedule_changed = "schedule" in updates diff --git a/cron/scheduler.py b/cron/scheduler.py index 7cf688f3734a..5c48cf07f227 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2864,12 +2864,28 @@ def run_job( except Exception: pass - # Reasoning config from config.yaml (raw value — a YAML boolean False - # means thinking disabled, see parse_reasoning_effort) + # A valid per-job override wins; absent or invalid stored values safely + # fall back to config.yaml. Literal "none" explicitly disables reasoning. from hermes_constants import parse_reasoning_effort - reasoning_config = parse_reasoning_effort( - _cfg.get("agent", {}).get("reasoning_effort", "") + reasoning_agent_cfg = _cfg.get("agent", {}) + global_effort = ( + reasoning_agent_cfg.get("reasoning_effort", "") + if isinstance(reasoning_agent_cfg, dict) + else "" ) + job_effort = job.get("reasoning_effort") + reasoning_config = ( + parse_reasoning_effort(job_effort) + if job_effort not in {None, ""} + else parse_reasoning_effort(global_effort) + ) + if job_effort not in {None, ""} and reasoning_config is None: + logger.warning( + "Job '%s': invalid reasoning_effort %r; falling back to global agent.reasoning_effort", + job_id, + job_effort, + ) + reasoning_config = parse_reasoning_effort(global_effort) # Prefill messages from env or config.yaml. The top-level # prefill_messages_file key is canonical; agent.prefill_messages_file is diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index b0c907326e88..439e31afe4f3 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -163,6 +163,8 @@ def cron_list(show_all: bool = False): workdir = job.get("workdir") if workdir: print(f" Workdir: {workdir}") + if job.get("reasoning_effort") is not None: + print(f" Reasoning: {job['reasoning_effort']}") # Execution history last_status = job.get("last_status") @@ -310,6 +312,7 @@ def cron_create(args): script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), no_agent=getattr(args, "no_agent", False) or 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)) @@ -326,6 +329,8 @@ def cron_create(args): print(" Mode: no-agent (script stdout delivered directly)") if job_data.get("workdir"): print(f" Workdir: {job_data['workdir']}") + if job_data.get("reasoning_effort") is not None: + print(f" Reasoning: {job_data['reasoning_effort']}") print(f" Next run: {result['next_run_at']}") _warn_if_gateway_not_running() return 0 @@ -361,6 +366,17 @@ def cron_edit(args): if skill not in final_skills: final_skills.append(skill) + if getattr(args, "clear_reasoning_effort", False) and getattr( + args, "reasoning_effort", None + ): + print(color("Cannot combine --reasoning-effort with --clear-reasoning-effort", Colors.RED)) + return 1 + reasoning_effort = ( + "" + if getattr(args, "clear_reasoning_effort", False) + else getattr(args, "reasoning_effort", None) + ) + result = _cron_api( action="update", job_id=args.job_id, @@ -373,6 +389,7 @@ def cron_edit(args): script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), no_agent=getattr(args, "no_agent", None), + reasoning_effort=reasoning_effort, ) if not result.get("success"): print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -392,6 +409,8 @@ def cron_edit(args): print(" Mode: no-agent (script stdout delivered directly)") if updated.get("workdir"): print(f" Workdir: {updated['workdir']}") + if updated.get("reasoning_effort") is not None: + print(f" Reasoning: {updated['reasoning_effort']}") return 0 diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py index c50b3401462b..f0610ad673cf 100644 --- a/hermes_cli/subcommands/cron.py +++ b/hermes_cli/subcommands/cron.py @@ -70,6 +70,11 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: "--workdir", help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).", ) + cron_create.add_argument( + "--reasoning-effort", + choices=["none", "minimal", "low", "medium", "high", "xhigh"], + help="Per-job reasoning override; omit to inherit agent.reasoning_effort.", + ) # cron edit cron_edit = cron_subparsers.add_parser( @@ -134,6 +139,16 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: "--workdir", help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.", ) + cron_edit.add_argument( + "--reasoning-effort", + choices=["none", "minimal", "low", "medium", "high", "xhigh"], + help="Per-job reasoning override; 'none' explicitly disables reasoning.", + ) + cron_edit.add_argument( + "--clear-reasoning-effort", + action="store_true", + help="Clear the override and inherit agent.reasoning_effort.", + ) # 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 000000000000..c503889ef8c6 --- /dev/null +++ b/tests/cron/test_cron_reasoning_effort.py @@ -0,0 +1,241 @@ +import sys +import types + +import pytest + +from cron.jobs import create_job, get_job, list_jobs, save_jobs, update_job + + +@pytest.fixture() +def tmp_cron_dir(tmp_path, monkeypatch): + 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 + + +def test_create_stores_normalized_reasoning_effort(tmp_cron_dir): + job = create_job(prompt="Think lightly", schedule="30m", reasoning_effort=" low ") + assert job["reasoning_effort"] == "low" + assert get_job(job["id"])["reasoning_effort"] == "low" + + +def test_create_stores_none_as_explicit_override(tmp_cron_dir): + job = create_job(prompt="No reasoning", schedule="30m", reasoning_effort="NONE") + assert job["reasoning_effort"] == "none" + + +def test_create_invalid_reasoning_effort_raises(tmp_cron_dir): + with pytest.raises(ValueError, match="reasoning_effort must be one of"): + create_job(prompt="Bad", schedule="30m", reasoning_effort="turbo") + + +def test_update_changes_preserves_and_clears_reasoning_effort(tmp_cron_dir): + job = create_job(prompt="Update me", schedule="30m", reasoning_effort="low") + + updated = update_job(job["id"], {"reasoning_effort": "HIGH"}) + assert updated["reasoning_effort"] == "high" + + preserved = update_job(job["id"], {"name": "renamed"}) + assert preserved["reasoning_effort"] == "high" + + cleared = update_job(job["id"], {"reasoning_effort": ""}) + assert cleared is not None + assert cleared["reasoning_effort"] is None + + updated = update_job(job["id"], {"reasoning_effort": "none"}) + assert updated["reasoning_effort"] == "none" + + cleared = update_job(job["id"], {"reasoning_effort": None}) + assert cleared["reasoning_effort"] is None + + +def test_legacy_invalid_reasoning_effort_is_read_safe(tmp_cron_dir): + save_jobs([ + { + "id": "abc123deadbe", + "name": "legacy", + "prompt": "legacy", + "schedule_display": "every 60m", + "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, + "enabled": True, + "reasoning_effort": "turbo", + } + ]) + + jobs = list_jobs() + assert jobs[0]["reasoning_effort"] is None + assert get_job("abc123deadbe")["reasoning_effort"] is None + + +@pytest.fixture() +def scheduler_harness(tmp_path, monkeypatch): + """Patch heavy scheduler dependencies and capture AIAgent kwargs.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / ".env").write_text("", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + + captured = {} + + class FakeAIAgent: + def __init__(self, **kwargs): + captured.update(kwargs) + + def run_conversation(self, prompt): + captured["prompt"] = prompt + return {"completed": True, "final_response": "ok"} + + def get_activity_summary(self): + return {"seconds_since_activity": 0.0} + + fake_run_agent = types.ModuleType("run_agent") + setattr(fake_run_agent, "AIAgent", FakeAIAgent) + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + fake_state = types.ModuleType("hermes_state") + + class FakeSessionDB: + pass + + setattr(fake_state, "SessionDB", FakeSessionDB) + monkeypatch.setitem(sys.modules, "hermes_state", fake_state) + + fake_runtime_provider = types.ModuleType("hermes_cli.runtime_provider") + setattr( + fake_runtime_provider, + "resolve_runtime_provider", + lambda **kwargs: { + "provider": kwargs.get("requested") or "openai-codex", + "api_key": "[REDACTED]", + "base_url": None, + "api_mode": None, + }, + ) + setattr(fake_runtime_provider, "format_runtime_provider_error", lambda exc: str(exc)) + monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_provider) + + fake_auth = types.ModuleType("hermes_cli.auth") + + class FakeAuthError(Exception): + pass + + setattr(fake_auth, "AuthError", FakeAuthError) + monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth) + + fake_dotenv = types.ModuleType("dotenv") + setattr(fake_dotenv, "load_dotenv", lambda *args, **kwargs: True) + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_mcp = types.ModuleType("tools.mcp_tool") + setattr(fake_mcp, "discover_mcp_tools", lambda: []) + monkeypatch.setitem(sys.modules, "tools.mcp_tool", fake_mcp) + + return home, captured + + +def _minimal_job(reasoning_effort=None): + job = { + "id": "abc123deadbe", + "name": "reasoning test", + "prompt": "Say ok", + "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, + "schedule_display": "every 60m", + "enabled": True, + "deliver": "local", + } + if reasoning_effort is not None: + job["reasoning_effort"] = reasoning_effort + return job + + +def test_scheduler_uses_job_reasoning_effort_over_global(scheduler_harness): + home, captured = scheduler_harness + (home / "config.yaml").write_text( + "model:\n default: gpt-5.5\n provider: openai-codex\nagent:\n reasoning_effort: high\n", + encoding="utf-8", + ) + + from cron.scheduler import run_job + + success, _doc, final_response, error = run_job(_minimal_job("low")) + + assert success is True + assert final_response == "ok" + assert error is None + assert captured["reasoning_config"] == {"enabled": True, "effort": "low"} + + +def test_scheduler_falls_back_to_global_reasoning_effort(scheduler_harness): + home, captured = scheduler_harness + (home / "config.yaml").write_text( + "model:\n default: gpt-5.5\n provider: openai-codex\nagent:\n reasoning_effort: high\n", + encoding="utf-8", + ) + + from cron.scheduler import run_job + + success, _doc, final_response, error = run_job(_minimal_job()) + + assert success is True + assert final_response == "ok" + assert error is None + assert captured["reasoning_config"] == {"enabled": True, "effort": "high"} + + +def test_scheduler_none_disables_reasoning_instead_of_fallback(scheduler_harness): + home, captured = scheduler_harness + (home / "config.yaml").write_text( + "model:\n default: gpt-5.5\n provider: openai-codex\nagent:\n reasoning_effort: high\n", + encoding="utf-8", + ) + + from cron.scheduler import run_job + + success, _doc, final_response, error = run_job(_minimal_job("none")) + + assert success is True + assert final_response == "ok" + assert error is None + assert captured["reasoning_config"] == {"enabled": False} + + +def test_scheduler_invalid_hand_edited_value_falls_back_to_global(scheduler_harness): + home, captured = scheduler_harness + (home / "config.yaml").write_text( + "model:\n default: gpt-5.5\n provider: openai-codex\nagent:\n reasoning_effort: medium\n", + encoding="utf-8", + ) + + from cron.scheduler import run_job + + success, _doc, final_response, error = run_job(_minimal_job("turbo")) + + assert success is True + assert final_response == "ok" + assert error is None + assert captured["reasoning_config"] == {"enabled": True, "effort": "medium"} + + +def test_scheduler_no_agent_ignores_reasoning_and_never_constructs_agent( + scheduler_harness +): + _home, captured = scheduler_harness + scripts_dir = _home / "scripts" + scripts_dir.mkdir() + script = scripts_dir / "silent.sh" + script.write_text("#!/bin/sh\n", encoding="utf-8") + script.chmod(0o755) + + job = _minimal_job("high") + job["no_agent"] = True + job["script"] = "silent.sh" + + from cron.scheduler import run_job, SILENT_MARKER + + success, _doc, final_response, error = run_job(job) + + assert success is True + assert final_response == SILENT_MARKER + assert error is None + assert "reasoning_config" not in captured \ No newline at end of file diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 1ce36a1740d2..932da4407495 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -337,10 +337,11 @@ def test_cron_tick_invokes_scheduler_tick_with_verbose(monkeypatch): def test_cron_create_success_prints_job_details(monkeypatch, capsys): - monkeypatch.setattr( - cron_cli, - "_cron_api", - lambda **kwargs: { + captured = {} + + def fake_cron_api(**kwargs): + captured.update(kwargs) + return { "success": True, "job_id": "job-1", "name": "Nightly docs", @@ -351,8 +352,14 @@ def test_cron_create_success_prints_job_details(monkeypatch, capsys): "script": "scripts/build_docs.py", "no_agent": True, "workdir": "/tmp/repo", + "reasoning_effort": "low", }, - }, + } + + monkeypatch.setattr( + cron_cli, + "_cron_api", + fake_cron_api, ) monkeypatch.setattr(cron_cli, "_warn_if_gateway_not_running", lambda: None) @@ -367,6 +374,7 @@ def test_cron_create_success_prints_job_details(monkeypatch, capsys): script="scripts/build_docs.py", workdir="/tmp/repo", no_agent=True, + reasoning_effort="low", ) rc = cron_cli.cron_create(args) @@ -378,7 +386,52 @@ def test_cron_create_success_prints_job_details(monkeypatch, capsys): assert "Script: scripts/build_docs.py" in out assert "Mode: no-agent" in out assert "Workdir: /tmp/repo" in out + assert "Reasoning: low" in out assert "Next run: 2026-06-01T00:00:00Z" in out + assert captured["reasoning_effort"] == "low" + + +def test_cron_edit_clear_reasoning_effort(monkeypatch, capsys): + captured = {} + monkeypatch.setattr( + "cron.jobs.resolve_job_ref", lambda _job_id: {"id": "job-1", "skills": []} + ) + + def fake_cron_api(**kwargs): + captured.update(kwargs) + return { + "success": True, + "job": { + "job_id": "job-1", + "name": "Reasoning job", + "schedule": "every day", + "skills": [], + }, + } + + monkeypatch.setattr(cron_cli, "_cron_api", fake_cron_api) + args = SimpleNamespace( + job_id="job-1", + schedule=None, + prompt=None, + name=None, + deliver=None, + repeat=None, + skill=None, + skills=None, + add_skills=None, + remove_skills=None, + clear_skills=False, + script=None, + workdir=None, + no_agent=None, + reasoning_effort=None, + clear_reasoning_effort=True, + ) + + assert cron_cli.cron_edit(args) == 0 + assert captured["reasoning_effort"] == "" + assert "Reasoning:" not in capsys.readouterr().out def test_cron_create_failure_returns_nonzero(monkeypatch, capsys): diff --git a/tests/hermes_cli/test_cron_parser_builder.py b/tests/hermes_cli/test_cron_parser_builder.py index 16be898b1a99..a531ad29424f 100644 --- a/tests/hermes_cli/test_cron_parser_builder.py +++ b/tests/hermes_cli/test_cron_parser_builder.py @@ -70,6 +70,24 @@ def test_cron_edit_no_agent_tristate(): assert parser.parse_args(["cron", "edit", "j"]).no_agent is None +def test_cron_reasoning_effort_options(): + parser = _build() + created = parser.parse_args( + ["cron", "create", "30m", "prompt", "--reasoning-effort", "low"] + ) + assert created.reasoning_effort == "low" + + edited = parser.parse_args( + ["cron", "edit", "j", "--reasoning-effort", "none"] + ) + assert edited.reasoning_effort == "none" + assert edited.clear_reasoning_effort is False + + cleared = parser.parse_args(["cron", "edit", "j", "--clear-reasoning-effort"]) + assert cleared.reasoning_effort is None + assert cleared.clear_reasoning_effort is True + + def test_cron_dispatch_func_is_injected_handler(): parser = _build() ns = parser.parse_args(["cron", "list"]) diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 41aea33c7dc0..9809465a58a2 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -4,6 +4,7 @@ import pytest from tools.cronjob_tools import ( + CRONJOB_SCHEMA, _scan_cron_prompt, check_cronjob_requirements, cronjob, @@ -264,6 +265,61 @@ def test_create_and_list(self): assert listing["jobs"][0]["name"] == "Server Check" assert listing["jobs"][0]["state"] == "scheduled" + def test_reasoning_effort_create_update_clear_and_schema(self): + created = json.loads( + cronjob( + action="create", + prompt="Check", + schedule="every 1h", + reasoning_effort="low", + ) + ) + assert created["success"] is True + assert created["job"]["reasoning_effort"] == "low" + + updated = json.loads( + cronjob( + action="update", + job_id=created["job_id"], + reasoning_effort="none", + ) + ) + assert updated["job"]["reasoning_effort"] == "none" + + cleared = json.loads( + cronjob( + action="update", job_id=created["job_id"], reasoning_effort="" + ) + ) + assert "reasoning_effort" not in cleared["job"] + + values = CRONJOB_SCHEMA["parameters"]["properties"]["reasoning_effort"]["enum"] + assert {"", "none", "xhigh"}.issubset(values) + + def test_invalid_reasoning_effort_fails_without_writing(self): + created = json.loads( + cronjob( + action="create", + prompt="Check", + schedule="every 1h", + reasoning_effort="turbo", + ) + ) + assert created["success"] is False + assert json.loads(cronjob(action="list"))["count"] == 0 + + good = json.loads( + cronjob(action="create", prompt="Check", schedule="every 1h") + ) + updated = json.loads( + cronjob( + action="update", + job_id=good["job_id"], + reasoning_effort="turbo", + ) + ) + assert updated["success"] is False + def test_list_handles_partial_legacy_job_records(self): from cron.jobs import save_jobs diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 430cd1dda401..254eec463624 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -598,6 +598,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: result["enabled_toolsets"] = job["enabled_toolsets"] if job.get("workdir"): result["workdir"] = job["workdir"] + if job.get("reasoning_effort") is not None: + result["reasoning_effort"] = job["reasoning_effort"] return result @@ -678,6 +680,7 @@ def cronjob( no_agent: Optional[bool] = None, attach_to_session: Optional[bool] = None, task_id: str = None, + reasoning_effort: Optional[str] = None, ) -> str: """Unified cron job management tool.""" del task_id # unused but kept for handler signature compatibility @@ -750,6 +753,7 @@ def cronjob( workdir=_normalize_optional_job_value(workdir), no_agent=_no_agent, attach_to_session=attach_to_session, + reasoning_effort=_normalize_optional_job_value(reasoning_effort), ) _notify_provider_jobs_changed_safe() _create_message = f"Cron job '{job['name']}' created." @@ -927,6 +931,10 @@ def cronjob( # Empty string clears the field (restores old behaviour); # otherwise pass raw — update_job() validates / normalizes. updates["workdir"] = _normalize_optional_job_value(workdir) or None + if reasoning_effort is not None: + updates["reasoning_effort"] = ( + _normalize_optional_job_value(reasoning_effort) or None + ) if no_agent is not None: # Toggling no_agent on/off at update time. If flipping to True, # we need a script to already exist on the job (or be part of @@ -1081,6 +1089,11 @@ def cronjob( "type": "string", "description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated." }, + "reasoning_effort": { + "type": "string", + "enum": ["", "none", "minimal", "low", "medium", "high", "xhigh"], + "description": "Optional per-job reasoning effort. Omit or clear to inherit agent.reasoning_effort; pass 'none' to disable reasoning. Ignored for no_agent jobs. On update, pass empty string to clear." + }, "attach_to_session": { "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'." @@ -1141,6 +1154,7 @@ def check_cronjob_requirements() -> bool: workdir=args.get("workdir"), no_agent=args.get("no_agent"), task_id=kw.get("task_id"), + reasoning_effort=args.get("reasoning_effort"), ))(), check_fn=check_cronjob_requirements, emoji="⏰", diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index a65a4bca1e78..35bd83c9ebd6 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -125,6 +125,23 @@ When `workdir` is set: Jobs with a `workdir` run sequentially on the scheduler tick, not in the parallel pool. This is deliberate: the cron worker applies the job workdir through process-global terminal state, so two workdir jobs running at the same time would corrupt each other's cwd. Workdir-less jobs still run in parallel as before. ::: +## Tuning reasoning effort per job + +Cron jobs inherit the global `agent.reasoning_effort` by default. Override it for one job when scheduled work needs a different latency/cost trade-off: + +```bash +hermes cron create "0 3 * * *" \ + "Summarize yesterday's low-priority feeds" \ + --reasoning-effort low +``` + +The `cronjob` tool accepts the same `reasoning_effort` field. Valid values are `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. + +- Omit the field to inherit global `agent.reasoning_effort`. +- Use `none` to explicitly disable reasoning for that job. +- Run `hermes cron edit --clear-reasoning-effort` (or update with an empty string through the tool) to restore inheritance. +- Script-only `no_agent` jobs ignore reasoning effort because they do not create an agent. + ## Editing jobs You do not need to delete and recreate jobs just to change them. From b2a63357078cb04002822a9ee04f6becedf52fbf Mon Sep 17 00:00:00 2001 From: herbalizer404 <8180647+herbalizer404@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:54:40 +0000 Subject: [PATCH 2/3] fix(cron): align per-job reasoning effort values --- cron/jobs.py | 15 ++++---- hermes_cli/subcommands/cron.py | 8 +++- tests/cron/test_cron_reasoning_effort.py | 39 ++++++++++++++------ tests/hermes_cli/test_cron.py | 10 +++-- tests/hermes_cli/test_cron_parser_builder.py | 8 ++-- tests/tools/test_cronjob_tools.py | 20 ++++++++-- tools/cronjob_tools.py | 4 +- website/docs/user-guide/features/cron.md | 2 +- 8 files changed, 71 insertions(+), 35 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 36a125f02df8..b6b8c340c4fc 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -987,16 +987,15 @@ def _normalize_job_optional_text(value: Any, *, strip_trailing_slash: bool = Fal def _normalize_reasoning_effort(value: Any) -> Optional[str]: """Normalize and validate an optional per-job reasoning override.""" - text = str(value or "").strip().lower() - if not text: + if value is None or (isinstance(value, str) and not value.strip()): return None - from hermes_constants import parse_reasoning_effort + from hermes_constants import VALID_REASONING_EFFORTS, parse_reasoning_effort - if parse_reasoning_effort(text) is None: - raise ValueError( - "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh" - ) - return text + parsed = parse_reasoning_effort(value) + if parsed is None: + valid = ", ".join(("none", *VALID_REASONING_EFFORTS)) + raise ValueError(f"reasoning_effort must be one of: {valid}") + return "none" if not parsed["enabled"] else parsed["effort"] def _compute_provider_model_snapshots( diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py index f0610ad673cf..6c2b4c7f2e3e 100644 --- a/hermes_cli/subcommands/cron.py +++ b/hermes_cli/subcommands/cron.py @@ -9,9 +9,13 @@ from typing import Callable +from hermes_constants import VALID_REASONING_EFFORTS from hermes_cli.subcommands._shared import add_accept_hooks_flag +_REASONING_EFFORT_CHOICES = ["none", *VALID_REASONING_EFFORTS] + + def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: """Attach the ``cron`` subcommand (and its sub-actions) to ``subparsers``.""" cron_parser = subparsers.add_parser( @@ -72,7 +76,7 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: ) cron_create.add_argument( "--reasoning-effort", - choices=["none", "minimal", "low", "medium", "high", "xhigh"], + choices=_REASONING_EFFORT_CHOICES, help="Per-job reasoning override; omit to inherit agent.reasoning_effort.", ) @@ -141,7 +145,7 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: ) cron_edit.add_argument( "--reasoning-effort", - choices=["none", "minimal", "low", "medium", "high", "xhigh"], + choices=_REASONING_EFFORT_CHOICES, help="Per-job reasoning override; 'none' explicitly disables reasoning.", ) cron_edit.add_argument( diff --git a/tests/cron/test_cron_reasoning_effort.py b/tests/cron/test_cron_reasoning_effort.py index c503889ef8c6..0aacef996877 100644 --- a/tests/cron/test_cron_reasoning_effort.py +++ b/tests/cron/test_cron_reasoning_effort.py @@ -15,9 +15,9 @@ def tmp_cron_dir(tmp_path, monkeypatch): def test_create_stores_normalized_reasoning_effort(tmp_cron_dir): - job = create_job(prompt="Think lightly", schedule="30m", reasoning_effort=" low ") - assert job["reasoning_effort"] == "low" - assert get_job(job["id"])["reasoning_effort"] == "low" + job = create_job(prompt="Think deeply", schedule="30m", reasoning_effort=" MAX ") + assert job["reasoning_effort"] == "max" + assert get_job(job["id"])["reasoning_effort"] == "max" def test_create_stores_none_as_explicit_override(tmp_cron_dir): @@ -33,11 +33,11 @@ def test_create_invalid_reasoning_effort_raises(tmp_cron_dir): def test_update_changes_preserves_and_clears_reasoning_effort(tmp_cron_dir): job = create_job(prompt="Update me", schedule="30m", reasoning_effort="low") - updated = update_job(job["id"], {"reasoning_effort": "HIGH"}) - assert updated["reasoning_effort"] == "high" + updated = update_job(job["id"], {"reasoning_effort": "MAX"}) + assert updated["reasoning_effort"] == "max" preserved = update_job(job["id"], {"name": "renamed"}) - assert preserved["reasoning_effort"] == "high" + assert preserved["reasoning_effort"] == "max" cleared = update_job(job["id"], {"reasoning_effort": ""}) assert cleared is not None @@ -68,6 +68,22 @@ def test_legacy_invalid_reasoning_effort_is_read_safe(tmp_cron_dir): assert get_job("abc123deadbe")["reasoning_effort"] is None +def test_legacy_false_reasoning_effort_is_normalized_to_none(tmp_cron_dir): + save_jobs([ + { + "id": "abc123deadbe", + "name": "legacy", + "prompt": "legacy", + "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, + "enabled": True, + "reasoning_effort": False, + } + ]) + + assert list_jobs()[0]["reasoning_effort"] == "none" + assert get_job("abc123deadbe")["reasoning_effort"] == "none" + + @pytest.fixture() def scheduler_harness(tmp_path, monkeypatch): """Patch heavy scheduler dependencies and capture AIAgent kwargs.""" @@ -149,7 +165,7 @@ def _minimal_job(reasoning_effort=None): return job -def test_scheduler_uses_job_reasoning_effort_over_global(scheduler_harness): +def test_scheduler_uses_max_job_reasoning_effort_over_global(scheduler_harness): home, captured = scheduler_harness (home / "config.yaml").write_text( "model:\n default: gpt-5.5\n provider: openai-codex\nagent:\n reasoning_effort: high\n", @@ -158,12 +174,12 @@ def test_scheduler_uses_job_reasoning_effort_over_global(scheduler_harness): from cron.scheduler import run_job - success, _doc, final_response, error = run_job(_minimal_job("low")) + success, _doc, final_response, error = run_job(_minimal_job("max")) assert success is True assert final_response == "ok" assert error is None - assert captured["reasoning_config"] == {"enabled": True, "effort": "low"} + assert captured["reasoning_config"] == {"enabled": True, "effort": "max"} def test_scheduler_falls_back_to_global_reasoning_effort(scheduler_harness): @@ -183,7 +199,8 @@ def test_scheduler_falls_back_to_global_reasoning_effort(scheduler_harness): assert captured["reasoning_config"] == {"enabled": True, "effort": "high"} -def test_scheduler_none_disables_reasoning_instead_of_fallback(scheduler_harness): +@pytest.mark.parametrize("job_effort", ["none", False]) +def test_scheduler_disabled_override_does_not_fall_back(scheduler_harness, job_effort): home, captured = scheduler_harness (home / "config.yaml").write_text( "model:\n default: gpt-5.5\n provider: openai-codex\nagent:\n reasoning_effort: high\n", @@ -192,7 +209,7 @@ def test_scheduler_none_disables_reasoning_instead_of_fallback(scheduler_harness from cron.scheduler import run_job - success, _doc, final_response, error = run_job(_minimal_job("none")) + success, _doc, final_response, error = run_job(_minimal_job(job_effort)) assert success is True assert final_response == "ok" diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 932da4407495..f93e508e091e 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -294,6 +294,7 @@ def test_cron_list_warns_when_gateway_not_running(monkeypatch, capsys): "enabled": True, "next_run_at": "2026-06-01T00:00:00Z", "deliver": ["local"], + "reasoning_effort": "max", } ], ) @@ -305,6 +306,7 @@ def test_cron_list_warns_when_gateway_not_running(monkeypatch, capsys): out = capsys.readouterr().out assert "Gateway is not running" in out assert "Nightly docs" in out + assert "Reasoning: max" in out def test_cron_status_reports_running_gateway(monkeypatch, capsys): @@ -352,7 +354,7 @@ def fake_cron_api(**kwargs): "script": "scripts/build_docs.py", "no_agent": True, "workdir": "/tmp/repo", - "reasoning_effort": "low", + "reasoning_effort": "max", }, } @@ -374,7 +376,7 @@ def fake_cron_api(**kwargs): script="scripts/build_docs.py", workdir="/tmp/repo", no_agent=True, - reasoning_effort="low", + reasoning_effort="max", ) rc = cron_cli.cron_create(args) @@ -386,9 +388,9 @@ def fake_cron_api(**kwargs): assert "Script: scripts/build_docs.py" in out assert "Mode: no-agent" in out assert "Workdir: /tmp/repo" in out - assert "Reasoning: low" in out + assert "Reasoning: max" in out assert "Next run: 2026-06-01T00:00:00Z" in out - assert captured["reasoning_effort"] == "low" + assert captured["reasoning_effort"] == "max" def test_cron_edit_clear_reasoning_effort(monkeypatch, capsys): diff --git a/tests/hermes_cli/test_cron_parser_builder.py b/tests/hermes_cli/test_cron_parser_builder.py index a531ad29424f..9b8e33ce506c 100644 --- a/tests/hermes_cli/test_cron_parser_builder.py +++ b/tests/hermes_cli/test_cron_parser_builder.py @@ -73,14 +73,14 @@ def test_cron_edit_no_agent_tristate(): def test_cron_reasoning_effort_options(): parser = _build() created = parser.parse_args( - ["cron", "create", "30m", "prompt", "--reasoning-effort", "low"] + ["cron", "create", "30m", "prompt", "--reasoning-effort", "max"] ) - assert created.reasoning_effort == "low" + assert created.reasoning_effort == "max" edited = parser.parse_args( - ["cron", "edit", "j", "--reasoning-effort", "none"] + ["cron", "edit", "j", "--reasoning-effort", "max"] ) - assert edited.reasoning_effort == "none" + assert edited.reasoning_effort == "max" assert edited.clear_reasoning_effort is False cleared = parser.parse_args(["cron", "edit", "j", "--clear-reasoning-effort"]) diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 9809465a58a2..88629ffe8ba9 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -3,6 +3,8 @@ import json import pytest +from hermes_constants import VALID_REASONING_EFFORTS + from tools.cronjob_tools import ( CRONJOB_SCHEMA, _scan_cron_prompt, @@ -271,11 +273,14 @@ def test_reasoning_effort_create_update_clear_and_schema(self): action="create", prompt="Check", schedule="every 1h", - reasoning_effort="low", + reasoning_effort="max", ) ) assert created["success"] is True - assert created["job"]["reasoning_effort"] == "low" + assert created["job"]["reasoning_effort"] == "max" + + listing = json.loads(cronjob(action="list")) + assert listing["jobs"][0]["reasoning_effort"] == "max" updated = json.loads( cronjob( @@ -286,6 +291,15 @@ def test_reasoning_effort_create_update_clear_and_schema(self): ) assert updated["job"]["reasoning_effort"] == "none" + updated = json.loads( + cronjob( + action="update", + job_id=created["job_id"], + reasoning_effort="max", + ) + ) + assert updated["job"]["reasoning_effort"] == "max" + cleared = json.loads( cronjob( action="update", job_id=created["job_id"], reasoning_effort="" @@ -294,7 +308,7 @@ def test_reasoning_effort_create_update_clear_and_schema(self): assert "reasoning_effort" not in cleared["job"] values = CRONJOB_SCHEMA["parameters"]["properties"]["reasoning_effort"]["enum"] - assert {"", "none", "xhigh"}.issubset(values) + assert set(values) == {"", "none", *VALID_REASONING_EFFORTS} def test_invalid_reasoning_effort_fails_without_writing(self): created = json.loads( diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 254eec463624..c5fa6ef0017b 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Union -from hermes_constants import display_hermes_home +from hermes_constants import VALID_REASONING_EFFORTS, display_hermes_home logger = logging.getLogger(__name__) @@ -1091,7 +1091,7 @@ def cronjob( }, "reasoning_effort": { "type": "string", - "enum": ["", "none", "minimal", "low", "medium", "high", "xhigh"], + "enum": ["", "none", *VALID_REASONING_EFFORTS], "description": "Optional per-job reasoning effort. Omit or clear to inherit agent.reasoning_effort; pass 'none' to disable reasoning. Ignored for no_agent jobs. On update, pass empty string to clear." }, "attach_to_session": { diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 35bd83c9ebd6..767d022fc722 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -135,7 +135,7 @@ hermes cron create "0 3 * * *" \ --reasoning-effort low ``` -The `cronjob` tool accepts the same `reasoning_effort` field. Valid values are `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. +The `cronjob` tool accepts the same `reasoning_effort` field. Valid values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. - Omit the field to inherit global `agent.reasoning_effort`. - Use `none` to explicitly disable reasoning for that job. From fed281776f2a1988cb08f7b5a93850fc4479a692 Mon Sep 17 00:00:00 2001 From: herbalizer404 <8180647+herbalizer404@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:32:44 +0000 Subject: [PATCH 3/3] fix(cron): handle malformed reasoning effort values --- cron/scheduler.py | 5 +++-- tests/cron/test_cron_reasoning_effort.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 5c48cf07f227..d5cd7fcb0ee8 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2874,12 +2874,13 @@ def run_job( else "" ) job_effort = job.get("reasoning_effort") + has_job_effort = job_effort is not None and job_effort != "" reasoning_config = ( parse_reasoning_effort(job_effort) - if job_effort not in {None, ""} + if has_job_effort else parse_reasoning_effort(global_effort) ) - if job_effort not in {None, ""} and reasoning_config is None: + if has_job_effort and reasoning_config is None: logger.warning( "Job '%s': invalid reasoning_effort %r; falling back to global agent.reasoning_effort", job_id, diff --git a/tests/cron/test_cron_reasoning_effort.py b/tests/cron/test_cron_reasoning_effort.py index 0aacef996877..7c6e032d02d8 100644 --- a/tests/cron/test_cron_reasoning_effort.py +++ b/tests/cron/test_cron_reasoning_effort.py @@ -234,6 +234,23 @@ def test_scheduler_invalid_hand_edited_value_falls_back_to_global(scheduler_harn assert captured["reasoning_config"] == {"enabled": True, "effort": "medium"} +def test_scheduler_unhashable_hand_edited_value_falls_back_to_global(scheduler_harness): + home, captured = scheduler_harness + (home / "config.yaml").write_text( + "model:\n default: gpt-5.5\n provider: openai-codex\nagent:\n reasoning_effort: medium\n", + encoding="utf-8", + ) + + from cron.scheduler import run_job + + success, _doc, final_response, error = run_job(_minimal_job(["invalid"])) + + assert success is True + assert final_response == "ok" + assert error is None + assert captured["reasoning_config"] == {"enabled": True, "effort": "medium"} + + def test_scheduler_no_agent_ignores_reasoning_and_never_constructs_agent( scheduler_harness ):