Skip to content

v3.0.0b1

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 10 Aug 07:40
· 48 commits to dev since this release

First public beta of v3. Deck replaces App as the single composition root;
v1's agents/, workflows/ and app.py are gone with no re-export shim.

Removed

  • Deck(context=...) (#182): the parameter was accepted at construction and then refused —
    run/stream/resume raised on any non-None value, because nothing injects a context yet.
    A constructor parameter nobody can use is a false promise, so it is gone until Context[T]
    lands (#166), at which point it returns additively. No working code passed it.

v2.0.0 shipped with an explicit compatibility promise for v1's Python API; this entry
starts retiring it, one PR at a time (#137). First slice: App's turn-starting methods
now play on the same Runtime the HTTP surface always has, so a Python caller's turn is
recorded — every bit of it — in the same event log a running server would show, instead
of vanishing the moment the call returns.

Changed

  • The Runtime now plays every turn on the real engine adapters — OpenAIAgentsEngine and
    LangGraphEngine — instead of the v1 compatibility subclasses that stood in for them, and
    agentdeck.v1bridge is removed. What a run is configured with (model provider, CA bundle,
    temperature, turn and token caps, workflow name) is now resolved at the composition root
    and handed to the adapter, so a caller can wire a different endpoint without touching
    process state. Behavior is unchanged: the same settings resolve to the same run config,
    pinned field by field by tests/test_run_config_parity.py.
  • A workflow's durable = True now travels to the engine on its spec, and the configured
    checkpointer is built at the first durable run rather than when a Runtime is assembled —
    so naming a sqlite/postgres backend still costs a project that only chats nothing, and
    the [durability] extra stays optional.
  • Breaking: App.session_for(session_id) now returns the engine's own session for that
    id, keyed by tenant (local:<session_id>) the way every other entry point already keys it.
    One conversation is now one conversation whether the turn arrived through App.chat or
    through HTTP — and a Redis-backed deployment gets its sessions on the Runtime path, which
    it silently did not before. Conversations written under an unprefixed Redis key by an
    earlier version are not read back; start them fresh or re-key them.
  • Breaking: agentdeck.runtime no longer re-exports OpenAISettings, PluginRegistry,
    RunnerSettings, Settings, SkillsSettings, Workspace, get_settings or
    reset_settings_cache. Import each from the module that defines it, e.g.
    from agentdeck.runtime.settings import get_settings; nothing about how any of them
    behaves changed. Part of the v3 cutover's prep to put a package-wide "agentdeck.runtime
    stays adapter-free" import-linter contract on the whole package, rather than just today's
    service/dispatch carve-out (docs/delivery/plan-v2-cutover.md).
  • Breaking: agentdeck.runtime.sessions and agentdeck.runtime.checkpointer (forwarders
    left behind when SessionFactory and resolve_checkpointer relocated to their engine
    adapters) are removed. Import SessionFactory from
    agentdeck.adapters.engines.openai_agents.sessions and resolve_checkpointer from
    agentdeck.adapters.engines.langgraph.checkpointer. agentdeck.agents.mcp.lifecycle, the
    equivalent forwarder for MCPLifecycle, is removed the same way — import it from
    agentdeck.adapters.tools.mcp.lifecycle.
  • Breaking: OpenAISettings.tracing_api_key (OPENAI_TRACING_API_KEY) is removed — it
    was never read anywhere in the codebase.
  • Breaking: agentdeck.runtime.workspace.runtime_capture and current_capture are
    removed. Nothing ever bound the ContextVar behind them, so current_capture() always
    answered None; a run's identity now reaches telemetry through the event envelope.
  • Breaking: the sandbox is a port. agentdeck.runtime.workspace and its Workspace class
    are removed, replaced by SandboxPort (agentdeck.core.ports.sandbox) and the
    agentdeck.adapters.caps.sandbox adapter that implements it. Open one with
    async with open_sandbox(...) as sandbox: instead of Workspace.open(...), and reach the
    ambient one with require_sandbox() instead of Workspace.require(). The port carries only
    what callers actually use — read_text, write_bytes, mount_dir, exec — so
    write_text, write_output, read_output, output_path and OUTPUT_FILES_DIR are gone
    (nothing in the package or its tests called them), exec no longer takes shell, and
    mounting a host directory now grants access to it in the same call rather than requiring a
    separate extra_path_grants=. materialize() and input_file_entries(), which took the
    Agents SDK's own manifest-entry types, are replaced by mount_dir() and
    input_file_targets(); Workspace.open's unused capture, client and client_factory
    arguments are gone. A sandbox's environment is unchanged, including the rule that
    host-supplied trace carriers win over a caller's stale copy.
  • An agent turn no longer opens its Langfuse observation inside the engine — the sink builds
    the run's trace from its events instead, so a turn is reported once rather than twice.
  • Breaking: App.run_agent and App.chat no longer return the OpenAI Agents SDK's
    RunResult. Both return a TurnResult (output, usage, run_id, session_id) built
    from the run's own run.completed event. Update result.final_output to result.output;
    result.usage is now a Usage model (.input_tokens / .output_tokens / .usd), not a
    dict. A validated output_type result now arrives as plain JSON data (a dict/list),
    not the SDK's validated model instance.
  • Breaking: App.chat_stream no longer yields raw text deltas followed by a
    StreamDone sentinel. It yields the run's own canonical Events (text.delta per token,
    run.completed last, run.failed in place of both if the turn raises).
  • Breaking: run_agent, chat, chat_stream, run_workflow and resume_workflow no
    longer take arbitrary **runner_options. Configure a run on the agent/workflow class or
    through settings instead of per call.
  • App.run_workflow and App.resume_workflow keep their return shapes (the final state, or
    an InterruptResult while paused) but now play on the Runtime instead of driving the
    compiled graph directly — every workflow turn is recorded, and a second concurrent call on
    the same thread_id now raises SessionBusyError instead of racing the first. A workflow
    with no state argument keeps defaulting to no updates.
  • App.run_agent, App.chat, App.chat_stream, App.run_workflow and
    App.resume_workflow compose the Runtime on first use (calling load() themselves) if
    App.load() was never called by hand.
  • Breaking: run control's vocabulary and safe point moved out of the ports package to
    agentdeck.core.controlSignal, ControlSignal, Gate, ControlSignalled,
    RunCancelledError, RunPausedError and CONTROL_POLL_INTERVAL. Only ControlPort, the
    transport an adapter implements, stays in agentdeck.core.ports. Import from
    agentdeck.core.control instead; nothing about how control behaves changed.
  • InvocableSpec and ToolSet now raise on a keyword they don't have, instead of dropping it.
    Both are built in-process, so an unknown keyword is a typo — and a dropped tools= used to
    yield an empty ToolSet, degrading a run exactly like an unreachable tool source. Event and
    content payloads keep ignoring unknown fields: they are parsed off a wire, where a field a
    newer writer added has to land rather than raise.

Added

  • Langfuse now traces workflow runs. build_runtime registers the Langfuse sink itself
    when AGENTDECK_LANGFUSE_PUBLIC_KEY and AGENTDECK_LANGFUSE_SECRET_KEY are both set, so
    every run played through a Runtime — workflow as well as agent — becomes a trace built from
    the run's own events, carrying its session id, its principal as the Langfuse user, its
    nodes, its tool calls and its token usage. Workflow runs previously produced either no trace
    or an anonymous one. Nothing is registered and the Langfuse SDK is never imported without
    both keys, so the [observability] extra stays optional. Pass sinks=() to build_runtime
    to opt out.
  • App.store: the event log every recorded turn appends to. Read a turn back with
    await app.store.read(log_key, ctx), where log_key is a TurnResult's session_id (or
    run_id, for a session-less run).
  • Docs: reference/settings.mdx and reference/cli.mdx are now generated from the code —
    every AGENTDECK_* (and OPENAI_*/TAVILY_*/SKILL_*) setting and the agentdeck CLI's
    own --help output — and verified against the code on every make check, so the published
    pages cannot drift from what the package actually does (#133).

Changed

  • Every LayeredSettings field in agentdeck/runtime/settings.py now carries a
    Field(description=...), the source the new generated settings reference renders from.
  • Breaking: parse_event is removed. Event.model_validate(data) does the same job —
    an unfamiliar kind still lands as UnknownEvent rather than raising — so the forward-
    compatibility promise is now a property of the type instead of something a reader has to
    remember to call. Replace parse_event(row) with Event.model_validate(row).
  • Two kind values that disagree are refused instead of silently relabelled. When the
    envelope's kind was one this version didn't know, the payload's own claim used to be
    overwritten with the envelope's and buried in raw_payload, so a row was accepted under a
    name it never carried. Only reachable from rows this package didn't write.
  • Event.kind and UnknownEvent.kind now have to look like a kind (run.started,
    a2a.task.started); "", "Run Started" and "run..started" were accepted before. A
    shape, not a fixed set — an unfamiliar kind from a newer writer still parses.
  • Every free-form JSON field holds only what a store hands back unchanged:
    NodeUpdated.state_patch, ToolCallStarted.args, RunInterrupted.payload, Custom.data,
    UnknownEvent.raw_payload and UnknownBlock.raw_block. All six were dict[str, Any], so a
    NaN reached the log as null, a set as a list and a datetime as a string — the divergence
    DataBlock has always refused. They now carry the same JsonData type DataBlock does.
    The two raw_* fields matter most: UnknownEvent and UnknownBlock exist so this version
    survives a newer writer, which they cannot do while free to alter that writer's data on the
    way through. Every engine adapter already sanitized before constructing these, so nothing the
    package produces changes; a caller building one by hand from non-JSON values now gets a
    ValidationError.
  • The cost and budget fields validate like the token counts always did: Usage.usd,
    Budget.max_usd and Budget.max_tokens reject negatives, and the two dollar fields also
    reject NaN and ±Infinity. Those have no JSON literal, so they serialized as null — a
    consumer read no cost where the producer wrote nonsense. Nothing in the package produced
    such a value, so this closes a trap rather than fixing a live bug; a caller that built a
    Usage or Budget by hand with one now gets a ValidationError at construction. No
    serialized shape changed.
  • POST /v2/invocables/{name}/chat answers 422 to an empty session_id instead of
    accepting it. A run's log key is session_id or run_id, so "" was not an error anywhere
    downstream — it quietly gave the turn a private log, and the caller's next message found no
    history with nothing saying why. v1's POST /agents/{name}/chat is unchanged.
  • Breaking (for anyone who implemented EventStorePort): the store now assigns seq and
    ts, in the same indivisible step that persists the event, and the port went from eight
    methods to seven. append(log_key, payloads, ctx, origin) takes payload objects instead of
    finished Events and returns the events it wrote; claim_start takes the opening
    RunStarted plus origin and returns (SessionClaim, Event | None); claim_resume takes
    the RunResumed plus origin and returns the event it wrote or None instead of a bool;
    last_seq is removed, having existed only to recover a counter nothing holds any more.
    SessionClaim.overridden now carries each abandoned run's last Event rather than its id,
    and claim_start's cutoff is a stale_after: timedelta rather than a stale_before: datetime — the store owns the clock, so only it can subtract from its own now. The four
    bundled stores are unchanged in behavior; a store built outside this package needs porting.
    read, read_run, list_runs and run_status are untouched.
  • Runtime(clock=...) and build_runtime(clock=...) no longer decide anything. Every event's
    ts is assigned by the store, so a caller that wants to hold time still builds the store
    with a clock — MemoryEventStore(clock=...), RedisEventStore(clock=...) — while the SQLite
    and Postgres stores read their own backend's clock, so N workers sharing one database compare
    one clock instead of N. Both keywords are still accepted and do nothing.

Deprecated

  • Runtime(clock=...) and build_runtime(clock=...) now raise a DeprecationWarning when
    passed explicitly. They are inert (see above), and a keyword that silently ignores a frozen
    clock is how a caller ends up asserting against wall time believing it held time still.
    Removal is tracked in #158; pass the clock to the store instead.

Fixed

  • A log no longer carries a permanent gap after a dropped report or a transient append failure.
    The seq was taken before the write and stayed spent when the write failed, so a run that
    otherwise completed cleanly left a hole in its sequence — and a consumer seeing that hole
    could not tell "an event was lost in transit, refetch it" from "this gap is permanent and
    refetching will never converge". A number is now allocated and persisted together, so it
    cannot be allocated and not persisted, and check_contiguous is the loss check it is
    documented to be.

Known limits

  • Deck.tick() and Deck.due_resumes() still resume a paused workflow through its LangGraph
    checkpointer rather than the Runtime (#120), so a timer-paused run started through run is
    resumed outside the log: its own log entry stays WAITING_HUMAN until stale_run_after
    reclaims it.

Changed

  • Breaking (v3.0.0 in progress, #164): a bundle's agent.py/workflow.py now builds an
    Agent(...)/Workflow(...) instance from agentdeck.authoring instead of subclassing
    BaseAgent/BaseWorkflow. agentdeck.agents and agentdeck.workflows are removed;
    LoadFileNode and AgentNode move to agentdeck.authoring.nodes, the capability mixins
    move to agentdeck.authoring.capabilities, and web_search moves to
    agentdeck.authoring.web_search. Agent.mcp replaces BaseAgent.mcp_server_names.
    Subagent delegation (BaseAgent.subagents) and the sandboxed agent path
    (BaseSandboxAgent, SandboxAgentNode) are dropped rather than ported — sandboxing is
    disabled and tracked separately (#163); a workflow that needs a sub-run composes another
    Agent/Workflow and calls it directly. SkillNode is removed along with it — a workflow
    invokes a skill through its executor, not a graph node. This is the first slice of the
    Deck composition API (#164); App still serves .agentdeck/ projects unchanged for now
    and is removed once Deck replaces it later in the same effort.
  • Breaking (v3.0.0 in progress, #164): a skill is disclosed into an agent's own execution, not
    run as a program.
    agentdeck.skills.Skills is the new capability object — one or more root
    directories, scanned direct-child only (<root>/<name>/SKILL.md, never recursive) and merged
    into one name-keyed registry at build(); a name declared under two roots fails naming both
    paths. build() also enforces what a permissive scan would not: a SKILL.md's frontmatter
    name must match its directory name, and it must declare a non-empty description — pass
    validate=False for the old lenient fallback. agentdeck.skills.SkillRegistry (single root,
    no validation) is removed; App.skills is now a Skills over .agentdeck/skills. The
    executable skill model — SkillExecutor, SkillOutputSchema, the skill_runtime subprocess
    package, and the sandboxed scripts/run.py contract they wrapped — is removed outright rather
    than ported: nothing in the package or its tests used it, and sandboxing is disabled and
    tracked separately (#163). An activated skill now reaches an agent as an instructions-block
    (name + description per declared skill) plus a load_skill(name) tool that reads the full
    SKILL.md body on demand, scoped to that agent's own skills=[...]; SkillError (the base
    exception a workflow node may still raise itself) is unaffected.
  • Breaking (v3.0.0 in progress, #164): named MCP servers move to a .mcp.json file,
    reversing #78.
    agentdeck.mcp.MCP parses one file's mcpServers object (the shape Claude
    Code already uses) and validates every entry; Agent.mcp resolves names against it through
    Deck's own build(). The mcp: section of config.yaml/config.default.yaml and the
    AGENTDECK_MCP_SERVERS env var are removed — McpSettings is gone, and McpServerSettings
    (the per-server shape, unchanged) moves from agentdeck.runtime.settings to agentdeck.mcp.
    App now reads .mcp.json from the project root (a sibling of .agentdeck/, not inside it)
    when present, and boots with no servers when it is absent — the same fail-open behavior an
    empty mcp.servers always had. MCPLifecycle.configure/.startup no longer fall back to
    process settings; a caller now always hands them the config to use.

Added

  • agentdeck.deck.Deck, the v3 composition root (#164): Deck(agents=..., workflows=..., skills=..., mcp=..., context=...) builds and runs a catalog from Python objects with no
    .agentdeck/ project on disk, and Deck.from_project(path) discovers the same four arguments
    from today's directory layout — both end at the same constructor, so there is one catalog
    mechanism either way. Lifecycle is NEW -> build() -> BUILT -> (async with) -> OPEN -> CLOSED:
    build() validates every name a catalog references (an unknown skill, MCP server, or
    workflow-as-tool name; an agent and a workflow sharing a root name) and compiles every
    agent/workflow to an InvocableSpec, reading only local files — no network call, no MCP
    server started, and idempotent, so it doubles as a CI check. deck.agents/deck.workflows
    are read-only mappings once built; run/stream/pause/cancel/resume/status/pending
    require an opened deck (async with deck: ...), which is also what starts every configured
    MCP server and composes the Runtime. Closing tears down only what a Deck itself instantiated
    — an MCP(...) it holds, always, and an event store it built from settings — never a store
    handed in through the (private, test-only) _store= seam. Deck.run/.stream/.resume
    accept context= for forward compatibility but raise on a non-None value: full Context[T]
    injection is its own, larger effort (docs/delivery/plan-context-injection.md) and is not
    wired into this slice. App is unchanged and still serves .agentdeck/ projects; Deck
    replaces it as the documented entry point once agentdeck serve and the CLI move onto it.
  • Deck.asgi(): the ASGI app agentdeck serve runs, built from a Deck instead of an
    App (#164). agentdeck.serve.create_app() — the console script's entry point, and every
    existing test's — is now Deck.from_project().asgi(); the lifespan opens and closes that same
    Deck (async with deck: ...) instead of building an App of its own. The HTTP contract has
    not moved: every route, status code and event-stream shape is identical, which
    tests/golden/'s byte-for-byte snapshots confirm. GET /health's inventory now reads off
    Deck.agents/.workflows/.skills directly rather than a cached dict, with the same three
    keys in the same shape.
  • Deck.answer(run_id, value) (#164): answers the interrupt the run named by run_id is
    paused on, in place of resume_workflow(name, thread_id, value)'s five-argument shape.
    Pairs with pending() — list the inbox, then answer one run by the run_id it named there;
    the lookup a caller used to do by hand (which invocable, which thread, which session) now
    travels with the pending entry.
  • agentdeck.__init__ now also exports Agent and Workflow alongside Deck (#164), so
    from agentdeck import Agent, Deck, Workflow covers the whole composition surface without
    reaching into agentdeck.authoring.

Removed

  • Breaking (v3.0.0, #164): App, agentdeck.app, agentdeck.agents and
    agentdeck.workflows are gone, with no re-export shim.
    Deck (agentdeck.deck.Deck, also
    exported as agentdeck.Deck) is the one composition root now: Deck(...) in place of
    App(), Deck.from_project() in place of discovery-on-construction. agentdeck serve's
    console script and HTTP contract are unchanged from the previous slice
    (Deck.from_project().asgi()); everything else that called App migrates to Deck per the
    surface change below.
  • Breaking (v3.0.0, #164): Deck's Python API is run/stream/pause/cancel/status/
    resume/pending/answer only.
    run_agent, chat, chat_stream, run_workflow,
    run_workflow_stream and resume_workflow — v1's method names, carried onto Deck
    unchanged in the previous slice — are removed outright, and pending_interrupts is no
    longer public (folded into due_resumes, which stays). None of the six were ever called by
    agentdeck.serve — it always drove the Runtime directly — so the HTTP surface and
    tests/golden/'s byte-for-byte wire are unaffected; only the Python API changes. run
    covers run_agent/chat/run_workflow uniformly (pass session_id= for a conversational
    or threaded turn), and stream covers chat_stream the same way — including, now, what
    run_workflow_stream used to do outside the Runtime: a workflow's stream is canonical
    Events, not the old dict shape, whichever method starts it.

Fixed

  • Agent(tools=[...]) now rejects a tool it cannot compile at build(), instead of building
    clean and failing at run time inside the SDK (#172).
    A bare function or lambda used to
    reach the Agents SDK unwrapped, where it only failed once a run actually started, with a
    UserError about "hosted tools" that named nothing a caller recognised. build() (both
    Deck.build() and standalone Agent.build()) now raises ConfigError naming the agent and
    the offending tool, pointing at @function_tool — structurally, by checking the tool is one
    of the SDK's own tool types, so the check still constructs no engine and touches no network.

  • Every tools= example in the docs now shows the real contract (#179). Deck's module
    docstring and docs-site/content/reference/definitions.mdx / concepts/agents.mdx documented
    tools=[find_slots, book_slot] with plain callables — a form that has never run, and now fails
    build() per the fix above instead of the SDK at run time. They now show a tool built with
    @function_tool (from agents import function_tool), the only place agentdeck asks you to
    reach for the SDK directly; Agent's own docstring gains the same statement of the contract.

  • A bundle that defines only an AgentDeclaration/WorkflowDeclaration subclass, and never
    instantiates an Agent/Workflow, now fails Deck.from_project() loudly (#174) instead of
    contributing nothing with no error or warning.
    v1 scanned for a subclass — a bare
    class Ghost(AgentDeclaration): ... was the agent — so this is the natural shape of an
    existing bundle ported to v3, which scans for instances instead. The error names the bundle
    file and what to add (e.g. greeter = Agent(...)). A bundle directory that legitimately
    holds shared code and no invocable of its own opts out the same way it already could for the
    import/collision checks: give it a leading _/..

  • A discovered agent's or workflow's compile failure now names its bundle path (#119,
    following up #82/#117, which wrapped only import failures). Deck.from_project() (and any
    bare InvocableRegistry.load() that discovers its own catalog) wraps a compile_agent/
    build_graph() exception in a ConfigError naming the offending agents/<bundle>/agent.py
    or workflows/<bundle>/workflow.py, chaining the original exception as __cause__. A
    code-first Agent/Workflow has no bundle to name, so its build failures are unchanged.

  • An agent declaring mcp= opened with async with deck: never actually got its MCP
    servers, even when they connected successfully.
    Deck.build() compiles every agent
    before Deck.__aenter__ connects anything, so the compiled agent's tools and its
    strict-protocol banner were fixed at build time — permanently "unavailable" — regardless
    of what connected later. Deck.__aenter__ now refreshes MCP status on every already-
    compiled agent right after MCPLifecycle.startup connects the real servers, so the agent
    that actually runs turns carries the servers it is, in fact, connected to. Deck.build()
    also registers the MCP server specs up front (MCPLifecycle.configure, still network-free),
    so a name declared in mcp= no longer logs a false "not found in config" warning at build
    time for a server that will, in fact, connect once the deck opens.

Added

  • agentdeck.__version__ (#176): the installed distribution's version
    (importlib.metadata.version("agentdeck")), so it can never drift from what
    pip/uv actually installed. Falls back to "0+unknown" rather than raising when the
    package has no installed distribution to read (e.g. a bare source checkout).
  • Docs: deck.stream()'s worked example (Agents) now discriminates
    events by matching on event.payload instead of printing the envelope, and
    Deck says so in prose — type(event) is always Event; the
    discriminator is the payload's own kind, reachable as event.payload or event.kind
    (#175).