Skip to content

feat(cron): honor a per-job max_turns cap in the scheduler - #57285

Open
riesvriend wants to merge 1 commit into
NousResearch:mainfrom
riesvriend:oteny/cron-per-job-max-turns
Open

feat(cron): honor a per-job max_turns cap in the scheduler#57285
riesvriend wants to merge 1 commit into
NousResearch:mainfrom
riesvriend:oteny/cron-per-job-max-turns

Conversation

@riesvriend

Copy link
Copy Markdown

What

Add an optional per-job max_turns — a ceiling on how many agent tool-use
iterations a single cron job may run — and honor it in the scheduler over the
global agent.max_turns default (90).

Why

Today the only iteration ceiling is agent.max_turns in config.yaml. It is
global: 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: 3 on 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 / workdir already do:

  • cron/scheduler.py — new _resolve_max_iterations(job, cfg): precedence
    is per-job max_turns → config agent.max_turns (or 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. run_job now calls it in place of the inline expression.
  • cron/jobs.pycreate_job / update_job accept, normalize
    (_normalize_job_max_turns: positive int or None), and persist max_turns.
    Storage stays byte-identical when unset (the key is only written when a
    positive cap is given, like attach_to_session); on update, 0 clears it
    back to the default.
  • tools/cronjob_tools.pymax_turns parameter on the cronjob tool,
    a JSON-schema entry (integer, minimum: 1), _format_job output, and the
    registry wiring so an agent can set it.
  • hermes cron create / edit — a --max-turns flag (pass 0 on edit to
    clear).
  • tools/blueprints.py — a blueprint may declare max_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_turns unset the
scheduler resolves exactly as before (agent.max_turns → 90), and stored jobs
are 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 cronjob tool JSON round-trip +
schema, _resolve_max_iterations precedence (per-job wins in both directions,
config/default fallback, junk ignored), and blueprint parse/validate/round-trip.
All green.


Separately noticed: install.sh update path can't git 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

git remote set-branches origin "$BRANCH"
git fetch origin "$BRANCH"
git checkout "$BRANCH"

which treats --branch <tag> as a branch. The tag lands in FETCH_HEAD but
never becomes a local ref, so git checkout v2026.7.1 fails with
pathspec … did not match on any already-provisioned host. The fresh-clone
path 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-remote and passing --commit.

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>
@alt-glitch alt-glitch added type/feature New feature or request comp/cron Cron scheduler and job management comp/cli CLI entry point, hermes_cli/, setup wizard area/billing Account usage, credit usage, billing (cross-cutting) P3 Low — cosmetic, nice to have labels Jul 2, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: this competes with the earlier open PR #45809 (per-job max_turns plus a wall-clock timeout), which is the canonical entrant for the per-job max_turns feature. Other entrants in the cluster: #33323 (per-job max_iterations), #33305 (global cron.max_iterations cap), #50268 (per-job runtime_cap_seconds). This PR is the CLI-integrated max_turns-only slice — a maintainer should pick which to merge.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:1088 declares max_turns with minimum: 1, while the same new schema description says update can pass 0 to clear it and the new update branch accepts 0 as 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 0 or by providing a separate clear mechanism; adjust the schema test too.
  • Add a run_job wiring test that captures the AIAgent(..., max_iterations=...) argument, not only _resolve_max_iterations unit tests.
  • Document the new CLI and tool parameter in website/docs/user-guide/features/cron.md.

This is an automated hermes-sweeper review.

Comment thread tools/cronjob_tools.py
},
"max_turns": {
"type": "integer",
"minimum": 1,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/billing Account usage, credit usage, billing (cross-cutting) comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants