Skip to content

feat(cron): run scripts on a schedule, with or without an agent turn - #221

Merged
keyKQ merged 3 commits into
use-agent-os:mainfrom
keyKQ:feat/cron-script-jobs
Aug 5, 2026
Merged

feat(cron): run scripts on a schedule, with or without an agent turn#221
keyKQ merged 3 commits into
use-agent-os:mainfrom
keyKQ:feat/cron-script-jobs

Conversation

@keyKQ

@keyKQ keyKQ commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #219.

Cron script jobs

What this adds

Two shapes for the same idea — let a script decide whether anything is worth reporting.

--job-kind script — the script is the job.

agentos cron add --every 5m --script watch-memory.sh --name memory-watchdog

stdout is delivered verbatim · empty stdout is a silent tick · a non-zero exit delivers the error and fails the job, so a broken watchdog can't be mistaken for a quiet one.

--job-kind agent_turn --script — the script runs first as a collector.

agentos cron add --every 10m --job-kind agent_turn \
  --script watch_github.py \
  --script-arg=--repo --script-arg=owner/name \
  --text "Summarize anything urgent."

Its stdout is prepended to the prompt as a ## Script output block. A tick where it prints nothing — or ends with {"wakeAgent": false} — skips the turn entirely. That happens before the session is touched, so a gated tick leaves no session row, no transcript line, and no model call behind. This is the half of the feature that makes a 5-minute triage job affordable: the agent only wakes on ticks with news.

Where this differs from the issue

The issue proposes job_kind=command with a shell command string. This ships job_kind=script pointing at a file under ~/.agentos/scripts/, exec'd directly:

  • No shell. Arguments go through --script-arg and are passed as argv. A value containing spaces or ; stays one argument and cannot start a second command — there is no quoting layer to get wrong.
  • The directory is the trust boundary, and it's auditable. Absolute paths, ~, .., and symlinks that leave ~/.agentos/scripts/ are all refused. .sh/.bash run under bash, anything else under the gateway's interpreter.

On the execution-safety question the issue left open, this takes option 3 (owner-only) rather than option 1 (sandbox): only an interactive CLI or Web caller may schedule a script, and the in-agent cron tool refuses it from a chat channel — the same gate that already guards tool_policy.elevated. Routing through SandboxRuntime stays open as a follow-up; say the word if you'd rather have that (or the command naming) before this lands.

Acceptance criteria

  • cron add --every 5m --job-kind script --script scripts/check.sh creates a job that runs on schedule
  • A tick produces zero provider calls — test_a_quiet_tick_costs_zero_provider_calls drives the gated path with a turn runner that raises if reached; test_the_handler_has_no_way_to_reach_a_provider pins that make_script_run_handler takes a delivery chain and nothing else, so no argument or payload can route a script job into a model
  • Non-empty stdout delivered; empty stdout with exit 0 delivers nothing
  • Non-zero exit is a failed run, visible in cron runs, feeding the existing consecutive-failure counter
  • timeout_seconds kills a hanging script and records the timeout
  • The execution-safety choice is covered by tests (caller gate, path confinement, symlink escape, argv handling)
  • docs/cli.md, docs/scheduling.md and the bundled agentos SKILL.md document the new kind

One fix to shared code, worth a look

ops.update merged payload patches, which made any optional payload key unremovable — clearing a job's pre-run script silently kept the old one. It never showed before because the three existing kinds only carry required keys. A patch that names its kind is a complete normalized payload from the RPC layer and now replaces; partial patches still merge. Both branches are pinned by tests.

Also here

  • cron-watchers bundled skill: ready-made watchers for an RSS/Atom feed, a JSON endpoint, and a GitHub repo, each following the print-only-what's-new contract. Dedup state lives in ~/.agentos/state/cron-watchers/, outside the scripts directory. The first run reports nothing by default — a watcher that has never run can't tell which of the thirty items on the page are new.
  • A Kind column in cron list and a SCRIPT pill on the Web UI cards.
  • A docs correction: --job-kind defaults to auto, which resolves to reminder. The old example cron add --every 1h --text "Summarize important updates" therefore repeated that sentence hourly rather than summarizing anything. docs/scheduling.md now has a Job Kinds table saying so.

Verified live

Beyond unit tests, a gateway built from this branch ran the whole matrix end to end: stdout delivered, silent tick, wakeAgent gate, exit 3 failing the job, args reaching the script with "a b; rm -rf /" intact as one argument, AGENTS_GATEWAY_TOKEN absent from the child env, webhook delivery received, a pre-run gate closing with zero sessions created, and a pre-run collector feeding a real model turn. A restart re-loaded all four payload kinds with zero field drift, and a database seeded in the pre-migration schema — including a payload with no kind key at all — loaded and ran unchanged.

