v4.0.0
Hardening, and it cost a major version. Thirty-seven issues, most of them findings
from people using the SDK rather than reading it. Nothing here adds a user-facing
capability; what it adds is the right to trust what was already there. Three things
had to change shape to fix the defect underneath them: a run's identity, the
run-scoped API, and the control plane. Read Upgrading before you bump.
Upgrading
- Breaking:
deck.runsis nowstart/get/list, and aRunhandle owns every op that
acts on a run already in flight (#322).deck.runs.pause/cancel/resume/answer/status/pending
are removed, not deprecated.await deck.runs.start(name, input, ...)begins a run and hands
back aRun(.id,.key,.namespace,.session_id) whose own methods replace them:
run.status(),run.pause(reason),run.resume(),run.cancel(reason),run.pending(),
run.answer(value),run.events(from_seq=0, follow=False), andawait runfor the result — a
TurnResultfor an agent, the graph's own state for a workflow.deck.runs.get(id)(optionally
namespace=) ordeck.runs.get(namespace=, key=)rehydrates a handle to a run that already
exists; it never mutates and raisesNotFoundErrorfor one this namespace has never heard of.
deck.runs.list(namespace=, status=, limit=)replaces the oldpending()inbox and stays
scoped to one namespace. Two handles on one run always agree — the durable store is the only
thing either reads from.deck.run()/deck.stream()are unchanged in behavior (still return
an interrupt as a value rather than raising);await runon aRunthat isPAUSEDor
WAITING_ANSWERinstead raises the newRunSuspendedError(aRunStateError), carrying
.pending, since there is no timeout parameter to wait either state out.context=is retained
on the handleruns.start()returns for that handle's whole life —resume()/answer()no
longer take one, and a handle fromget()always resuppliesNone.PendingRunis no longer
public (deck.runs.list(status=RunStatus.WAITING_ANSWER)replaces it);InterruptResultgains
the canonicalidalongside its existing fields.EventStorePort.locate()is removed (no
caller left onceDeck._statuswent with it) and replaced byfind_by_key(ctx, key), the read
side of the(namespace, key)claim, across all four stores. - Breaking: a run's
idis now minted, never derived from a caller-supplied value (#324).
deck.run(...)/deck.stream(...)no longer acceptrun_id=: the keyword iskey=, an
optional stable application identifier for lookup and idempotency, and it plays no part in
the run's own address any more. Every run gets a fresh, globally uniqueidregardless of
key, so two namespaces reusing one key now get two unrelated runs instead of the collision
riskrun_id=carried.(namespace, key)is a permanent claim once a run starts with it — a
seconddeck.run(..., key=...)reusing one raisesDuplicateKeyErrorrather than replaying
the run that holds it, and the pairing survives a restart. Theeventstable gains akey
column and its run-scoped uniqueness tightens from(namespace, log_key, run_id, seq)to
(namespace, run_id, seq), so one logical run can no longer be split across two log keys. An
existing SQLite events database is migrated in place on open (keycolumn added, the tightened
index rebuilt); a database with rows that genuinely violate the tighter constraint raises
StoreErrornaming the conflict instead of silently picking a survivor.list_runsgains a
limitparameter across all four stores. - Breaking:
deck.run(...)/deck.stream(...)now raisesSessionBusyErroron a session
held by a run parkedPAUSEDorWAITING_ANSWER, however long ago it went quiet (#311).
Every store'sclaim_startappliedAGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDSto any open
run, including one suspended waiting for a human — so a parked approval was silently closed
failed(destroying it) by the very next turn started on its session once the window had
passed, contradicting the README's own promise that an approval outlives the process that
asked for it. The timer now only ever applies toRUNNING; a parked run holds its session
untildeck.runs.answer/deck.runs.resumecontinues it ordeck.runs.cancelends it,
however long that takes.SessionBusyError's message reflects it too: a parked holder names
the call that frees it instead of claiming it is "in flight", which was never true of it.
If your deployment relied on a stale approval being cleaned up automatically, call
deck.runs.cancel(run_id)on it explicitly instead — see Sessions and
Memory. redisis no longer installed bypip install agentdeck-sdk(#253). A deployment with
AGENTDECK_SESSION=redis://...orAGENTDECK_EVENTS=redis://...now raisesImportErrorat
boot —Deck.__aenter__resolves both throughSessionFactory.from_settings()and
resolve_event_store()before it opens — not on first use. It was a base dependency because a
Redis-backed session (agents.extensions.memory.RedisSession) was imported unconditionally on
every agent run, whateverAGENTDECK_SESSIONwas set to. That import is now deferred to the
point aredis://URL is actually configured, and the client moves to a new[redis]extra:
pip install "agentdeck-sdk[redis]". Selecting aredis://session or event log without it
raises a clearImportErrornaming the install command, the way the durability extras already
do.
Added
- A fourth example,
examples/existing-langgraph-agent— a LangGraph graph written
without agentdeck, wrapped in four lines and gaining the event log, streaming and run
control without a change to the graph module. It documents the two things wrapping asks
for:graph=takes an uncompiledStateGraphfactory (so agentdeck can attach a
checkpointer when a workflow isdurable), and a sibling module inside a bundle is
imported relatively (from .pipeline import …). ATypedDictstate is fine; a pydantic
model is not required. AGENTDECK_RUNNER_HANDOFF_ENDS_ON_USER_TURN(#178). agentdeck collapses a handoff's
transcript into a single assistant-role message before handing it to the next agent, and some
OpenAI-compatible endpoints (Gemini's, for one) reject a request that carries no user role at
all. Setting this totrueappends a closing user turn after the collapsed transcript, via
RunConfig.handoff_history_mapper. Off by default: it changes what every model sees on every
handoff, including against OpenAI, so it stays opt-in rather than becoming everyone's new
default behavior. Wired into both places agentdeck setsnest_handoff_history— a
Runtime-driven run and a workflow node driving an agent of its own.AGENTDECK_RUNNER_HANDOFF_CLOSING_TURN(#178), defaulting to"Please continue."— the
content of the user turnAGENTDECK_RUNNER_HANDOFF_ENDS_ON_USER_TURNappends. Override it for
a deployment whose conversations aren't English: the default is otherwise an English sentence
injected into every handoff regardless of the conversation's own language. An empty (or
whitespace-only) value refuses to start rather than silently producing an empty user turn —
the shape a provider strict enough to need the setting is likely to reject too.
Changed
-
Breaking:
RunStatus.WAITING_HUMANis nowRunStatus.WAITING_ANSWER, value
waiting_answer(#295). The state pairs with the verb that leaves it, and covers a timer, a
webhook or another agent as honestly as a person —sleep_untilparks here, so a wall-clock
wait was being recorded as a human one. An ordinary API break, not a schema change: status is
derived by folding the log and is never serialised into a payload, so no golden file and no
snapshot moves.RunInterrupted.reason's"human"literal is in the schema and is
unchanged; renaming it is a separate versioned change. -
Breaking:
ControlPortgainsconsume(run_id, expected) -> bool(#295), the
compare-and-set that takes the intent a caller just ruled on and only that one. A third-party
adapter must implement it; both shipped adapters (memory,sqlite) do. It replaces
resume_runwritingRESUMEover whatever was pending — an unconditional write that could
overwrite, and silently destroy, a cancel that arrived while the run was suspended. A gate that
honors a signal now takes it too, so the port is empty afterwards rather than holding a
sentinel. -
A cancel or pause recorded against a stopped run is now read where that run is picked up
(#295). A run that has already stopped has no loop polling the gate, so the operation
continuing it — an answer, or a resume — reads the control port at its claim and rules on what
it finds. Every such read ends in an event or an explicit no-op, never in silence. -
Breaking:
EventStorePortgainslocate(run_id, ctx) -> log_key | None(#316), so finding
the log holding a run id is an indexed lookup rather than a scan of every run in the namespace
—log_keyis the session id for a run under one, so a run id alone never named its own log.
A third-party adapter must implement it; all four shipped ones (memory,sqlite,redis,
postgres) do, adding no data any of them didn't already hold: SQLite and Postgres gain an
index overevents' ownnamespace/run_idcolumns (CREATE INDEX IF NOT EXISTS, so it
applies cleanly to a database an earlier build already created), and memory/Redis keep a
derived(namespace, run_id) -> log_keymapping a replay of the log rebuilds.Deck._status
(behinddeck.runs.status) uses it now instead of walkinglist_runs. -
Breaking:
deck.run(...)/deck.stream(...)no longer stop a run when its caller stops
reading it (#325). Execution used to be consuming the event generator, so closing
stream()'s frame (or having the task reading it cancelled, as a real HTTP disconnect does)
closed the run underneath it asrun.cancelled. A run now advances in a deck-owned task from
the moment it starts, independent of whether anyone is still watching — the same task any
number of readers may observe through the store without stealing its events from one another
or advancing it, and without needing to have started it themselves. A client that disconnects
mid-stream therefore no longer stops the turn it was reading: the run keeps executing to its
own natural end (bounded to one turn, its session freed once it reaches one), and the explicit
deck.runs.cancel(run_id)is how a caller who wants that back gets it.deck.stream()'s wire
bytes are unchanged (tests/golden/proves it byte-for-byte) anddeck.run()'s propagated
exception on a failed turn is unchanged; only the disconnect-cancels-execution coupling is
gone.Deck.aclose()now settles or cancels whatever it is still executing before closing the
store, and logs which happened per run. -
agentdeck.testing.scripted_model_server'stool_name=now also accepts a sequence of
names (#248), one tool call per request in order, then plain text once the sequence is
exhausted — the shape a multi-step tool chain or a handoff round trip needs to script.
A single name keeps its existing one-shot behavior unchanged. -
Error messages a first-time user hits during composition or a first run now name the one
docs page that answers them (#238): the skill frontmatter/discoveryConfigErrors (missing
description, a name that doesn't match its directory, a duplicate name across skill roots),
SessionBusyError, the store/checkpointImportErrors for thedurabilityandredis
extras, the unknown-schemeValueErrors forAGENTDECK_CONTROL/AGENTDECK_EVENTS/
AGENTDECK_CHECKPOINT, and the durable-workflow missing-thread_idValueError(both the
direct-call and the langgraph-engine copy). No error type or field changed, only the
message text. In passing, the two durability install hints now sayagentdeck-sdk[durability]
(the actual distribution name) instead of the pre-renameagentdeck[durability].
Deprecated
Usage.usdis documented as reserved, not populated (#177). agentdeck does not price
model calls — no provider returns dollars in a response body, and a price depends on a
contract, a tier and a date rather than on the call — so the field isNoneunless a caller
sets its own cost. No behavior changes; it was alwaysNonein practice. Slated for removal
at the next major.
Removed
-
Breaking:
RunStatus.PENDINGis deleted, andstatus_of([])returnsNone(#295). It
was the fold's identity element for an empty sequence, never a state a run is in:run.started
is a run's row 0, so there is no moment between "does not exist" andRUNNINGfor it to name.
A store already answeredNonefor a run it never saw (#294);status_ofnow agrees, so
status_ofis typedRunStatus | Noneandcan_resumeacceptsNone. -
Breaking: the six run-scoped verbs move from flat
Deckmethods todeck.runs.*, and
tick/due_resumesleave the public surface entirely (#294).deck.pause,deck.cancel,
deck.resume,deck.answer,deck.statusanddeck.pendingare gone; call
deck.runs.pause(...),deck.runs.cancel(...),deck.runs.resume(...),
deck.runs.answer(...),deck.runs.status(...)anddeck.runs.pending(...)instead — same
signatures, same behavior, just grouped under the noun they act on rather than sitting flat
beside the catalog and the two verbs (run/stream) that start a turn.deck.tick()and
deck.due_resumes()— the timer sweep nothing in agentdeck calls yet — are no longer public at
all;sleep_untilkeeps working, since the underlying sweep is unchanged, just no longer
reachable from outsideDeck.
Fixed
-
A worker killed outright held its session for up to an hour (#244). Liveness was inferred
from silence, and a healthy turn can be quiet for a long time — so the staleness window had to
be generous, and one crashed process locked one user out of one conversation for
AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS(3600 by default), with no way to shorten it after
the fact. A run now holds a lease while it plays and renews it six times per TTL, so the
next turn on that session can positively assert that nobody is executing the run it found open,
instead of waiting out a timer. WithAGENTDECK_CONTROL=sqlite:///<path>a killed worker's
session is claimable within one lease TTL (90 seconds by default, set with
AGENTDECK_RUNTIME_LEASE_TTL_SECONDS). The newLeasePortreports only runs it held and
watched expire — a run it
has never seen is never reported dead — so with thememory://default, which knows nothing
about any other process, behavior is exactly as before and the staleness timer remains the only
backstop; boot warns when that is the case. Suspended runs are unaffected:PAUSEDand
WAITING_ANSWERhave no worker to be dead, so they still hold their session until resumed,
answered or cancelled. No new public API onDeckordeck.runs. Redis and Postgres lease
backends follow whenAGENTDECK_CONTROLgains those schemes. -
A cancel or pause could land on the wrong tenant's run when two namespaces shared a
caller-suppliedrun_id(#315). BothControlPortadapters (memory,sqlite) kept one
pending signal per barerun_id—acme/order-1234andglobex/order-1234shared a row, so
a cancel meant for one could land on the other, andconsume()'s compare-and-set made the two
fight over the same slot. The control plane now addresses a run by itsid, an opaque address
thatRunContext.idsupplies —Gate,Runtime.signal/resume/resume_runand both
ControlPortadapters all key by it, and no path takes a bare caller-suppliedrun_id.
Unnamespaced deployments see no change at all: an unnamespaced id is byte-identical to
today'srun_id, so stored ids, the unnamespaced CLI (agentdeck runs signal) and the frozen
v1 HTTP wire are unaffected. A caller-suppliedrun_idstarting withadr:is now refused —
that prefix marks a namespaced id, and without the reservation an unnamespaced one could be
crafted to collide with it.agentdeck runs signal
now builds aRunContextto reach that same refusal, rather than writing straight to the
ControlPort: a forgedrun_idshaped like a realencode(namespace, run_id)could otherwise
reach a live namespaced run'sGatewith no validation at all, from the one caller-facing
surface that talks to aControlPortwithout going through aRuntime.Breaking, sqlite only: the
signalstable's primary key is nowid, notrun_id. A
file with no pending signal migrates automatically in place. A file with one or more pending
signals refuses to open instead: the old schema never recorded a namespace at all, so a
pending row cannot be told apart from one that collided under the very bug being fixed here,
and carrying it forward under a guessed identity could silently re-address it to an unrelated
run. Let every in-flight run settle (or clear thesignalstable) before upgrading. -
A tool that raises is now recorded on
tool.call.completed.error(#250). The field has
been in the schema since v3.0.0 and nothing ever set it, so a database call that timed out or
an API that 500'd left no machine-readable trace anywhere: the run completed, HTTP answered
200, and the only sign of failure was whatever prose the model chose to write about it — which
a model that paraphrases past the word "error" omits entirely.compile_toolnow passes its
ownfailure_error_functionto the Agents SDK, records the exception type and message, and
the openai-agents translator moves it onto the pairedtool.call.completed, capped at
RESULT_PREVIEW_MAXlikeresult_previewbeside it.Nothing the model sees changes, deliberately: the formatter delegates to the SDK's own
default_tool_error_function, so the failure text and the agent's freedom to retry are
byte-identical to before. A tool failure is still not a run failure, the run still ends
completed, and no event kind was added. One gap, by design: a tool the author decorated with
@function_toolthemselves is passed to the engine untouched and keeps its own failure
handling, so its exceptions stay unrecorded — that is the existing trade for handing in a
pre-built SDK tool, not a new one. -
sleep_untilnow actually wakes up (#303). An openDecksweeps for its own lifetime —
started in__aenter__, cancelled in__aexit__— resuming any durable workflow parked past
its wake moment with no cron or scheduler wired in by the user. Previously_tick/_due_resumes
(the mechanism behind the sweep) were never called by anything, so a parked timer held
WAITING_ANSWERforever, keeping its session claim, until something else happened to call the
now-private_tick. The interval isAGENTDECK_RUNTIME_SWEEP_INTERVAL_SECONDS(default 30s) on
RuntimeSettings, on by default — there is no deployment for which silently never waking a timer
is the safer choice. A sweep that raises is logged and retried on the next interval rather than
ending the loop; a process that opens the deck, takes a turn and closes within one interval never
sweeps at all, and the deadline fires on whoever next holds the deck open past that. -
A cancel against a run waiting for an answer is honored instead of vanishing (#229, #295,
#311).deck.runs.cancelon a parked run returnedTrue, recorded the signal, and the run
answered on anyway: onlyresume_runpolled the control port, and an approval does not come
back that way.deck.runs.cancelagainst a suspended run now claims and terminates it right
there, recordingcontrol.requestedthenrun.cancelled— nocontrol.observed, because the
run reached no safe point; it was already stopped when the cancel landed. Ends the same way for
a paused run. Claiming happens at the cancel itself rather than being deferred to whoever
next answers or resumes: once #311 stopped a stale timer from ever reclaiming a parked run's
session, a deferred cancel could sit unread forever if nobody happened to touch the run again.
deck.runs.cancel(run_id, reason, namespace=...)takes the samenamespacedeck.runs.pending
already does, needed to locate a suspended run opened outside the default namespace at all. -
deck.runs.resumeon a run that is waiting for an answer now refuses, naming
deck.runs.answer(#295), anddeck.runs.answeron a paused run refuses naming
deck.runs.resume. Both raise the newagentdeck.errors.RunStateError, which the HTTP surface
answers as409.resumeused to return[]for a parked run — silence, to a caller holding
that run's only answer — because the lookup behind it listedPAUSEDruns only. -
A pause recorded against a run waiting for an answer now refuses the answer (#295) rather
than being silently lifted by it, and stays pending. Lifting would let an answer override an
operator who said stop; refusing costs the answerer one round trip and keeps both intents
intact. -
EventStorePort.run_statusno longer returnsPENDINGfor a run the store never heard
of (#294). It now returnsNonefor that case, distinguishing it from a run that exists but
hasn't logged a lifecycle transition yet — the two used to fold into the same value. Only the
default projection changes (no adapter overridesrun_status);RunStatus.PENDINGand
status_of()'s own contract are unchanged. -
The documentation entry path now points readers to skills, sessions, durable stores and the
API reference instead of ending at a two-link dead end (#239). The getting-started page now lists the
next concepts to read, the concepts overview names the reference as the source for exact API
details, and the how-to guides link onward to the specific reference pages behind the APIs they
use. -
pip install agentdeck-sdknow runs adurable=Trueworkflow with no extra (#232).
langgraph-checkpoint-sqlite— whatAGENTDECK_CHECKPOINT's default (sqlite://...) needs —
moves from the optional[durability]extra into base dependencies, so the default that every
human-approval workflow relies on is installable by default.[durability]now covers the
Postgres checkpointer and event store only. -
The non-streamed HTTP surface now answers every server-side failure with the documented
500{"detail": "internal error"}, not justAgentdeckErrorones (#243). A workflow
node's plain exception, an SDK error, or anhttpxtransport failure used to fall through to
Starlette's bare-textInternal Server Erroron the non-streamed chat and workflow endpoints,
while the streamed path already reported the identical failure correctly as an in-band SSE
errorevent. A catch-all handler beside the existing one closes that gap; a tool's own
exception is a separate, still-open gap (#250) — the SDK's defaultfailure_error_function
swallows it into a successful 200 before it ever reaches this handler. 404/409/422 and the
existingAgentdeckError500 are unchanged, and no exception message reaches the response
body. -
The human-approval guide now shows
answer()re-supplying a context, and says what omitting
it does (#255). Two rules meet on resume — the interrupt node re-runs from its start, and the
context is never serialized with the run — so a node readingctx.dataafter an approval gets
Nonerather than an error, and the run continues with a quietly wrong value. Both rules were
already documented separately and correctly; their interaction was stated once in prose and
never demonstrated, and two clean-room reviewers missed the consequence anyway. -
The openai-agents engine no longer refuses a
DataBlockon input (#226). It used to raise
ConfigErrorthere — aDataBlockwas an output-only block in practice, so the typed way to
hand a model structured per-run context did not exist and every embedded application invented
its own prose preamble. It now renders as its own part,json.dumps(data, ensure_ascii=False)
with nothing wrapped around it: each block is already a separate entry in the SDK's content
list, so the boundary between it and a neighbouringTextBlockis the API's own rather than a
delimiter this adapter
invents, and there is no open/close token embedded data could spoof to escape early.
ResourceBlockstill raises — auriis a pointer the engine never fetches, and the message now
says so, rather than reading identically to the data case. Crash reconciliation renders a
DataBlockthe same way on its log-side transcript, so a turn that carries one does not read as
a permanent session divergence on every turn after it. -
A langgraph workflow run now has a safe point, so
pauseandcancelcan reach it
(#128).LangGraphEnginecheckpoints the run's control gate between twoupdateschunks
(langgraph's own node boundary), which is what producescontrol.observed{safe_point: "node_boundary"}; a workflow run previously had no safe point at all, so a signal against it
sat unread until the graph finished on its own.A resumed pause continues from that boundary: it never replays. Unlike an interrupted
run, which re-enters from its start, a paused workflow's checkpoint already has everything
before the pause, sodeck.runs.resumere-enters langgraph withNone(its own idiom for
continuing a thread) rather than the run's original input, and no already-completed node
runs again. That guarantee holds fordurable=Truefrom any process; adurable=False
workflow can only be resumed from the process that paused it (its checkpoint lives in that
engine's own memory, ADR-D5), and is refused, namingdurable = True, if resumed from
another one instead of being silently replayed from the entry node with empty state. -
The docs site is swept against the whole v4.0.0 surface. Three claims were stale rather than
merely thin:definitions.mdxnamedDeck.runs.answer(), removed by #322;run-control.mdx
still called a run's identityrun_id, renamed by #324; andchoosing-a-store-backend.mdxsaid
the control port "only has to outlive the seconds between a request and that safe point", which
#244 made false by putting each run's liveness lease in the same backend. Two v4 changes had no
page at all: a reader no longer drives the run it is reading (#325), now in
runs-and-the-event-log.mdxandserve-over-http.mdx, and a raising tool's exception landing on
tool.call.completed.error(#250), now inadd-a-tool.mdxwith the@function_toolopt-out
named.known-issues.mdxretitles its fixed table to v4.0.0 and moves #244 and #178 into it,
both closed;roadmap.mdxis rewritten around the shipped v4.0.0 and the v5.0.0/v5.1.0/v5.2.0
milestones that replace v3.3/v3.4/v3.5.AGENTDECK_CONTROL's own description now says it holds
the lease port too, so the generated settings reference says it as well. -
Docs swept against nine issues closed since the last pass (#317).
known-issues.mdxgains
aFixed in v3.2.0section (#250, #229, #232, #243, #255, #253, #226);usage.usdmoves off
the page entirely, since #177 ruled it a design position rather than a defect. The one entry
that stayed open got reworded rather than removed: a tool's non-serializable return still
reaches the model as arepr()— #251 was closed by folding it into #250, but #250's fix
shipped only the raise half, so this half is untracked by any open issue today.run-control.mdx
andruns-and-the-event-log.mdxdrop their lastwaiting_human/pendingreferences, both
renamed away by #295.README.md's extras line now matchespyproject.toml(SQLite
checkpointer in base,redisits own extra) and its run-control bullet says "agent or workflow". -
tests/test_generated_reference.pynow covers all five filesgenerate_docs_reference.py
writes, not two (#317).settings.mdxandcli.mdxstay pinned byte for byte;llms.txt
joins them.changelog.mdxandllms-full.txtonly assert the generator still produces them,
rather than pinning them too: both derive fromCHANGELOG.md, which ismerge=unionso
concurrent PRs can each add an entry, and a byte pin would fail every open PR the moment any
other one merged one. The three previously untested pages could drift fromCHANGELOG.md/the
site's own pages for a whole release withmake checkgreen throughout — reported as unrelated
churn by two different agents this week when they regenerated one page and were surprised by
the other four changing too.
Added
examples/agent-with-a-skill/— an agent with two tools and one skill, the first shipped
example to include aSKILL.md. Skills were the only thingDeck.from_project()discovers with
no runnable example, so the frontmatter contract could only be learned from a build error
(#242).docs/delivery/review-v3-outsider.md— the v3.0.0 clean-room review: three reviewers given
only the wheel, the README, the docs site andexamples/, each building a small app and reporting
what broke. Source of thefinding:-labelled issues opened against v3.0.0.