-
Notifications
You must be signed in to change notification settings - Fork 2
0.35.0
Skills can now bound their whole run in wall-clock time — and when the clock runs out, in-flight work is actually stopped, not just abandoned.
Upgrade impact: none (additive), fully opt-in. A skill with no # Deadline: and a runtime with no ceiling behave exactly as before.
Before this, the only time control was a per-op timeout. That leaves two holes:
- No total budget. Ten ops that each finish just under their timeout can still add up to a run that takes far longer than you'd ever intend. Nothing bounded the whole thing.
- Timeout ≠ stopped. When an op did time out, the runtime moved on — but the work it started often kept going. A hung HTTP call, a model still generating, a robot still moving. "Timed out" meant "we stopped waiting," not "it stopped."
That second one is the sharp edge. An approver who signs off on a "bounded" skill is trusting that bounded means both capped in time and safe when cut. Deadlines make both literally true.
Add one frontmatter line. N is seconds (an integer or a $(VAR) reference):
# Skill: nightly-report
# Deadline: 30
# Status: Approved
default: run
run:
$ db.query sql="..." -> ROWS
$ llm prompt="Summarize: ${ROWS}" -> SUMMARY
notify(agent="ops", text="${SUMMARY}")
This run — and everything it composes — is bounded to 30 seconds total. The deadline resolves once, at the root, to an absolute instant and propagates into child skills by remaining time: a child can make the bound tighter, never looser. A deep execute_skill gather can't outlive the root's budget.
It's uncatchable: when the deadline hits, the run stops and returns whatever completed. An op's (fallback:) and a target's else: do not catch it — otherwise a fallback-laden skill could quietly cascade every remaining op into instant-fail-then-fallback and hand you a result that looks complete but ran past the bound. Uncatchable is what makes "bounded to 30s" true.
When an op is cut at the deadline, the runtime actually stops the work — how depends on what the op is:
-
shell(...)— the child process is killed (process-group SIGKILL). It's gone, not orphaned. -
$ connector.tool— the connector's in-flight call gets anAbortSignal. A connector that forwards it to its HTTP/RPC client genuinely cancels. - An effect that outlives the call — a robot that keeps moving after the call returns, a spawned job — is handled specially (see below).
This one's for operators, not skill authors. Set it in the environment and it caps the wall-clock time of every run — enforced even for a skill that declares no # Deadline:, on both the server/MCP path and the skillfile execute CLI:
SKILLSCRIPT_MAX_DEADLINE_SECONDS=120A skill's own # Deadline: can only make its bound tighter, never exceed the ceiling. Since skills are written by agents, this is your backstop: an agent author can't dodge the time bound by simply omitting # Deadline:. On a latency-sensitive or robotic host, set a small ceiling so no run — or motion — can hang. Unset = no ceiling (deadlines stay purely opt-in).
When the deadline cuts an op that was changing external state — a send, a write, a device command — the runtime can't know whether the request reached the backend before it aborted the client. So it doesn't lie in either direction. It records the op in an uncertain-effects log: "issued, outcome uncertain, never auto-retried."
You'll find it as uncertain_effects on the execute_skill result and — critically — on the durable trace record. That last part matters for autonomous runs: a 3 a.m. cron fire has no caller watching the return value, so the trace is the only record that "this send may or may not have gone out."
If your connector's effect keeps running after call() returns (a physical actuator, a long async job), declare it and give it a bounded stop hook:
{
effectBoundary: "outlives-call",
async call(tool, args, ctx) { /* start the motion; ctx.signal aborts it */ },
async onAbort(budgetMs) { /* issue a real safety-stop within budgetMs */ },
}At the deadline the runtime aborts the call, then invokes onAbort(budgetMs) with a small reserved cleanup budget so you can halt the motion — and the whole run still returns within the deadline. onAbort is a safety-stop, not a rollback: it halts a runaway effect, it doesn't undo what already happened (which is exactly why the op also lands in uncertain_effects).
The runtime refuses to register an outlives-call connector that has no onAbort — an out-of-band effect that can't be cancelled has no business being wired in.
-
Editing
# Deadline:returns a skill to Draft. Unlike# Tags:, the deadline is part of the approval signing hash — it's the safety envelope the approver signed. Changing it (even tightening it) invalidates the signature and needs re-approval. That's deliberate. -
uncertain_effectsmeans "reconcile this," not "this failed." Don't treat it as an error to retry. It's a "go check whether the email actually sent / the row actually wrote" flag. It never auto-retries — retrying a maybe-completed mutation is how you double-charge a card. -
Reads and model calls aren't in the log. A cut
$ data_reador$ llmlands nothing external, so recording it would just be noise — they're excluded on purpose. The log stays high-signal: it's the list of possible external changes. - A tight deadline + an outlives-call op = the op won't start late. If there isn't enough time left for the op and its cleanup reserve, it fails fast rather than beginning an effect it couldn't guarantee stopping in time. Give effectful skills realistic deadlines.
-
Add a deadline to anything effectful. A skill that dispatches an effectful op with no
# Deadline:is unbounded at the run level; a lint (unbounded-no-deadline) will nudge you. Especially anything driving a device or a long external job.