feat(cron): honor a per-job max_turns cap in the scheduler - #57285
feat(cron): honor a per-job max_turns cap in the scheduler#57285riesvriend wants to merge 1 commit into
Conversation
A cron job can now declare `max_turns` — a per-job ceiling on agent tool-use iterations — which the scheduler honors over the global `agent.max_turns` default (90). This bounds a runaway job's cost (a one-shot reminder that starts looping stops early) without lowering the ceiling for the tenant's interactive turns. Today the only knob is `agent.max_turns`, which is global and so caps interactive turns too; there is no per-job cap at any released version. - scheduler: `_resolve_max_iterations(job, cfg)` — per-job cap > config `agent.max_turns` > legacy top-level `max_turns` > default 90. A junk / boolean / non-positive per-job value (e.g. a hand-edited jobs.json) is ignored so it can never silently disable the cap. - cron.jobs.create_job / update_job: accept, normalize, and persist `max_turns` — byte-identical when unset (like `attach_to_session`); an update of 0 clears it back to the default. - cronjob tool: `max_turns` param, JSON schema entry, `_format_job` output, and registry wiring (so an agent can set it). - `hermes cron create` / `edit`: `--max-turns` flag. - blueprints: `max_turns` parses, validates, and reaches the job spec. Tests: tests/cron/test_cron_max_turns.py (27 cases across the normalizer, create/update, the tool round-trip + schema, `_resolve_max_iterations` precedence, and blueprint parsing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Related: this competes with the earlier open PR #45809 (per-job |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the cap through job storage, the CLI, the model tool, and blueprints. The premise remains valid on current main: cron/scheduler.py:3024 still resolves only the global cap, which is passed to AIAgent at cron/scheduler.py:3179.
Problems
tools/cronjob_tools.py:1088declaresmax_turnswithminimum: 1, while the same new schema description says update can pass0to clear it and the new update branch accepts0as the clearing sentinel. A schema-conformant tool call cannot perform that documented clear operation.
Suggested changes
- Align the schema with the clearing contract, either by allowing
0or by providing a separate clear mechanism; adjust the schema test too. - Add a
run_jobwiring test that captures theAIAgent(..., max_iterations=...)argument, not only_resolve_max_iterationsunit tests. - Document the new CLI and tool parameter in
website/docs/user-guide/features/cron.md.
This is an automated hermes-sweeper review.
| }, | ||
| "max_turns": { | ||
| "type": "integer", | ||
| "minimum": 1, |
There was a problem hiding this comment.
This schema rejects 0 (minimum: 1), but the new update path and this description define 0 as the sentinel that clears a cap. Please either permit 0 here or expose a separate clear mechanism, so an agent can make the documented update with a schema-valid call.
What
Add an optional per-job
max_turns— a ceiling on how many agent tool-useiterations a single cron job may run — and honor it in the scheduler over the
global
agent.max_turnsdefault (90).Why
Today the only iteration ceiling is
agent.max_turnsinconfig.yaml. It isglobal: it caps a scheduled job and the user's interactive turns with the
same number. So there is no way to say "this daily reminder should never take
more than a few steps" without also throttling real interactive work down to
that same low ceiling.
The failure mode this closes: a cheap scheduled job (a one-shot reminder, a
data-collection ping) that starts looping — re-calling tools well past what
the task needs — runs all the way to the global default of 90 iterations before
the max-iteration guard stops it, and every one of those iterations is a paid
model call. We saw exactly this in production: a handful of runaway cron fires
were a meaningful fraction of one account's spend. A per-job cap lets the job's
author bound that blast radius (
max_turns: 3on a single-message reminder)while interactive turns keep the full ceiling.
There is no per-job cap at any released version, which is why this goes upstream
rather than staying a downstream config tweak.
What changed
The field travels the exact path
enabled_toolsets/workdiralready do:cron/scheduler.py— new_resolve_max_iterations(job, cfg): precedenceis per-job
max_turns→ configagent.max_turns(or legacy top-levelmax_turns) → default 90. A junk / boolean / non-positive per-job value(e.g. a hand-edited
jobs.json) is ignored, so it can never silently disablethe cap.
run_jobnow calls it in place of the inline expression.cron/jobs.py—create_job/update_jobaccept, normalize(
_normalize_job_max_turns: positive int orNone), and persistmax_turns.Storage stays byte-identical when unset (the key is only written when a
positive cap is given, like
attach_to_session); on update,0clears itback to the default.
tools/cronjob_tools.py—max_turnsparameter on thecronjobtool,a JSON-schema entry (
integer,minimum: 1),_format_joboutput, and theregistry wiring so an agent can set it.
hermes cron create/edit— a--max-turnsflag (pass0on edit toclear).
tools/blueprints.py— a blueprint may declaremax_turns; it is parsed,validated (positive int), and carried into the job spec.
Backward compatibility
No behavior change for any existing job or config: with
max_turnsunset thescheduler resolves exactly as before (
agent.max_turns→ 90), and stored jobsare byte-for-byte unchanged.
Tests
tests/cron/test_cron_max_turns.py— 27 cases: the normalizer, create/update(set, clear, byte-identical-when-unset), the
cronjobtool JSON round-trip +schema,
_resolve_max_iterationsprecedence (per-job wins in both directions,config/default fallback, junk ignored), and blueprint parse/validate/round-trip.
All green.
Separately noticed:
install.shupdate path can'tgit checkout <tag>While pinning a fleet to a released tag we hit a bug in
scripts/install.sh(happy to split this into its own issue/PR): the update path (existing
checkout) runs roughly
which treats
--branch <tag>as a branch. The tag lands inFETCH_HEADbutnever becomes a local ref, so
git checkout v2026.7.1fails withpathspec … did not matchon any already-provisioned host. The fresh-clonepath works (a clone fetches all tags), which hides it until the first in-place
update. A fetch of the tag ref (
git fetch origin "refs/tags/$TAG:refs/tags/$TAG"when the arg is a tag), or documenting
--commit <sha>as the pin-to-tag path,would fix it. We currently work around it by resolving tag → sha with
git ls-remoteand passing--commit.