v2.0.0
The release where agentdeck becomes a platform rather than a harness. Every turn — chat
or workflow — now runs on one Runtime and leaves one canonical event log behind, which is
what makes the rest of this list possible: a run you can pause, resume or cancel from
another process; an approvals inbox that survives a restart; a log you can point at
Postgres or Redis and share between workers; status and progress a client can render
instead of inferring. The v1 Python API, the .agentdeck/ layout and the SSE wire are
unchanged and verified against recorded baselines, so a v1.2.1 project keeps working.
Known limits worth reading before you upgrade, each with an issue rather than a footnote:
run control covers agent runs — a workflow run has no safe point yet, so it pauses
through its own interrupt/resume instead (#128). Telemetry still flows through v1's
tracer, not the event-stream sink, so the b4 note claiming Langfuse covered workflow runs
described a sink nothing had wired (#124). The HTTP approvals inbox and
App.pending_interrupts() read different sources and will disagree if you drive
approvals through both (#120). There is still no auth on the endpoints (#25) and no
tenancy — one tenant, one principal.
Added
-
Pause, resume and cancel a run that is already in flight, by
run_id, from
Python or over HTTP:await app.pause_run(run_id, reason="operator stepped away") events = await app.resume_run(run_id) await app.cancel_run(run_id, reason="user closed the tab")
POST /runs/{run_id}/pause {"reason": "..."} -> {"run_id", "verb", "recorded": true} POST /runs/{run_id}/cancel {"reason": "..."} -> {"run_id", "verb", "recorded": true} POST /runs/{run_id}/resume {"reason": "..."} -> {"run_id", "status", "events"}, 409 if not pausedAsking is not stopping, and the log says both.
pause_runandcancel_run
record a request and return; they cannot tell you when the run will stop, because
it may be halfway through a tool call. So a controlled run writes
control.requested, thencontrol.observed(safe_point)once it reached a safe
point, then the effect —run.paused/run.cancelled/run.resumed. Watch the
events for the effect to learn that it stopped. A request leaves the run
running; only the effect moves it.Nothing is force-killed. A signal is honored at the next safe point — between
two stream items, so the chunk in flight is always delivered whole — and a tool
call already running is never interrupted: the call finishes and the run stops
before the step that would have used its result.safe_pointon
control.observedis what distinguishes "cancel took eight seconds" from "cancel
took eight seconds because a tool call did".A paused run is suspended in the log, not parked in a process. The worker is
free to exit, and any worker sharing the event store can lift the pause. Because
there is no stack to return to, resuming re-enters the engine with the run's own
input and the log as history: samerun_id,seqcarrying on. Work the paused
turn had already done can therefore happen again — the model is asked again, and
a tool it had already called may be called a second time, so keep tools idempotent
and put side effects behindctx.idempotency_key. Exactly one caller can resume a
paused run; a second gets nothing rather than a second turn. Cancel is terminal
and cannot be resumed, andpausedstays distinct fromwaiting_human(that one
resumes with a value).Cancelling a paused run works, with one caveat worth knowing. Pause, think,
give up is the ordinary path, and a cancel recorded against a paused run is
honored by the next resume — which ends the runcancelledrather than playing it
on, so a resume can never quietly override whoever cancelled. But a paused run has
no loop reaching safe points, so nothing else can turn that request into an effect:
a paused run that nobody ever resumes stays paused, holding its session until
AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDStakes it over.Races are no-ops, never errors. A signal that arrives after the run ended does
nothing and records nothing; the same pause sent twice is one request; resuming a
run that is not paused returns nothing (409 over HTTP).Pending signals live in a control port, which is in-process by default — one
worker can control its own runs, a second worker cannot see them. Set
AGENTDECK_CONTROL_BACKEND=sqliteandAGENTDECK_CONTROL_URL=<file>for signals
that cross processes, which is also the file
agentdeck runs signal <run_id> <cancel|pause|resume> --control-db <file> [--reason ...]writes to. Agent runs honor safe points today; a workflow
(LangGraph) run has none yet, so pause and cancel do not reach one. -
A run can say what it is doing — two new event kinds,
status.reported(a
human-readable line:"Searching GitHub") andprogress.reported(a named stage,
optionally counted:step="Reviewing issues", current=2, total=4), so a client can
show a long run's activity instead of inferring it from tool calls. Both are
advisory: they carry no meaning for the platform, and a run's status still
folds from its lifecycle events alone — a run that reports is stillRUNNING, and
neither kind is terminal.
Emitters reach the stream through the run context, which they already have:
await ctx.reporter.status("Searching GitHub")and
await ctx.reporter.progress("Reviewing issues", current=2, total=4). An
openai-agents function tool gets that context as the SDK's own — declare a first
parameter of typeRunContextWrapper[RunContext]and usewrapper.context.reporter.
A langgraph node declares aconfig: RunnableConfigparameter and reads
config["configurable"]["reporter"](the key is
agentdeck.adapters.engines.langgraph.REPORTER_KEY). Nothing imports the Runtime, and
aRunContextbuilt outside a run has a reporter that validates and drops.
currentpasttotalraises immediately, at the call, whether or not a Runtime is
listening. Reports are recorded in order, always before the run's terminal event, and
the CLI reference renderer prints them ([status] …/[progress] … (2/4)).
Three honest limits. A report is written at the engine's next event, so one emitted
inside a single long tool call surfaces when that call ends rather than while it
runs — enough for a client to show what a run has been doing, not enough to narrate a
single slow call as it happens. Reports are best-effort: more than 64 waiting at once
are dropped with a warning rather than growing without bound, and a report the event
log refuses is dropped too, because an advisory event is never worth failing a run
that would otherwise have completed.
Reading a stream that contains them needs no change: a reader older than this release
parses both asUnknownEventand skips them, and because neither is a lifecycle kind
a mixed-version deployment folds status identically on both sides. -
control.requestedandcontrol.observed(agentdeck.core): run control
is now three events, not one.control.requested(verb, reason=None)records
that a signal was written;control.observed(verb, safe_point)records that the
run reached a safe point and is acting on it; the effect stays the kind it
always was (run.cancelled,run.paused,run.resumed,input.appended). So
"we asked it to stop" and "it stopped" are finally different facts in the log —
under cooperative control they can be seconds apart, andsafe_point
(stream_item,tool_dispatch,node_boundary) says what the run was in the
middle of. One pair of kinds carries every verb —cancel,pause,resume,
steer— so pause/resume and mid-run steering add no further vocabulary when
they ship. Neither kind is a status transition: a request leaves a runRUNNING
until its terminal event says otherwise, and neither is terminal.
The vocabulary landed first and the producers followed in the same release (see
the pause/resume/cancel entry above): a run that is signaled now emits both
kinds. The CLI renderer prints both phases and the Langfuse sink puts them on the
run's timeline. -
run.resumednow carries the answer it was resumed with, as content
(value: list[ContentBlock] | None) — content passes through as sent, a string
arrives as aTextBlock, any other JSON answer as aDataBlock, and lifting an
operator's pause carries nothing. Stored in full, like a run's own input,
because a truncated answer cannot be replayed. This is what makes a
previously unrecoverable window repairable: the single write that moved a run
fromwaiting_humantorunningrecorded that it was answered and not what
the answer was, so a process dying between that write and the engine consuming
the value left the log sayingrunningwhile the engine was still parked at its
interrupt — every later resume then rejected as stray, with no recovery but a
manual one. The answer is now in the log at the instant the claim commits, before
the engine is asked for anything, so a successor process has what it needs. The
repair itself is not built here — nothing yet readsvalueback to bring an
engine into line — so treat this as the prerequisite, not the fix. An answer JSON
cannot carry (an arbitrary object, adatetime,NaN) is logged as a warning and
recorded as no value rather than failing a resume that would otherwise work; such
a run keeps the old stranding risk.
Compatible in both directions, and measured rather than assumed: arun.resumed
written before this release still parses (novaluemeans none), and a 2.0.0b4
reader handed one of the new events parses it and drops the field it does not
know — no listing or dashboard outage like the oneDataBlockcaused, and the
new kinds arrive as unknown kinds a consumer skips. The one caveat is what that
dropping implies: only a process new enough to seevaluecan use it to
repair a resume, so upgrade the workers that reconcile before relying on it. -
UnknownBlock(agentdeck.core): a content block of a type this version
doesn't recognize now falls back toUnknownBlock(type, raw_block)— keeping the
raw block for a store to hold and a consumer to skip — instead of rejecting the
whole event, mirroring how an unknown eventkindalready becomesUnknownEvent.
Closes the one asymmetryDataBlock(#101) exposed:ContentBlockwas a strict
discriminated union, so a reader older than a new block type raised on the entire
event rather than skipping the block, and becauseSqliteEventStore.list_runs
deserializes a run's last lifecycle row in one comprehension, one such event in a
shared store could fail a whole tenant's listing. A malformed known block still
raises. Measured againstorigin/dev's ownContentBlock, not asserted: that
reader really does reject a block type this addition introduces, and this tree's
reader parses the same wire event, keeps the raw block, and leavesstatus_ofand
the terminal invariant unchanged (tests/core/test_old_reader_block_compat.py).
Changed
- A run reads its pending control signal at most once every 200ms, instead of
once per streamed item. A 500-chunk answer used to cost 500 control reads whose
answer was "no" 499 times — one file read each with the SQLite control port, and a
network round trip each once the port is shared. Measured at a real model's pace
(~30ms a chunk), a 400-chunk answer now costs 58 reads instead of 400.
What this trades is latency, not correctness: a cancel is noticed up to 200ms
after it is recorded, and still acted on at a safe point, never mid-token. The
first safe point of a run always reads, so a signal that beat the run out of the
gate is honored immediately. Anyone who was relying on the previous
read-every-item behavior — a test asserting a cancel lands within a stream shorter
than 200ms, for instance — can passRuntime(..., control_poll_interval=0)to get
it back at the old read cost. - A sink the breaker disables is no longer disabled for good. A telemetry
endpoint that failed five events in a row used to be dead for the rest of the
process; now the dispatch waits 30 seconds and then lets one event through to
see whether it is back. A sink that takes that event starts receiving the
stream again; one that fails it keeps its events dropped and is offered
another event 30 seconds later, so a genuinely dead endpoint costs two emit
attempts a minute rather than one per event. Coming back is logged as loudly as
going away was, and says how many events the outage dropped, so a stream that
resumes mid-run is not a gap with nothing to explain it. The cooldown is a
deadline read off a clock and never a wait — a run is not slowed by a sink's outage or by
its recovery — and nothing is replayed: the events the outage covered are
still lost, and still counted as drops. A sink therefore needs no retry logic
of its own for a transient outage, and one that cannot lose events reads the
event log, which is the complete copy. - A flapping sink can no longer flood the log with stack traces. Failure
logging was rate-limited per failure streak, which bounded nothing for a
sink that fails every other event — each success reset the streak, so every
failure printed a fresh traceback and the run's length decided the log
volume. Tracebacks are now limited to one per sink per 60 seconds, and each
one reports how many failures went unlogged since the last, so a throttled
log still says how much it is standing in for. The breaker's disable decision
is unchanged by this. - The workflow HTTP endpoints run on the v2 Runtime.
POST /workflows/{name}/run,GET /workflows/{name}/pendingandPOST /workflows/{name}/{thread_id}/resumewere the last surface still calling v1's
runner directly, so a workflow turn left no event log behind at all — it
streamed to the caller and vanished. Every workflow turn is now recorded like a
chat turn: one run in the log, node updates, stream writes, interrupts and the
final state, readable by the same listings, replays and dashboards. The wire is
unchanged — the samenode_update/custom/interrupt/doneSSE frames
and the same JSON bodies, checked against the recorded baselines rather than by
inspection.
Three consequences worth knowing before upgrading. A workflow'sthread_idis
now its session, and a session runs one turn at a time, so posting a second
run to a thread whose previous turn has not finished answers 409 instead of
interleaving two turns over one graph state. Read "not finished" broadly: a
thread sitting idle on an unanswered approval is not finished either, and holds
its session until somebody answers it — so the case an approval UI actually hits
is a 409, for as long as the approval goes unanswered (or until that run has been
silent forAGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS, one hour by default,
after which the next turn takes the session over). A resume against a thread with
no paused run answers 404 where it previously surfaced v1's runner error.
And aget_stream_writer()write now reaches the log as a namespacedcustom
event (langgraph.stream_write) on its way to the unchangedcustomframe.
Adurable = Trueworkflow still resumes on the configured checkpointer — the
bridge plays v1's own compiled graph, which carries it — and the[durability]
extra stays optional for a project that only chats. - The HTTP approval inbox and
App.pending_interrupts()are now separate sources
of truth, and will be until they are joined.GET /workflows/{name}/pending
and the HTTP resume project the event log;App.pending_interrupts(),
App.due_resumes()andApp.tick()still read the graph's checkpointer. They
agree only as long as one of them is used: an interrupt created headlessly is
invisible over HTTP, and one answered headlessly leaves an entry behind in the
HTTP listing. Answering such a leftover entry over HTTP is a 404 rather than
the stale final state a replayed thread would otherwise hand back, so no answer
is silently dropped — but a deployment that drives approvals through both doors
will see the two listings disagree. Joining them — routing the Python API's inbox
through the Runtime too — is tracked in #120. - The v2
LangGraphEngine(not v1's endpoints, whose final state always came from
ainvoke) now reports a final state for a graph compiled without a
checkpointer, which it previously could not: the terminal state is read from the
run's own event stream instead of from a checkpoint that never existed.
Removed
agentdeck.runtime.REPO_ROOT/agentdeck.runtime.settings.REPO_ROOT— it only
ever pointed at the repo root in a source checkout and at the installed package's
site-packagesdirectory otherwise; nothing in agentdeck needs that path, and
nothing outside it should have depended on it either. (#16)agentdeck.runtime.ENV_FILE/agentdeck.runtime.settings.ENV_FILE— was a path
frozen at import time (see Fixed below for why that was itself unsafe); replaced by
resolve_env_file(), resolved fresh every timeget_settings()actually builds a
Settingsobject. (#16)
Fixed
.envandconfig.yamlnow resolve from the project's current working
directory, not from whereveragentdeckitself is installed. Previously
both were located relative toruntime/settings.py's own file path, which
is the repo root in a source checkout but lands insidesite-packagesonce
agentdeckispip installed as a dependency — so a consumer project's
.env(API keys,OPENAI_MODEL, …) was silently ignored, typically
surfacing asOPENAI_API_KEY ... must be setdespite a valid.envin the
project. If you were exporting the same values as real shell/CI
environment variables to work around this, nothing changes — a real env
var still outranks the file. But if you have a.envsitting unused next
to an installedagentdeck, it will now take effect..envis also now read
at first use rather than atimport agentdecktime, so achdirbetween
importing the package and first building settings still resolves against the
right project. (#16)- Two bundles of the same kind (two agents, or two workflows) exporting a
class of the same name used to collapse silently into one invocable, in
sorted bundle order — copyingagents/greeter/toagents/greeter-v2/to
iterate and forgetting to rename the class made the original vanish from the
registry with no error, no warning, no log line.App.load()(and anything
that discovers a project, includingInvocableRegistry) now raises
ConfigErrornaming both bundle paths and the class name they share. A
project relying on the old shadowing to hide one bundle behind another now
fails at load instead of routing requests to the wrong agent; rename one of
the classes to fix it. - A bundle whose
agent.pyorworkflow.pyraises while importing (a
SyntaxError, a missing dependency, anything at module scope) used to
surface as a raw traceback through the import machinery. It's now a
ConfigErrornaming the offending bundle path, with the original exception
chained as the cause.