Note for reviewers: a script inherits provider credentials like every other AgentOS child process (AGENTOS_STRIP_PROVIDER_ENV=1 withholds them); only the gateway token and the redaction switches are always stripped. docs/scheduling.md says so explicitly.

Gate

ruff + mypy (590 files) clean · 7194 pytest passed · 1660 vitest passed (npm --prefix frontend run check). Rebased on main at f759dd9.

The Windows paths (CREATE_NO_WINDOW, uv-venv interpreter resolution) are written but untested — this was developed on macOS.

keyKQ added 2 commits August 5, 2026 13:23
Every cron job that could *do* something went through the model chain, so a
deterministic task — poll an endpoint, check a threshold, diff a feed — paid
for a full turn on every tick. A 5-minute job is 288 turns a day to run a
script that prints one line when something is wrong.

Two shapes, one idea: let the script decide whether anything is worth
reporting.

**`--job-kind script`** — the script is the job. Its stdout is delivered
verbatim, empty stdout is a silent tick, and a non-zero exit delivers the
error and fails the job so a broken watchdog cannot be mistaken for a quiet
one. `make_script_run_handler` takes a delivery chain and nothing else, so
there is no argument or payload that can route it into a provider call.

**`--job-kind agent_turn --script`** — the script runs first as a collector.
Its stdout is prepended to the prompt as a `## Script output` block; a tick
where it prints nothing (or ends with `{"wakeAgent": false}`) skips the turn
entirely — before the session is touched, so a gated tick leaves no session
row, no transcript line, and no model call behind.

Scripts resolve inside `~/.agentos/scripts/`; absolute paths, `~`, `..`, and
symlinks out of it are refused. Arguments are exec'd as argv and never handed
to a shell. Scheduling one requires an interactive CLI or Web caller — the
in-agent `cron` tool refuses it from a chat channel, matching the existing
gate on `tool_policy.elevated`.

Also fixes a pre-existing limitation the new payload keys exposed:
`ops.update` merged payload patches, which made any optional key
unremovable — clearing a job's pre-run script silently kept the old one. A
patch that names its `kind` is a complete normalized payload from the RPC
layer and now replaces; partial patches still merge.

Refs use-agent-os#219
A script job is only as useful as the script, and the three everyone writes
first are the same three: an RSS/Atom feed, a JSON endpoint, and a GitHub
repo. Each ships as a small standalone script that follows the contract the
scheduler expects — print what is new, print nothing when nothing is new,
exit non-zero on a real failure.

Deduplication state lives in `~/.agentos/state/cron-watchers/<name>.json`,
outside the scripts directory, so that directory stays read-only in practice.
The first run reports nothing by default: a watcher that has never run cannot
tell which of the thirty items on the page are new, and dumping all of them
into a chat is the wrong first impression.

Refs use-agent-os#219
Asking an agent in the Web chat to schedule a script job gets you
`script='"watch-memory.sh"'` — the model passes the value with its quotes
still attached often enough that it is the first thing that happens. Nothing
rejected it: the job saved cleanly, then failed on its first tick against a
path with quote characters in the middle of it.

A path whose first and last characters are the same quote is never a real
file name, so one layer is unwrapped at the boundaries that store the value
and again in the resolver as a backstop. Found by asking a live agent to
create one.

Refs use-agent-os#219
@keyKQ

keyKQ commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c469e89 after driving the last untested path — asking a live agent in the Web chat to schedule a script job.

It works (the tool is reachable from a Web caller, refused from a channel), but the first attempt produced a broken job: the model passed script='"watch-memory.sh"' with the quotes still attached, nothing rejected it, and the job saved cleanly and then failed on its first tick against a path with quote characters in the middle of it:

Script not found: …/scripts/"watch-memory.sh"

A path whose first and last characters are the same quote is never a real file name, so one layer is now unwrapped where the value is stored and again in the resolver as a backstop. Re-verified end to end: the agent-created job now stores watch-memory.sh and its first run returns disk at 91%.

Worth knowing for anyone reviewing the tool ergonomics: on a smaller model (glm-5.2) the agent needed four attempts to get the cron call right, cycling through "I keep omitting schedule and task". The errors it got back were accurate and specific each time, so this looks like model fumbling rather than a contract problem — but it is the kind of thing a required-args-by-job-kind schema would smooth out if it keeps showing up.

CI on the previous head was green on both runners, including windows-latest — which covers the CREATE_NO_WINDOW and uv-venv interpreter paths I'd flagged as written-but-untested.

@keyKQ
keyKQ merged commit c3318e6 into use-agent-os:main Aug 5, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cron: add a no-agent job kind that runs a command/script without an LLM turn

1 participant