Skip to content

v2.0.0b4

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 06 Aug 06:25
· 85 commits to dev since this 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 logsAGENTDECK_EVENTS_BACKEND=redis or
    =postgres, with AGENTDECK_EVENTS_URL as 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 are RedisEventStore(url) (agentdeck.adapters.stores.redis) and
    PostgresEventStore(dsn) (agentdeck.adapters.stores.postgres) for anyone
    wiring a Runtime directly.
    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 over WATCH/MULTI/EXEC. Every case in the cross-store
    contract suite runs against all four backends on real servers, so the four
    answer identically — one seq per run refused a second time included.
    Each keeps to its own keyspace — a Postgres schema (agentdeck_events by
    default, overridable with schema=) and a Redis key prefix
    (agentdeck:events, overridable with prefix=) — 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 installs psycopg[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 wants appendonly 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
    raises StoreError rather than reporting a claim somebody else won.
  • DataBlock (agentdeck.core): structured data is now content, alongside
    TextBlock, ImageBlock and ResourceBlock. 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 validated output_type result or a workflow's state
    travels as itself instead of being squeezed through text. Data that could not
    survive the wire (a datetime, a set, an arbitrary object, and NaN /
    ±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 a data block at all. Because a run listing parses
    each run's last lifecycle event, one structured run.completed in a shared
    event store makes the older process's list_runs fail 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
    data blocks.
  • The chat endpoints now run on the v2 Runtime. POST /agents/{name}/chat and
    ?stream=true are 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 / error frames, 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 /v2 one. 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.chat or over HTTP. The workflow endpoints still run on v1's workflow
    runner, unchanged.
  • App is 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
    wired Runtime, defaulting the mapping to discovery over ./.agentdeck and
    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 — App registers 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) or sqlite with
    url pointing at a file, for a log that survives a restart, or redis /
    postgres for 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-serve says 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 — or None when Langfuse has no keys — to register where you build
    the v2 Runtime: 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 inline data:...;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 the AGENTDECK_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
    Runtime takes, 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 a SKILL.md
    bundle. v1's App and its discovery are unchanged.
  • ToolSourcePort (agentdeck.core.ports): tools now arrive from a source
    behind one small interface — resolve(spec) hands back a ToolSet of 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 an AgentdeckError like every other, so except 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 whose emit only 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
    atexit hook 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 no emit is 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: an emit that has not finished
    when the dispatch stops waiting for it still overlaps close — whether it
    swallowed the cancellation sent to end it, or simply awaits something while
    unwinding (an await in a finally or an except, such as salvaging a partial
    result). Read-await-clear inside close can therefore drop what that emit adds
    in between; guard the buffer instead. Bounded and non-fatal like every
    other wait on the sink path: a close still running after CLOSE_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 (or except AgentdeckError) now covers
    the SQLite event log and the SQLite control-signal database; the underlying
    sqlite3 exception is kept as the cause for diagnosis.

Changed

  • A v2 workflow run's final state is now a DataBlock on run.completed
    instead of a stringified Python dict, and a workflow can be started from a
    state-shaped input: pass one DataBlock whose 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 its str(), exactly as before, so no workflow that
    completed before now fails. v1's /workflows/* endpoints and Python API are
    untouched.
  • POST /agents/{name}/chat now answers 422 for a message or a session_id
    that is not a string. message used to accept two more shapes — a message object
    ({"role": ..., "content": ...}) and a list of SDK input items — and a
    non-string session_id (say the integer 7) 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 done frame serializes a structured output_type result the same
    way the non-streamed body always has, so the two agree. Only nested values
    whose JSON form differs from str() change: a datetime in 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}/chat without ?stream=true drives 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.append now 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 ... and from 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.transport and agentdeck.agents.mcp.wiring are gone —
    import those names from the package instead.
  • EventSinkPort.emit must now return promptly: an emit that blocks longer
    than the dispatch's emit_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
    emit never returns. Runs after a drain() 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 its drain no 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>-wal and <db>-shm alongside 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 with SessionBusyError, 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 in detail,
    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 seq per 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 a seq a run has already used fails with
    StoreError instead of landing. A duplicate is the one corruption a gap check
    cannot see, and it would make refetching that seq — the whole point of
    contiguous seq — return whichever copy came back first. seq is 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 for AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS (one hour by
    default) stops blocking new turns; the next turn takes the session over, closes
    the abandoned run as run.failed with error code cancelled_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 called asyncio.run() twice against
    the same durable graph failed the second time with RuntimeError: ... 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 = True on the memory backend keeps resuming across asyncio.run
    calls as before.
  • An output_type agent run through the v2 Runtime no longer fails at its last
    step. The openai-agents engine refused any non-str final output, which turned
    a documented feature into a failed run; the validated result (pydantic model,
    dataclass, or plain JSON) now arrives as a DataBlock on run.completed.
    v1's App.chat / run_agent never 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.run and Runtime.resume
    caught GeneratorExit and Exception, and CancelledError is 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 record run.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 whose emit swallows 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-running emit could therefore keep a shutdown waiting for as long as it
    kept working, and no outer wait_for could 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
    CancelledError from its own emit is 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-emit retires 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 raw sqlite3 exception: it is raised as StoreError, 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 raises StoreError — two outcomes a raw sqlite3.OperationalError: database is locked used 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
    as custom / 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.