v2.0.0b4
Pre-release
Pre-release
The release where v1 starts running on v2. App is now the composition root and
the chat endpoints are served by the v2 Runtime with a byte-identical wire — same
SSE frames, same JSON bodies, verified against the recorded baselines rather than
by inspection. Around that: an event log you can point at Redis or Postgres and
share between workers, a session that runs one turn at a time instead of letting
two answers overwrite each other, telemetry that covers workflows and flushes
what it buffered at shutdown, a canonical shape for structured data, and a turn
that repairs its own history after a crash between two writes. The v1 Python API
is unchanged and still runs its own path.
Added
- Redis and Postgres event logs —
AGENTDECK_EVENTS_BACKEND=redisor
=postgres, withAGENTDECK_EVENTS_URLas the Redis URL or the Postgres DSN,
puts the canonical event log somewhere several workers can share. That was
not possible before: SQLite's durability rests on cross-process shared memory,
so one events file behind more than one machine is unsupported. The store
classes areRedisEventStore(url)(agentdeck.adapters.stores.redis) and
PostgresEventStore(dsn)(agentdeck.adapters.stores.postgres) for anyone
wiring aRuntimedirectly.
Both implement the whole store port, including the two atomic claims that keep
one resume and one turn per session correct between processes and not merely
between tasks: Postgres decides and writes inside one transaction holding that
log's lock, Redis overWATCH/MULTI/EXEC. Every case in the cross-store
contract suite runs against all four backends on real servers, so the four
answer identically — oneseqper run refused a second time included.
Each keeps to its own keyspace — a Postgres schema (agentdeck_eventsby
default, overridable withschema=) and a Redis key prefix
(agentdeck:events, overridable withprefix=) — so a database or instance
shared with LangGraph checkpoints or the agent-conversation store keeps the
log separate, and either side can be dropped without touching the other.
Redis needs no new dependency; Postgres needs the[durability]extra, which
now also installspsycopg[binary](nothing else pays for it — the driver is
imported only when you select that backend). Three things to know when
operating them: a Redis instance used as the record wantsappendonly yes
(the port promises an event a consumer has seen is already stored, and the
default snapshot-only persistence can lose the last seconds of a log) and
maxmemory-policy noeviction(this is a log, not a cache — an evicted key can
cost a live run its session); and a store call that cannot reach its server
raisesStoreErrorrather than reporting a claim somebody else won. DataBlock(agentdeck.core): structured data is now content, alongside
TextBlock,ImageBlockandResourceBlock.DataBlock(data=...)carries any
JSON value, so anywhere the v2 API takes or returns content blocks —
Runtime.run(...),run.started.input,run.completed.output,
input.appended— a validatedoutput_typeresult or a workflow's state
travels as itself instead of being squeezed through text. Data that could not
survive the wire (adatetime, aset, an arbitrary object, andNaN/
±Infinity— floats with no JSON literal, which would otherwise be written as
null) is refused at construction rather than failing later, or silently
changing value, in a store or a trace. Text and data blocks are stored in
full: they are the caller's own input and the run's own declared result, and a
truncated copy cannot be replayed — only tool results stay bounded to a
preview, size and hash.
Additive for writers: no existing block, payload or field changed. Not
backward-compatible for readers, and wider than one event — content blocks are
a strict discriminated union, so a process running an older agentdeck cannot
parse an event containing adatablock at all. Because a run listing parses
each run's last lifecycle event, one structuredrun.completedin a shared
event store makes the older process'slist_runsfail for the whole tenant,
including runs it wrote itself — a listing or dashboard outage on the old half
of a fleet, and a rollback after the first structured run lands in a state the
old code cannot read. Do not run mixed agentdeck versions against one event
store across this change: upgrade every reader first, then start producing
datablocks.- The chat endpoints now run on the v2 Runtime.
POST /agents/{name}/chatand
?stream=trueare served by the same Runtime, event schema, and event log the
/v2/...routes use, with the v1 wire format rendered at the surface: the
delta/done/errorframes, the{"output", "usage"}payloads and the
404/422/500 bodies are byte-for-byte what 1.2.x sent (the golden replay suite
is unchanged, and is what enforces that). Nothing to change in a client. What
you gain is what the Runtime keeps: every turn is now recorded as a canonical
event log —run.started, text deltas, tool calls, per-model-call
usage.reported,run.completed— so a chat turn is finally as inspectable as
a/v2one. Sessions, Langfuse traces, sandboxes,max_turns, the model
provider and every other setting resolve exactly as they did before, and a
conversation is still one conversation whether the turn arrived through
App.chator over HTTP. The workflow endpoints still run on v1's workflow
runner, unchanged. Appis the composition root, and the wiring behind it is one function:
build_runtime(engines=...)(agentdeck.composition) takes the parts — the
invocable mapping, engines, event store, sinks, control port — and returns a
wiredRuntime, defaulting the mapping to discovery over./.agentdeckand
the store to your settings.App.load()calls it and exposes the result as
App.runtime, so an application that wants the canonical event stream for a
project no longer has to assemble a Runtime by hand.App.aclose()drains the
Runtime before closing Redis and MCP, so a sink registered through the seam
flushes at shutdown instead of dying with the event loop —Appregisters none
of its own yet, so today that drain is a no-op that keeps its own promise.AGENTDECK_EVENTS_BACKEND/AGENTDECK_EVENTS_URL(YAML:events:) choose
where the canonical event log goes:memory(the default — no configuration,
no files, and a log that lives and dies with the process) orsqlitewith
urlpointing at a file, for a log that survives a restart, orredis/
postgresfor one several workers can share (see the entry above). The default
never evicts and is lost on restart, so a long-lived server keeps every event it
saw and re-reads the whole conversation each turn —agentdeck-servesays so
once at startup rather than leaving you to find out.- Langfuse tracing for workflow runs, not only agent runs
(agentdeck.adapters.telemetry.langfuse).langfuse_sink()hands back an
event sink — orNonewhen Langfuse has no keys — to register where you build
the v2Runtime:Runtime(..., sinks=[s for s in (langfuse_sink(),) if s]).
Each run becomes one Langfuse trace: the run itself is the trace, tool calls
are spans carrying their arguments and their result preview, hash and size
(an inlinedata:...;base64,payload in either is described, never sent —
Langfuse would otherwise upload the bytes to its media store),
workflow node updates are points on the timeline named for the node and the
state keys it touched, and reported token usage becomes Langfuse generations
so cost lands where the UI accounts it. It reads nothing but the event
stream, so an agent run and a workflow run are traced by exactly the same
code — and a run waiting on a human is visible while it waits, its answer
continuing the same trace even when it arrives in another worker. Sessions
map to Langfuse sessions and the run's principal to its user, so a
conversation is one filter away. Configuration is theAGENTDECK_LANGFUSE_*
settings you already have; with no keys, no sink is registered, and the
Langfuse SDK is never even imported. Needs the[observability]extra. v1's
tracing is unchanged — a v1 agent run with both paths active is reported
twice. InvocableRegistry(agentdeck.runtime.discovery): the v2 Runtime's list of
what it can run is now discovered from your./.agentdeck/project instead of
written out by hand at every entry point.InvocableRegistry(engines).load()
reads the same bundles v1 always has —agents/<bundle>/agent.py,
workflows/<bundle>/workflow.py— and returns the name-to-invocable mapping
Runtimetakes, with each bundle pointed at the engine its shape belongs to.
Adding an agent or a workflow to a project no longer means editing wiring code.
An agent and a workflow claiming one name, and a project whose bundles need an
engine the Runtime wasn't given, both fail at load with a message naming the
offender, rather than at the moment somebody runs it. (Two bundles of the same
kind exporting one class name still collapse to a single invocable, as in v1.)
Skills are not discovered as invocables yet — no engine runs aSKILL.md
bundle. v1'sAppand its discovery are unchanged.ToolSourcePort(agentdeck.core.ports): tools now arrive from a source
behind one small interface —resolve(spec)hands back aToolSetof the
tools an invocable gets, the names of the ones it asked for and did not get,
and the notice to put in front of the model when something is missing. MCP is
the first source, and its behavior is unchanged: an unconfigured or
unreachable server still degrades a run instead of failing it, and an agent
whose servers are all up gets its instructions back byte-for-byte, so upstream
prompt caches keep hitting.agentdeck.SessionBusyError: the error raised when a turn is asked for on a
session that already has one running. It names the session and the run holding
it, and is anAgentdeckErrorlike every other, soexcept AgentdeckError
already covers it.EventSinkPort.close()(agentdeck.core.ports): the hook a sink that buffers
needs to get its buffer out at shutdown.Runtime.drain()now calls it once per
sink — after the sink's queued events have been handed over and its consumer
retired — so a sink whoseemitonly buffers, which is what the emit contract
pushes any sink with real work to do into, has a deterministic last chance to
ship what it holds instead of hoping the process exits cleanly enough for an
atexithook to run. Optional: it defaults to doing nothing, so existing
sinks need no change. What a sink may assume is now stated and enforced —close
is called at most once, and noemitis ever started after it, not even by a
consumer that outlived the cancellation retiring it. It is also called on a sink
that never saw an event, since a process can shut down without running anything.
One caveat worth knowing if your sink buffers: anemitthat has not finished
when the dispatch stops waiting for it still overlapsclose— whether it
swallowed the cancellation sent to end it, or simply awaits something while
unwinding (anawaitin afinallyor anexcept, such as salvaging a partial
result). Read-await-clear insideclosecan therefore drop what that emit adds
in between; guard the buffer instead. Bounded and non-fatal like every
other wait on the sink path: aclosestill running afterCLOSE_TIMEOUT(5s) is
abandoned, anything it raises is logged and flagged (SinkDispatch.close_failed),
and neither can delay a shutdown further or break it. A sink the failure breaker
already disabled is closed too — the events it buffered before it started
failing are still worth writing out, and being bad at taking events says
nothing about being able to flush the ones already taken.agentdeck.StoreError: the error a durable store raises when it cannot be
read or written.except StoreError(orexcept AgentdeckError) now covers
the SQLite event log and the SQLite control-signal database; the underlying
sqlite3exception is kept as the cause for diagnosis.
Changed
- A v2 workflow run's final state is now a
DataBlockonrun.completed
instead of a stringified Python dict, and a workflow can be started from a
state-shaped input: pass oneDataBlockwhose data is a JSON object and it
becomes the graph's initial state, whole. Plain text still fills the single
{"input": text}channel, so text-in workflows are unchanged. Anyone reading
the canonical stream (the/v2/*preview surface, a sink) gets the state as
data it can index instead of a repr it would have to parse; a state value that
is not JSON still becomes itsstr(), exactly as before, so no workflow that
completed before now fails. v1's/workflows/*endpoints and Python API are
untouched. POST /agents/{name}/chatnow answers 422 for amessageor asession_id
that is not a string.messageused to accept two more shapes — a message object
({"role": ..., "content": ...}) and a list of SDK input items — and a
non-stringsession_id(say the integer7) used to be passed through as a
session key; both now fail the request with{"detail": "message must be a string, got dict"}/{"detail": "session_id must be a string, got int"}
instead of a server error. Coercing the id instead would have quietly moved that
caller's conversation to a new session, so it is a 4xx you can see. Multi-part
input (images, resources) returns as typed content blocks in a later release; a
string in both fields is unaffected.- A project where an agent class and a workflow class share one name now fails at
App.load()with a message naming both, instead of loading two invocables that
the HTTP surface could not tell apart. Two bundles of the same kind exporting
one class name still collapse to a single invocable, as before. - The streamed
doneframe serializes a structuredoutput_typeresult the same
way the non-streamed body always has, so the two agree. Only nested values
whose JSON form differs fromstr()change: adatetimein a structured output
is now"2026-08-06T12:34:56Z"on the streamed frame, where it used to be
"2026-08-06 12:34:56+00:00". Text output — the overwhelming majority — is
byte-identical. POST /agents/{name}/chatwithout?stream=truedrives the SDK's streaming
API internally (the streamed and non-streamed endpoints are now one code path
that differs only in how it answers). Same model, same settings, same result;
worth knowing if your provider behaves differently between its streaming and
non-streaming endpoints, or gates streaming behind account verification.MemoryEventStore.appendnow yields one scheduling turn (await asyncio.sleep(0))
before returning, matching what every durable store already does (SQLite's own
to_thread). Fidelity, not correctness: a caller whose liveness secretly depended
on the in-memory store never suspending — the way the bounded sink dispatch briefly
did, before its own fix — is now exercised the same way it would be against a real
deployment, in dev and in tests, instead of only by measurement in production.- MCP now lives in
agentdeck.adapters.tools.mcp(registry, hardened HTTP
transport, agent wiring — all unchanged).from agentdeck.agents.mcp import ...,
from agentdeck.agents.mcp.lifecycle import ...andfrom agentdeck.agents import ...keep working and hand back the same objects; both paths will be
dropped in a later release. The deeper module paths
agentdeck.agents.mcp.transportandagentdeck.agents.mcp.wiringare gone —
import those names from the package instead. EventSinkPort.emitmust now return promptly: an emit that blocks longer
than the dispatch'semit_timeout(5s) is abandoned and counted as a
failure, and a sink that does it repeatedly is disabled like any other
broken sink. A sink whose work is slow buffers internally and flushes on
its own schedule.Runtime.drain()is now terminal — it closes each sink rather than
pausing it, and returns within a bounded time even against a sink whose
emitnever returns. Runs after adrain()reach no sinks.- Langfuse traces no longer depend on the SDK's exit hook to leave the process.
Runtime.drain()now closes the sink: any trace still open is finished as
interrupted by the shutdown — an unfinished observation is never shipped at all,
so a run cut short showed up nowhere before — and the SDK's batch is flushed on
the spot. A process killed after itsdrainno longer silently loses the last
seconds of telemetry. Nothing to configure; a flush that hangs or fails is
bounded and logged like any other sink work, and the event log stays the
complete record either way. - The SQLite event log and the SQLite control-signal database now open in
WAL mode with an explicit 5-second busy timeout. Readers no longer wait
behind a writer, so a second process tailing or replaying a log costs the one
writing it far less: in a saturated benchmark, read latency at the 99th
percentile and in the worst case improved by roughly an order of magnitude.
Two things to know about the files: SQLite keeps
<db>-waland<db>-shmalongside each database — back them up
and move them together, not the one file on its own — and WAL depends on
shared memory that network filesystems (NFS, SMB) do not provide reliably, so
keep these databases on local disk. In-memory databases are unaffected. - One turn per session at a time. Starting a turn on a session that already
has a run in flight now fails immediately withSessionBusyError, naming the
session and the run that holds it, instead of running the second turn against a
conversation the first one is still changing — which silently corrupted the
model's context and could lose a message from either turn. A session counts as
busy until its run reaches a terminal event, and a run waiting on a human answer
is still busy: it owns the thread its resume continues from. Sequential turns,
resumes and runs without a session are unaffected, and two different sessions
never contend. This holds across processes, because the check and the write that
opens the run are one store operation, so it is not defeated by a second worker.
What a caller should do with the refusal is retry or report it; the losing turn
is not queued (that is deliberately deferred, not forgotten). Over HTTP the v2
chat route answers 409 Conflict with the holding run named indetail,
before the event stream starts. A client that disconnects in that window — after
the turn was admitted but before the first event reached it — has its run closed
as cancelled, so the session is free for the retry rather than held. - The event log now enforces one
seqper run:(tenant, session, run, seq)
is unique in the SQLite store and refused by the in-memory one, so a write that
would put a second event at aseqa run has already used fails with
StoreErrorinstead of landing. A duplicate is the one corruption a gap check
cannot see, and it would make refetching thatseq— the whole point of
contiguousseq— return whichever copy came back first.seqis still per
run, so runs sharing a session log all count from 0 as before. Note for existing
installations: only event databases created by this version carry the
constraint, since v2 has no schema migration yet. - A run whose process was killed outright — the one exit that cannot close its own
run in the log — no longer holds its session for good. An open run that has
written nothing forAGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS(one hour by
default) stops blocking new turns; the next turn takes the session over, closes
the abandoned run asrun.failedwith error codecancelled_hard, and logs the
takeover at WARNING. Two things follow from that window, both tunable with the
same setting: a session a crashed process left claimed is refused until the
window elapses, and an approval that has been waiting on a human for longer than
the window is closed as failed when somebody starts a new turn on that session —
installations with slower approvals should raise it. Keep it comfortably above the
longest a healthy turn can go without emitting an event: shortened below that, a
turn that is merely quiet looks abandoned and the next turn takes its session,
which loses the one-turn-per-session guarantee instead of tuning it. Only
positivity is checked — the real floor depends on your workload. Running several
workers on machines whose clocks disagree shortens the window by the worst skew
between them for the same reason, so keep them on NTP and leave headroom.
Fixed
- A durable LangGraph checkpointer can now be used by more than one event loop in
one process. The sqlite and postgres savers were cached for the process
lifetime, and each holds an internal lock that binds to the first event loop to
contend for it — so a script or test that calledasyncio.run()twice against
the same durable graph failed the second time withRuntimeError: ... is bound to a different event loop, usually only once real concurrency showed up. Each
loop now gets its own saver, and a loop that asks repeatedly still shares one
connection. The in-memory saver is unchanged and still shared, so
durable = Trueon the memory backend keeps resuming acrossasyncio.run
calls as before. - An
output_typeagent run through the v2Runtimeno longer fails at its last
step. The openai-agents engine refused any non-strfinal output, which turned
a documented feature into a failed run; the validated result (pydantic model,
dataclass, or plain JSON) now arrives as aDataBlockonrun.completed.
v1'sApp.chat/run_agentnever had this problem and are unchanged. - A run whose consumer goes away is now closed in the event log even when the
consumer was cancelled rather than closed — which is what a real ASGI server
does when a client disconnects mid-stream.Runtime.runandRuntime.resume
caughtGeneratorExitandException, andCancelledErroris neither, so a
disconnected stream used to leave a run with no terminal event: indistinguishable
from one still in flight, for status projections,pending()and anything
reading the log. Both now recordrun.cancelled(shielded, so the write is not
itself cancelled) and re-raise. A process that dies with the request still leaves
the run open — no in-process write can outlive its own event loop. - Shutdown no longer hangs forever on a wedged sink: every wait on the sink
path has a deadline, including the last one — the wait for the sink's
consumer to stop. A sink whoseemitswallows cancellation can delay a
shutdown but no longer block it, and a cancellation aimed at whoever is
shutting down is no longer absorbed by the shutdown itself. That last deadline
needed a second fix to hold: a deadline fires by cancelling the task that is
waiting, and a task waiting on another task hands that cancellation straight
to it — into the same sink that had just swallowed one, spending the deadline
with nothing left to fire again. A sink that ate cancellation from inside a
still-runningemitcould therefore keep a shutdown waiting for as long as it
kept working, and no outerwait_forcould end it either. The consumer is now
waited on from the outside, so the deadline expires on time whatever the sink
does; and the consumer has a second way out that needs no cancellation at all —
once the dispatch is closed, its own loop ends. - Sink loss counters no longer under-report. Events still queued (and the one
in flight) when a sink is closed are counted as dropped, so the counters
agree with the log line that reports them; a sink that raises
CancelledErrorfrom its ownemitis counted as a failure instead of
silently killing its consumer; and a clean shutdown with an empty queue no
longer logs a spurious "queued events go undelivered" error. Nor do they
over-report: a consumer abandoned mid-emitretires at its next turn instead
of draining a backlog the shutdown had already written off, which would have
counted every event in it a second time. - A SQLite failure inside the event log or the control-signal database no longer
surfaces as a rawsqlite3exception: it is raised asStoreError, with the
original chained as its cause. This matters most when two processes answer the
same human-in-the-loop interrupt: the one that loses gets the documented
"somebody else claimed it" answer, and a store that genuinely cannot be
reached raisesStoreError— two outcomes a rawsqlite3.OperationalError: database is lockedused to blur together. - Crash recovery for conversations on the OpenAI Agents engine: a process that
died mid-turn used to leave that conversation permanently short of whatever the
event log had already recorded — the question it was killed on, or the answer it
had just given. The model then answered later turns with a hole in its context
and nothing reported a problem. Each turn now checks the log against the
engine's own conversation state and replays the messages that are missing before
the model runs, so a restarted process picks the conversation up whole.
Messages only, in content and order: tool results and model reasoning are not
reconstructed, so a conversation repaired this way carries the text of a tool
answer without the tool call behind it — worth knowing if you read model context
back. A turn a client disconnected from before the first token is never replayed,
so retrying that question does not send it twice; a turn that was answered before
the client went away keeps both its messages. Conversation state that has diverged
from the log rather than fallen behind it is left untouched and reported on the run
ascustom/openai_agents.session_diverged. LangGraph workflows are unaffected —
a checkpoint is written by the graph step itself, so there is no gap between two
writes to repair.