feat(cron): run scripts on a schedule, with or without an agent turn - #221
Conversation
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
|
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 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 Worth knowing for anyone reviewing the tool ergonomics: on a smaller model (glm-5.2) the agent needed four attempts to get the CI on the previous head was green on both runners, including windows-latest — which covers the |
Closes #219.
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.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 outputblock. 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=commandwith a shell command string. This shipsjob_kind=scriptpointing at a file under~/.agentos/scripts/, exec'd directly:--script-argand 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.~,.., and symlinks that leave~/.agentos/scripts/are all refused..sh/.bashrun 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
crontool refuses it from a chat channel — the same gate that already guardstool_policy.elevated. Routing throughSandboxRuntimestays open as a follow-up; say the word if you'd rather have that (or thecommandnaming) before this lands.Acceptance criteria
cron add --every 5m --job-kind script --script scripts/check.shcreates a job that runs on scheduletest_a_quiet_tick_costs_zero_provider_callsdrives the gated path with a turn runner that raises if reached;test_the_handler_has_no_way_to_reach_a_providerpins thatmake_script_run_handlertakes a delivery chain and nothing else, so no argument or payload can route a script job into a modelcron runs, feeding the existing consecutive-failure countertimeout_secondskills a hanging script and records the timeoutdocs/cli.md,docs/scheduling.mdand the bundledagentosSKILL.md document the new kindOne fix to shared code, worth a look
ops.updatemerged 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 itskindis a complete normalized payload from the RPC layer and now replaces; partial patches still merge. Both branches are pinned by tests.Also here
cron-watchersbundled 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.Kindcolumn incron listand aSCRIPTpill on the Web UI cards.--job-kinddefaults toauto, which resolves toreminder. The old examplecron add --every 1h --text "Summarize important updates"therefore repeated that sentence hourly rather than summarizing anything.docs/scheduling.mdnow 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,
wakeAgentgate, exit 3 failing the job, args reaching the script with"a b; rm -rf /"intact as one argument,AGENTS_GATEWAY_TOKENabsent 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 nokindkey at all — loaded and ran unchanged.Note for reviewers: a script inherits provider credentials like every other AgentOS child process (
AGENTOS_STRIP_PROVIDER_ENV=1withholds them); only the gateway token and the redaction switches are always stripped.docs/scheduling.mdsays so explicitly.Gate
ruff+mypy(590 files) clean · 7194 pytest passed · 1660 vitest passed (npm --prefix frontend run check). Rebased onmainat f759dd9.The Windows paths (
CREATE_NO_WINDOW, uv-venv interpreter resolution) are written but untested — this was developed on macOS.