AgentOS 2026.8.6
A cron release. Schedules can run scripts now — without paying for a model turn on every tick — and the surface that tells you what a job actually did was rebuilt around that, because a script job is the shape that exposed how much of it was broken.
Added
-
A cron job can run a script instead of a model turn. Every 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_handlertakes 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, and 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 — 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-agentcrontool refuses it from a chat channel, matching the existing gate ontool_policy.elevated. (Refs #219) -
New bundled skill
cron-watchers. 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 following 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. (Refs #219) -
Read a run's output in full.
agentos cron output <job-id> [--run <run-id>]and thecron.runOutputRPC. The in-agentcrontool gainsaction="runs", bounded so one answer cannot eat the context window — "what did the watcher report?" now has an answer the model can look up rather than one it can invent. -
senior-unilp-managercan open the pool a position lives in.lp_write.py create-poolinitializes hook-less Uniswap v4 pools.initializePoolis a plain PositionManager function, not amodifyLiquiditiesaction, and the transaction carries no unlockData, no deadline and no token movement — so nothing about the plan/simulate/confirm/send path moves. Guards, in the order a run meets them:hooksis pinned toaddress(0)and there is no flag to change it; a dynamic fee is refused; fee and tickSpacing are range-checked locally so the failure is a sentence rather than an unnamed custom-error selector; a fee/tick-spacing pair outside the vanilla tiers needs--allow-odd-tier, becausepoolsonly searches those tiers and the pool would otherwise be invisible to discovery; an already-initialized pool prints its poolId and current tick and exits without planning; andrevalidatere-reads slot0 just before sending. The starting price is the one parameter that can never be corrected, so the table prints tick,sqrtPriceX96, and the price in both directions under a banner saying so. selftest goes 695 → 716 assertions. -
tick --json --alert-onlylets a ratchet cron stay quiet. A ratchet tick wired as a script job delivered the full result of every mandate on every run, so a healthy ratchet posted a block of JSON saying nothing happened, every ten minutes, until nobody read it any more. A tick that found nothing still prints the whole payload — the run history keeps it, so a quiet tick stays auditable — but ends on{"wakeAgent": false}, whichhas_actionable_output()already reads as "no news": the run succeeds and nothing is delivered. A tick that fired, adopted a landed fire, halted, was rejected, expired, or built a plan on a dry run is delivered as usual.NEEDS_ATTENTIONis tested apart from the action and deliberately alerts on every tick — it is a terminal state, so a halted mandate reports the same plainnoopas a healthy one while it waits for a person, and filtering on the action alone would silence the one alarm that must never go quiet. The flag is opt-in and only touches--json. (Closes #234) -
lp_read.py price --tokensis documented (SKILL.md §6b), with the rule to use it rather than deriving a price from a pool. Its absence was not theoretical: a session derived a token's price from a zero-TVL dust pool, was off by 3×, and that number would have become the permanent starting tick of a new pool. (Refs #228)
Fixed
-
A bad cron delivery target is rejected when you save it, not ten minutes later. A script job was repointed at Telegram through the Web UI; the value to hand was the session key
agent:main:telegram:direct:…, the Recipient field was free text, and nothing objected.validate_channel_targetnow refuses an id beginning with a session-key prefix (agent:,cron:,webchat:,session:) for any channel, and requires an integer or@usernamefor Telegram — a rejection suggests the id that would have worked. The guard is a prefix whitelist, not a colon count, because a real MS Teams conversation id contains colons.cron.addandcron.updatethen ask the adapter itself viaTelegramChannel.probe_target; only a definite "no" blocks the save, since a missing capability, a timeout, or a transport error is not evidence about the chat id. -
The chats we already know are offered. New
channels.deliveryTargetsRPC lists each Telegram channel's paired DMs and configured group chats, and the Web UI renders Recipient as a dropdown when the channel has known targets, withEnter manually…for a group chat outsidegroup_chat_ids. Channels with no pairing store keep the text input. -
The reason a delivery failed is on the run record.
DeliveryReport.channel_detailturns one line of "delivery failed" into "Cron job 'x' delivery to telegram failed: Bad Request: chat not found" inagentos cron runs. An exception escaping the channel leg reports its type instead of vanishing intoasyncio.gather. -
structlogevents reach~/.agentos/logs/debug.log. Half this codebase logs throughlogging.getLoggerand half through structlog, and only the first half reached the file — the missing half included everydelivery.*warning, which is why the reason above existed only in the terminal the gateway was started in. The gateway tees structlog events into stdlib logging while file logging is on, and restores the previous processor chain when it is turned off. -
A run's output is stored whole.
clamp_run_outputreplaces the scattered[:500]slices that truncated a script job's stdout on the way into the database;preview_summaryis what the delivery layer and the run list get. The Web UI's expanded row fetches the full text lazily and falls back to the preview when the fetch fails, with amin(60vh, 32rem)panel instead of 18rem. -
A script job's output no longer vanishes. Several skips in the delivery chain encode "the run already wrote this into the session, so mirroring it would duplicate it". That holds for an agent turn, which appends its own reply to the transcript it ran in. A script job has no turn: nothing writes its stdout anywhere unless the chain does. A job with
sessionTarget=currentfrom webchat was reported "delivered" without a single byte being written, and a job bound to a chat whose run is that chat was skipped onorigin == session_key— both ran green and left the operator with an empty conversation. Acron add --scriptfrom the CLI genuinely has nowhere to write, and now reportsno_session_targetrather than a bareskipped, so an empty chat is legible instead of looking like a quiet run. -
The "→ Chat" button no longer leads to "Could not load chat history." Each run reports
chatAvailableand the button is hidden when it is false — script jobs never create a session, and isolated agent sessions are reaped after 24h, so the key alone was never proof a chat exists.chat.historyanswers an empty transcript for a missing cron session instead of raising. -
The session reaper is paged.
list_sessions()returns the 100 most recently updated sessions — precisely the ones that are not expired — so expired isolated cron sessions were never reaped on a busy store. -
A cron tool refusal reaches the model. Cron raises plain
ToolErrorin ~30 places with field-naming messages thatenvelope.pydiscarded, because it exposes a message only forSafeToolUserMessagesubclasses. A call carryingtool_policy.elevatedon a script job came back as "received an invalid argument", and the seven retries after it — which dropped the requiredschedulefield — all came back as "failed with an internal error". Nothing was scheduled and no log line recorded why. Cron's refusals areSafeToolErrornow (scoped to cron; the ~230 other sites have not been audited for what they contain), andjob_kind='script'+tool_policy.elevatedis rejected up front, naming the field and pointing atagent_turn. (Refs #228) -
A quoted script path is unwrapped before it is stored. Asking an agent in the Web chat to schedule a script job gets you
script='"watch-memory.sh"'often enough that it is the first thing that happens — 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. (Refs #219) -
/resetclears the visible conversation on web and CLI.sessions.resetkeeps the session key and only rotatessession_id, sochat.historyreturns empty afterwards — but both clients left the conversation on screen, which reads as "nothing happened". The Web UI clears onsession.epoch_changed, which is broadcast from (and only from) the reset path, so one hook covers the typed/reset, the slash menu, the SessionChip button, and a reset issued by another connected client. The CLI gainsChatApplication.clear_screen(); native scrollback writes\x1b[3Jtoo, so the cleared turns cannot be scrolled back to. -
Two
senior-unilp-managerdoc commands were unrunnable.python3 <S>/ratchet.pyreads as a redirect from a file namedSin a shell, and the cron examples cannot use$Sat all, since a cron job runs in a fresh isolated session or as a bare file. Both now spell out{baseDir}/scripts/ratchet.py. (Refs #228)
Changed
- The cron surfaces say "no LLM" instead of "no model", and
agentos cron runsgrows Delivery and Output columns.
Full changelog: v2026.8.5...v2026.8.6