v3.0.0b1
Pre-releaseFirst 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/resumeraised on any non-Nonevalue, because nothing injects a context yet.
A constructor parameter nobody can use is a false promise, so it is gone untilContext[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 —
OpenAIAgentsEngineand
LangGraphEngine— instead of the v1 compatibility subclasses that stood in for them, and
agentdeck.v1bridgeis 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 bytests/test_run_config_parity.py. - A workflow's
durable = Truenow 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 asqlite/postgresbackend 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 throughApp.chator
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.runtimeno longer re-exportsOpenAISettings,PluginRegistry,
RunnerSettings,Settings,SkillsSettings,Workspace,get_settingsor
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/dispatchcarve-out (docs/delivery/plan-v2-cutover.md). - Breaking:
agentdeck.runtime.sessionsandagentdeck.runtime.checkpointer(forwarders
left behind whenSessionFactoryandresolve_checkpointerrelocated to their engine
adapters) are removed. ImportSessionFactoryfrom
agentdeck.adapters.engines.openai_agents.sessionsandresolve_checkpointerfrom
agentdeck.adapters.engines.langgraph.checkpointer.agentdeck.agents.mcp.lifecycle, the
equivalent forwarder forMCPLifecycle, 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_captureandcurrent_captureare
removed. Nothing ever bound the ContextVar behind them, socurrent_capture()always
answeredNone; a run's identity now reaches telemetry through the event envelope. - Breaking: the sandbox is a port.
agentdeck.runtime.workspaceand itsWorkspaceclass
are removed, replaced bySandboxPort(agentdeck.core.ports.sandbox) and the
agentdeck.adapters.caps.sandboxadapter that implements it. Open one with
async with open_sandbox(...) as sandbox:instead ofWorkspace.open(...), and reach the
ambient one withrequire_sandbox()instead ofWorkspace.require(). The port carries only
what callers actually use —read_text,write_bytes,mount_dir,exec— so
write_text,write_output,read_output,output_pathandOUTPUT_FILES_DIRare gone
(nothing in the package or its tests called them),execno longer takesshell, and
mounting a host directory now grants access to it in the same call rather than requiring a
separateextra_path_grants=.materialize()andinput_file_entries(), which took the
Agents SDK's own manifest-entry types, are replaced bymount_dir()and
input_file_targets();Workspace.open's unusedcapture,clientandclient_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_agentandApp.chatno longer return the OpenAI Agents SDK's
RunResult. Both return aTurnResult(output,usage,run_id,session_id) built
from the run's ownrun.completedevent. Updateresult.final_outputtoresult.output;
result.usageis now aUsagemodel (.input_tokens/.output_tokens/.usd), not a
dict. A validatedoutput_typeresult now arrives as plain JSON data (adict/list),
not the SDK's validated model instance. - Breaking:
App.chat_streamno longer yields raw text deltas followed by a
StreamDonesentinel. It yields the run's own canonicalEvents (text.deltaper token,
run.completedlast,run.failedin place of both if the turn raises). - Breaking:
run_agent,chat,chat_stream,run_workflowandresume_workflowno
longer take arbitrary**runner_options. Configure a run on the agent/workflow class or
through settings instead of per call. App.run_workflowandApp.resume_workflowkeep their return shapes (the final state, or
anInterruptResultwhile 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 samethread_idnow raisesSessionBusyErrorinstead of racing the first. A workflow
with nostateargument keeps defaulting to no updates.App.run_agent,App.chat,App.chat_stream,App.run_workflowand
App.resume_workflowcompose the Runtime on first use (callingload()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.control—Signal,ControlSignal,Gate,ControlSignalled,
RunCancelledError,RunPausedErrorandCONTROL_POLL_INTERVAL. OnlyControlPort, the
transport an adapter implements, stays inagentdeck.core.ports. Import from
agentdeck.core.controlinstead; nothing about how control behaves changed. InvocableSpecandToolSetnow 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 droppedtools=used to
yield an emptyToolSet, 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_runtimeregisters the Langfuse sink itself
whenAGENTDECK_LANGFUSE_PUBLIC_KEYandAGENTDECK_LANGFUSE_SECRET_KEYare 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. Passsinks=()tobuild_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), wherelog_keyis aTurnResult'ssession_id(or
run_id, for a session-less run).- Docs:
reference/settings.mdxandreference/cli.mdxare now generated from the code —
everyAGENTDECK_*(andOPENAI_*/TAVILY_*/SKILL_*) setting and theagentdeckCLI's
own--helpoutput — and verified against the code on everymake check, so the published
pages cannot drift from what the package actually does (#133).
Changed
- Every
LayeredSettingsfield inagentdeck/runtime/settings.pynow carries a
Field(description=...), the source the new generated settings reference renders from. - Breaking:
parse_eventis removed.Event.model_validate(data)does the same job —
an unfamiliarkindstill lands asUnknownEventrather than raising — so the forward-
compatibility promise is now a property of the type instead of something a reader has to
remember to call. Replaceparse_event(row)withEvent.model_validate(row). - Two
kindvalues that disagree are refused instead of silently relabelled. When the
envelope'skindwas one this version didn't know, the payload's own claim used to be
overwritten with the envelope's and buried inraw_payload, so a row was accepted under a
name it never carried. Only reachable from rows this package didn't write. Event.kindandUnknownEvent.kindnow 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_payloadandUnknownBlock.raw_block. All six weredict[str, Any], so a
NaNreached the log asnull, a set as a list and a datetime as a string — the divergence
DataBlockhas always refused. They now carry the sameJsonDatatypeDataBlockdoes.
The tworaw_*fields matter most:UnknownEventandUnknownBlockexist 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_usdandBudget.max_tokensreject negatives, and the two dollar fields also
rejectNaNand±Infinity. Those have no JSON literal, so they serialized asnull— 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
UsageorBudgetby hand with one now gets aValidationErrorat construction. No
serialized shape changed. POST /v2/invocables/{name}/chatanswers 422 to an emptysession_idinstead of
accepting it. A run's log key issession_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'sPOST /agents/{name}/chatis unchanged.- Breaking (for anyone who implemented
EventStorePort): the store now assignsseqand
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
finishedEvents and returns the events it wrote;claim_starttakes the opening
RunStartedplusoriginand returns(SessionClaim, Event | None);claim_resumetakes
theRunResumedplusoriginand returns the event it wrote orNoneinstead of a bool;
last_seqis removed, having existed only to recover a counter nothing holds any more.
SessionClaim.overriddennow carries each abandoned run's lastEventrather than its id,
andclaim_start's cutoff is astale_after: timedeltarather than astale_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_runsandrun_statusare untouched. Runtime(clock=...)andbuild_runtime(clock=...)no longer decide anything. Every event's
tsis 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=...)andbuild_runtime(clock=...)now raise aDeprecationWarningwhen
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.
Theseqwas 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, andcheck_contiguousis the loss check it is
documented to be.
Known limits
Deck.tick()andDeck.due_resumes()still resume a paused workflow through its LangGraph
checkpointer rather than the Runtime (#120), so a timer-paused run started throughrunis
resumed outside the log: its own log entry staysWAITING_HUMANuntilstale_run_after
reclaims it.
Changed
- Breaking (v3.0.0 in progress, #164): a bundle's
agent.py/workflow.pynow builds an
Agent(...)/Workflow(...)instance fromagentdeck.authoringinstead of subclassing
BaseAgent/BaseWorkflow.agentdeck.agentsandagentdeck.workflowsare removed;
LoadFileNodeandAgentNodemove toagentdeck.authoring.nodes, the capability mixins
move toagentdeck.authoring.capabilities, andweb_searchmoves to
agentdeck.authoring.web_search.Agent.mcpreplacesBaseAgent.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/Workflowand calls it directly.SkillNodeis removed along with it — a workflow
invokes a skill through its executor, not a graph node. This is the first slice of the
Deckcomposition API (#164);Appstill serves.agentdeck/projects unchanged for now
and is removed onceDeckreplaces 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.Skillsis 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 atbuild(); a name declared under two roots fails naming both
paths.build()also enforces what a permissive scan would not: aSKILL.md's frontmatter
namemust match its directory name, and it must declare a non-emptydescription— pass
validate=Falsefor the old lenient fallback.agentdeck.skills.SkillRegistry(single root,
no validation) is removed;App.skillsis now aSkillsover.agentdeck/skills. The
executable skill model —SkillExecutor,SkillOutputSchema, theskill_runtimesubprocess
package, and the sandboxedscripts/run.pycontract 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 aload_skill(name)tool that reads the full
SKILL.mdbody on demand, scoped to that agent's ownskills=[...];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.jsonfile,
reversing #78.agentdeck.mcp.MCPparses one file'smcpServersobject (the shape Claude
Code already uses) and validates every entry;Agent.mcpresolves names against it through
Deck's ownbuild(). Themcp:section ofconfig.yaml/config.default.yamland the
AGENTDECK_MCP_SERVERSenv var are removed —McpSettingsis gone, andMcpServerSettings
(the per-server shape, unchanged) moves fromagentdeck.runtime.settingstoagentdeck.mcp.
Appnow reads.mcp.jsonfrom 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
emptymcp.serversalways had.MCPLifecycle.configure/.startupno 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, andDeck.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 isNEW -> 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 anInvocableSpec, 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
— anMCP(...)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
acceptcontext=for forward compatibility but raise on a non-Nonevalue: fullContext[T]
injection is its own, larger effort (docs/delivery/plan-context-injection.md) and is not
wired into this slice.Appis unchanged and still serves.agentdeck/projects;Deck
replaces it as the documented entry point onceagentdeck serveand the CLI move onto it.Deck.asgi(): the ASGI appagentdeck serveruns, built from aDeckinstead of an
App(#164).agentdeck.serve.create_app()— the console script's entry point, and every
existing test's — is nowDeck.from_project().asgi(); the lifespan opens and closes that same
Deck(async with deck: ...) instead of building anAppof 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/.skillsdirectly 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 byrun_idis
paused on, in place ofresume_workflow(name, thread_id, value)'s five-argument shape.
Pairs withpending()— list the inbox, then answer one run by therun_idit 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 exportsAgentandWorkflowalongsideDeck(#164), so
from agentdeck import Agent, Deck, Workflowcovers the whole composition surface without
reaching intoagentdeck.authoring.
Removed
- Breaking (v3.0.0, #164):
App,agentdeck.app,agentdeck.agentsand
agentdeck.workflowsare gone, with no re-export shim.Deck(agentdeck.deck.Deck, also
exported asagentdeck.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 calledAppmigrates toDeckper the
surface change below. - Breaking (v3.0.0, #164):
Deck's Python API isrun/stream/pause/cancel/status/
resume/pending/answeronly.run_agent,chat,chat_stream,run_workflow,
run_workflow_streamandresume_workflow— v1's method names, carried ontoDeck
unchanged in the previous slice — are removed outright, andpending_interruptsis no
longer public (folded intodue_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
coversrun_agent/chat/run_workflowuniformly (passsession_id=for a conversational
or threaded turn), andstreamcoverschat_streamthe same way — including, now, what
run_workflow_streamused 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 atbuild(), instead of building
clean and failing at run time inside the SDK (#172). A bare function orlambdaused to
reach the Agents SDK unwrapped, where it only failed once a run actually started, with a
UserErrorabout "hosted tools" that named nothing a caller recognised.build()(both
Deck.build()and standaloneAgent.build()) now raisesConfigErrornaming 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 anddocs-site/content/reference/definitions.mdx/concepts/agents.mdxdocumented
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/WorkflowDeclarationsubclass, and never
instantiates anAgent/Workflow, now failsDeck.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
bareInvocableRegistry.load()that discovers its own catalog) wraps acompile_agent/
build_graph()exception in aConfigErrornaming the offendingagents/<bundle>/agent.py
orworkflows/<bundle>/workflow.py, chaining the original exception as__cause__. A
code-firstAgent/Workflowhas no bundle to name, so its build failures are unchanged. -
An agent declaring
mcp=opened withasync with deck:never actually got its MCP
servers, even when they connected successfully.Deck.build()compiles every agent
beforeDeck.__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 afterMCPLifecycle.startupconnects 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 inmcp=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/uvactually 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 bymatching onevent.payloadinstead of printing the envelope, and
Deck says so in prose —type(event)is alwaysEvent; the
discriminator is the payload's ownkind, reachable asevent.payloadorevent.kind
(#175).