Status: in progress. Parent design: long-running-agents.md. Direction: the owner review of #1132 and its follow-up comments (turn replay, Step mode).
The adapters run in Step mode. Every model call is one Dex Step, and every
tool call is its own Step, READ tools included. The tool calls of one model
response run as parallel Steps, and a ResumeTurn Step waits for their results
before the next model call. Each call therefore has its own retry policy,
timeout, failure route, history entry, and Dex Web node. A crash repeats at most
the one call that was running.
Dex adds no new primitive for this. There is no Step Journal, and no framework loop runs several model calls or inline tools inside one Step. Every guarantee rests on ordinary Steps, Attributes, Channels, and Timers.
| Package | Language | Framework |
|---|---|---|
agents/python/core (dex-agents, import dex_agents) |
Python | Shared session Flow |
agents/python/openai-agents (dex-agents-openai-agents, import dex_agents_openai_agents) |
Python | OpenAI Agents SDK |
agents/python/pydantic-ai (dex-agents-pydantic-ai, import dex_agents_pydantic_ai) |
Python | Pydantic AI |
agents/python/langgraph (dex-agents-langgraph, import dex_agents_langgraph) |
Python | LangChain create_agent and LangGraph |
agents/python/google-adk (dex-agents-google-adk, import dex_agents_google_adk) |
Python | Google ADK |
agents/python/claude-agent-sdk (dex-agents-claude-agent-sdk, import dex_agents_claude_agent_sdk) |
Python | Claude Agent SDK |
agents/typescript/core (@superdurable/dex-agents) |
TypeScript | Shared session Flow |
agents/typescript/vercel-ai (@superdurable/dex-agents-vercel-ai) |
TypeScript | Vercel AI SDK |
agents/typescript/openai-agents (@superdurable/dex-agents-openai-agents) |
TypeScript | OpenAI Agents SDK |
agents/typescript/mastra (@superdurable/dex-agents-mastra) |
TypeScript | Mastra |
agents/typescript/claude-agent-sdk (@superdurable/dex-agents-claude-agent-sdk) |
TypeScript | Claude Agent SDK |
agents/typescript/pi (@superdurable/dex-agents-pi) |
TypeScript | Pi: pi-ai models and Pi tools on Stepwise mode |
Each package has its own manifest and lockfile, so adapters pin framework
versions independently and never share a lock. An adapter depends on its
language core through a path dependency ({ path = "../core", editable = true }
for uv, "file:../core" for npm). Every package depends on the released Dex SDK
from the public registry, never on sdk-python/ or sdk-typescript/ by path:
Python dex-python-sdk==1.30.0, TypeScript the latest released
@superdurable/dex on npm.
Source files use the Sustainable Use License header (new), the same as
sdk-python/dex. agents is in included_prefixes and forced_new_prefixes
in script/licenseheaders/policy.json.
One Flow execution is one agent session. The Flow ID is the session ID. Each adapter registers the shared session Flow with its own framework runner. An application can register several agents; each gets its own Flow type.
flow = AgentSessionFlow(PydanticAiRunner(agent), flow_type="TriageAgent")
registry = Registry([flow], allow_async_handlers=True)A turn starts when the session consumes a user message and ends when the model produces a final answer. Within the turn, the state of the agent loop is the message list plus the framework's own run state. A runner loads that state, lets the framework make exactly one model call, and stops it before it runs any tool. If the response asks for tools, the runner returns the framework's serialized run state and the tool calls. The session stores both, runs each tool call in its own Step, and calls the runner again with the final tool results:
RunModel -> tool calls requested -> CallTool, CallTool, AwaitToolApproval -> ... (parallel)
-> ResumeTurn (waits for every result) -> RunModel -> ... -> final answer
A turn with two model calls and one READ tool produces
RunModel -> CallTool -> ResumeTurn -> RunModel. Nothing re-runs the loop, so
application code follows no determinism rule. A code change takes effect at the
next Step. The framework keeps its own features, such as handoffs, guardrails,
and structured output, because it still makes each model call itself.
Turn replay is a documented fallback for a framework that cannot stop after
one model call or cannot accept external tool results. It re-runs the framework
loop from the start of the turn in every model Step, returns recorded results
for calls already made (keyed model#<n>, tool:<toolCallId>,
approval:<toolCallId>), and parks at the first unrecorded call. No adapter in
this plan needs it, and the cores do not implement it.
| Framework | Mode | Stop after one model call | Run state | Feed tool results back |
|---|---|---|---|---|
| Pydantic AI | Step mode | Deferred tools (ExternalToolset or CallDeferred, with DeferredToolRequests in output_type) end the run right after the model call |
Message history as ModelMessagesTypeAdapter JSON plus the pending requests |
agent.run(..., message_history=..., deferred_tool_results=DeferredToolResults(calls={tool_call_id: value})) |
| Vercel AI SDK | Step mode | Tools without execute return their calls from generateText; stopWhen: isStepCount(1) (stepCountIs before v7) |
The plain-JSON ModelMessage[] |
Append a tool message with tool-result parts and call generateText again |
LangGraph / LangChain create_agent |
Step mode | An interrupt for every tool call: HumanInTheLoopMiddleware, or wrap_tool_call middleware that calls interrupt() |
Graph checkpoint, from a Dex BaseCheckpointSaver kept in FrameworkRunState |
Command(resume=...) whose decision supplies the recorded result |
| Google ADK | Step mode | A confirmation for every tool call: FunctionTool(require_confirmation=True) or tool_context.request_confirmation() |
Session events with ResumabilityConfig(is_resumable=True) |
The confirmation response, sent with the original invocation_id, carries the recorded result; the prototype confirms the details |
| Mastra | Step mode | Client tools: the agent stops with their calls instead of running them | Agent messages | Send the tool results back as client tool results and call the agent again |
| OpenAI Agents SDK | Step mode | A needs_approval gate on every function tool; the run stops with result.interruptions after one model call |
RunState.to_json() / RunState.from_json(), which also holds the current agent after a handoff |
Approve each interruption whose record is final, and the tool wrapper's on_invoke_tool returns the Dex result instead of running the tool; reject with the rejection message |
| Claude Agent SDK | Whole-turn harness | No clean stop: the CLI runs the turn's model calls and tools itself | Session transcript through SessionStore, resumed by session ID |
No injection; tools run inside the harness |
Notes:
- OpenAI Agents SDK handoffs are not function tools. The SDK runs a handoff
itself and continues with the next agent's model call, so a
RunModelthat hands off makes more than one model call. The adapter documents this. - The whole-turn harness runs one turn inside one
RunModelStep. Its tools run in the harness and are at-least-once: aRunModelretry repeats them. - Each adapter documents every tool kind it cannot defer and treats it as at-least-once.
| Effect | Step | Retry | Crash mid-call | Default approval |
|---|---|---|---|---|
read |
CallTool |
Up to 3 attempts; then failed |
Runs again | No |
idempotent_write |
CallTool |
Up to 3 attempts, key <flowId>/<turnId>/<toolCallId>; then outcome_unknown |
Runs again with the same key | No |
non_idempotent_write |
CallSingleAttemptTool |
One attempt; then outcome_unknown |
Outcome unknown | Yes |
unclassified |
CallSingleAttemptTool |
One attempt; then outcome_unknown |
Outcome unknown | Yes |
ToolPolicy(effect, requires_approval) overrides the defaults per tool name;
an unset requires_approval uses the effect's default. A session-level
default_tool_effect (default unclassified) applies to tools without a policy
or framework metadata. Adapters read framework metadata where it exists, such as
MCP readOnlyHint (read) and idempotentHint (idempotent_write)
annotations. No tool runs inside RunModel unless the framework cannot defer
it.
The idempotency key passed to execute_deferred_tool_call is
<flowId>/<turnId>/<toolCallId>, the same on every attempt. Tool call IDs must
be non-empty, contain no /, and be unique within one turn; they may repeat
across turns.
The table promises that a CallSingleAttemptTool call runs at most once and
records outcome_unknown when it may have run. Two cases break that promise.
Both are documented for operators in the
deployment guide. Dex
Server's attempt start and the recovery error's attempt_progress narrowed
both after M2.
- A single-attempt call runs twice only in narrow cases. Dex starts an attempt when it
hands the attempt to a Worker. A Worker that dies, or loses its stream,
after it received the tool Step fails the attempt, and the call records
outcome_unknown. For a SYNC Step, Dex hands the same attempt number out again only when it treats the attempt as never run, such as a stream that half-closed before any output. The tool Step heartbeats before it calls the tool, so a started tool is never returned that way. Two narrow cases remain. An ASYNC Step can run again at the same attempt number when a Dex Server node crashes before the attempt start is durable; the tool Steps use SYNC durability, so this case does not apply to them. Cancellation is cooperative, so a cancelled run may keep running while the failure route starts. - A call that never ran can still record
outcome_unknown. A tool Step that no Worker accepts waits in the queue without consuming an attempt, so it does not fail for lack of a Worker. When a tool Step does fail,RecordToolFailureandRecordToolOutcomeUnknownread the recovery error'sattempt_progress, the furthest progress across the Step's attempts.NOT_STARTEDmeans no attempt reached a Worker, so the tool never ran: the call recordsfailedwith anerrorofTOOL_CALL_NOT_RUN_ERROR_PREFIX(toolCallNotRunErrorPrefix,ToolCallNotRunErrorPrefix),the tool did not run, so it is safe to call again, then": "and the recovery detail. The model sees an ordinary failure and may call the tool again, and the set of final statuses does not grow. This applies only to a record that is stillapprovedand has noinput_answers: a poll or a cancellation follows a tool that already started a task, and a call whose tool asked a question already ran it.NOT_COMPLETED, andUNSPECIFIEDfrom a server that reported no progress, keep the status of the call's effect.NOT_COMPLETEDis conservative, so a call can still recordoutcome_unknownalthough its tool never ran, for example when a Dex Server node crashed after the attempt start was durable but before the task reached the Worker.
Parent design: C4, MCP as a tool source. Both cores ship an MCP client as an
optional part: dex_agents.mcp behind the dex-agents[mcp] extra, and the
@superdurable/dex-agents/mcp subpath with @modelcontextprotocol/sdk as an
optional peer dependency. Neither core's main entry point imports an MCP SDK.
McpToolSourcetakes stdio and Streamable HTTP server configurations from the Worker, never from Flow input, a user message, or a browser. It lists and caches each server's tools on their own as agent tool definitions named<server>__<tool>; a server that cannot be listed, and a tool whose name collides with an earlier one, are left out with a warning.deferred_tool_callresolves each call's policy with the annotation effect asframework_effect:readOnlyHintisread,idempotentHintisidempotent_writewhateverdestructiveHintsays, and an explicitToolPolicywins. Annotations are trusted by default.deferred_tool_callstores the server and the tool's MCP name in the newDeferredToolCall.tool_source_reference, which the session keeps on theAgentToolCallrecord (tool_source_reference), so the tool Step calls that server without a listing.- Calls run only in tool Steps, through
execute_deferred_tool_call. Every request opens a new connection, with the handshake bounded at 30 seconds by default, so a server restart between listing and calling is harmless.tools/callcarries the idempotency key in_metaunderio.superdurable.dex/idempotencyKey. The TypeScript source ends each HTTP protocol session with aDELETE. - An
isErrorresult, a JSON-RPC error other than-32603, and a call that was never sent raiseToolExecutionError. A request sent without a result back raisesMcpToolCallInterruptedError, a Step failure, so the effect class decides a re-issue:CallToolsends a new request forreadandidempotent_write, andCallSingleAttemptToolrecordsoutcome_unknown. The MCP SDKs also report an HTTP error status, and Python an unreadable body, as-32603and-32700; a gateway can send those after the server ran the call, so they count as no result. - A task handle from
io.modelcontextprotocol/tasks(Python) or the SDK's 2025-11-25 tasks (TypeScript) of a Streamable HTTP server is first saved as the attempt's heartbeat checkpoint: the newDeferredToolExecution.save_heartbeat_checkpointrecords it, every later heartbeat of the attempt repeats it, and a retried attempt reads it asheartbeat_checkpointand returns the same task without calling again. Once that checkpoint reaches Dex, a Worker kill aftertools/callcreates no second task. Dex may drop a heartbeat checkpoint (see Known limits), so an attempt that dies before its checkpoint is saved and before the tool Step commits can sendtools/callagain on aCallToolretry; the idempotency key in_metalets the server drop the duplicate. Each source reads only a checkpoint it wrote, so another writer's checkpoint reads as none. - A source called without
DeferredToolExecution.supports_tool_tasks, for a runner withoutpoll_tool_task, polls every task inside the tool Step, ascall_tooldoes, instead of starting one nobody polls. - A task that any connection can poll becomes
ToolTaskStarted(seePollToolTask): a Python Streamable HTTP server's task, and a TypeScript one when the server keeps no protocol session.poll_tool_tasksends onetasks/getper poll and writes anmcp.task_statusevent only when the status changed. The next interval is the server's poll interval, rounded up to whole seconds, or, when that is shorter, double the previous interval, up to a minute, to bound a long task's Steps. TypeScript requests a task TTL oftaskTtlMs, 24 hours by default, because the poll spans Worker restarts; Python lets the server choose. A task the server no longer knows, or that three poll attempts cannot reach, goes to the call's failure route by effect.cancel_tool_task(cancelToolTask) sendstasks/cancelfor a task thatcancel_turnabandoned. - A task that only its own connection can poll is polled with
tasks/getinside the tool Step, with a heartbeat per poll, for a bounded wait per attempt (10 minutes by default): a stdio server's task, which dies with its process, and in TypeScript a stateful server's task, which the SDK's task store binds to the protocol session that the request ends. TypeScript requests its TTL as four times the wait. A retried attempt of a session-bound task cannot see it and calls the tool again. - In Python, a task in
input_requiredthat asks one form elicitation with a single string,enum, or boolean field becomesToolInputRequestedfrompoll_tool_task. Its continuation names the request: its key and a SHA-256 fingerprint of the request. The first poll after the question is resolved sends the resolution withtasks/update, only on its first attempt, after a heartbeat, and only while the task still lists that same request: anacceptinput response for an answer,declinefor a declined question, andcancelfor a passed deadline. A different later request under the same key never gets an old answer. A retried attempt cannot tell whether the first one sent it, and the task may since have asked the identical request again, so it returns the question again and the user answers anew. After a send, the poll returnsToolTaskPendingwith the continuation plus the Worker clock's send time while the task still lists the request, and polls a second apart read that request as working for 10 seconds after the send. A poll that sees the task leaveinput_required, or the request change, returns no continuation, so an identical later request, such as one per batch, is asked as a new question; so is one still listed after those 10 seconds. Whentasks/updatefails for a decline, or the task is cancelled after one, the source cancels it best-effort and fails the poll, so the call is recorded by effect. Any other input request, any TypeScript task input request (2025-11-25 input requests must be answered on a live connection), and aninput_requiredanswer totools/callitself recordfailedwith text naming the requested input, and a waiting task is cancelled. - The Python SDK (
mcp2.3) speaks 2026-07-28; the source adds the tasks extension, which the SDK does not implement. The TypeScript SDK 1.x speaks up to 2025-11-25, so the TypeScript source uses the initialize handshake per request and the SDK's 2025-11-25 tasks fortaskSupport: requiredtools.
Follow-ups:
- Question interrupts outside tasks. An
input_requiredanswer totools/callraises a session question interrupt per input request instead of failing. The answer RPC resumes the call, and the tool Step retriestools/callwithinputResponsesand the echoedrequestState. - MCP 2026-07-28 in TypeScript when an
@modelcontextprotocol/sdkrelease speaks it, which also lets TypeScript task input requests become questions.
Framework adapters use the source when they accept tools defined by a JSON
Schema: each MCP tool becomes a framework tool that calls call_tool with the
idempotency key from the adapter's tool context, and
merge_tool_policies(application_policies) feeds the adapter's
tool_policies. The Claude Agent SDK harness runs read tools
inline, so only its write calls become Steps.
Names here are wire contracts shared by Python and TypeScript, so a UI or Dex Web view can read any agent session. JSON field names are snake_case in both languages.
| Step type | Input | Kind | Next |
|---|---|---|---|
StartAgentSession |
AgentSessionStartInput |
Start Step. Initializes AgentState (next_message_sequence 1), AgentUsage, and an empty PendingInterrupts, and stores non-empty session_settings in SessionSettings; settings it refuses force-fail the session with a reason starting dex-agents: the start input's session_settings are not valid session settings. With a non-blank initial_message, appends it as the turn's user message. |
RunModel when initial_message is set and not blank, else AwaitUserMessage |
AwaitUserMessage |
none | WaitFor any of TurnControlRequests.for_range(at_least=1, at_most=MAX), QueuedUserMessages.for_one(), and, while AgentPlan is draft or active, PlanExecutions.for_one(<revision>), in that order; Dex selects Channel conditions in argument order, so a steer beats a queued message, which beats a plan execution. Execute locks TurnCancellationRequest, so no turn starts while cancel_turn decides. A control wake: Execute moves to ApplyTurnControl. A message: Execute appends it and starts a turn whose interaction_mode is planning when plan mode is enabled and the message has plan_mode or a draft plan exists, else chat. An approval: Execute deletes PendingPlanExecution; unless the plan still has that revision and is draft or active, it waits again; otherwise it marks the plan active, appends [Dex] The user approved plan revision <n>. Execute it and keep the task statuses current with write_todos. through build_user_message, and starts an executing turn, writing turn_started then plan_execution_started. |
ApplyTurnControl on a control wake, else RunModel, or itself |
RunModel |
none | Async Execute. Before the model call, when the turn is steered or cancelled, it ends the turn in this commit, as ApplyTurnControl does, and moves to ApplyTurnControl without a model call, which starts the steered turn. cancel_turn cancels a running RunModel: Python cancels the coroutine, TypeScript aborts cancellationSignal, and the model call's result is dropped. Otherwise, with AgentSessionOptions.compaction, it asks the runner for estimate_input_tokens; at trigger_ratio of the window it asks for select_compaction_cutoff, and a cutoff after the current ContextSummary makes it set status compacting_context and move to CompactContext with no model call. These are its only decision points, in that order. Otherwise it calls run_model_step for one model call. Options: method timeout 15 min, heartbeat timeout 2 min, retry 3 attempts within 30 min, failure route RecordTurnFailure. |
Compaction: CompactContext({through_sequence}). TurnCompleted: appends new_messages, clears the turn, AwaitUserMessage; with turn_error set, it also sets last_turn_error and writes turn_failed instead of turn_completed, with no retry. TurnContinued: appends new_messages, which may end with a user message, stores FrameworkRunState, records usage, keeps status running_model, and moves to RunModel again in the same turn; a runner that returns messages without build_unfinished_turn_messages fails the turn as for ToolCallsRequested. ToolCallsRequested: appends new_messages, stores FrameworkRunState, one AgentToolCalls record per call, and the interrupts, then go_to_many with one Step per call plus ResumeTurn(<call count>). A runner that sets new_messages without implementing build_unfinished_turn_messages fails the turn in this commit, with last_turn_error a runner that sets ToolCallsRequested.new_messages must implement build_unfinished_turn_messages (TypeScript names newMessages and buildUnfinishedTurnMessages) and no retry. It records the model call's usage first. An ask_user call is recorded awaiting_answer with a question interrupt and goes to AwaitQuestionAnswer; a durable_wait call is recorded waiting_for_timer with wait_until, writes timer_started, and goes to AwaitTimer; a write_todos call replaces AgentPlan, writes plan_updated, and records succeeded; an invalid built-in call records failed; in a planning turn every call but write_todos records rejected with The tool call was not run: a planning turn can only call write_todos.. A call that becomes final here publishes its result in this commit, and ResumeTurn still counts it |
CompactContext |
CompactContextInput |
Async Execute with RunModel's options and failure route. Loads only the messages it summarizes. Writes compaction_started, calls summarize_context once with the previous summary and the messages after it through through_sequence, then in one commit writes ContextSummary (generation + 1, first_sequence 1, last_sequence through_sequence), records the call's usage, sets status running_model, and writes compaction_finished. A crash reruns only the summary call. A blank summary fails the attempt. |
RunModel |
AwaitToolApproval |
tool call ID | WaitFor any of ToolCallCancellations.for_one(id), InterruptResolutions.for_one(id), and, while the interrupt is in PendingInterrupts with a deadline, a Timer at that deadline, rounded up to whole seconds because Dex Timers take whole seconds. Execute locks PendingInterrupts. cancel_turn cancels it, and FinishTurnCancellation records its call. |
Approved: records approved, then CallTool or CallSingleAttemptTool (id). Rejected or deadline passed: records rejected, publishes ToolCallResults, ends (dead end). Timer fired after resolve_interrupt already removed the interrupt, or before the deadline: itself (id), which waits again. A cancellation wake: see the steering rules below |
AwaitQuestionAnswer |
tool call ID | WaitFor any of ToolCallCancellations.for_one(id), InterruptResolutions.for_one(interrupt_id), and, while the interrupt is pending with a deadline, a Timer at that deadline; while a tool's question is neither pending nor answered, a zero Timer instead. Execute locks PendingInterrupts and raises such a tool question from the record (interrupt_raised), then waits again; in a steered or cancelled turn it records the call cancelled instead and asks nothing, unless the record has task_id; in a cancelled turn a record with task_id moves to FinishTurnCancellation instead. cancel_turn cancels a waiting AwaitQuestionAnswer, and FinishTurnCancellation records its call. When the tool question's ID <tool_call_id>-input-<n> is already the ID of a pending interrupt of another call, or of another call's record, it records the asking call failed with the tool's question cannot use interrupt ID <interrupt_id>, which another tool call of this turn uses and raises nothing. Answered ask_user: records succeeded with {"status":"answered","answer":"<text>"}. Answered tool question: appends a QuestionAnswer to input_answers and records approved. A question that a started task asked (the record has task_id) never ends the call here: an answer, a decline, and a passed deadline each append a QuestionAnswer with resolution answered, declined, or deadline_passed (the answer, the decline text, or question deadline passed in answer), set task_answer_pending, record waiting_for_task, and move to PollToolTask, which hands the resolution to the task; such a call whose question ID collides is recorded by effect instead of failed. Declined ask_user or tool question: rejected with the message or The user declined to answer.. Deadline: rejected with question deadline passed. |
Ends, back to the call's tool Step or PollToolTask, or itself after an early Timer |
AwaitTimer |
tool call ID | cancel_turn cancels it, and FinishTurnCancellation records its call. WaitFor any of a Timer for the remaining whole seconds until wait_until and ToolCallCancellations.for_one(id); Dex selects Timers before Channels, so a wait that ends as a steer arrives completes normally. Execute records succeeded with {"status":"completed","waited_seconds":<n>,"reason":"<reason>"} and publishes ToolCallResults. The Timer is the authority, so SkipTimer ends the wait. |
Ends |
CallTool |
tool call ID | Execute one read or idempotent_write call. Options: retry 3 attempts, failure route RecordToolFailure. Before tool_call_started, when the turn is steered or cancelled, records the call cancelled and publishes its result without running it. Records succeeded, or failed for ToolExecutionError, and publishes ToolCallResults. A ToolInputRequested result records awaiting_answer with input_continuation, input_question, input_choices, and input_allows_free_text, and moves to AwaitQuestionAnswer, which raises the question interrupt <tool_call_id>-input-<n> under the PendingInterrupts lock. The tool Step itself never writes PendingInterrupts, so parallel tool Steps cannot lose an update. A ToolTaskStarted result records waiting_for_task with task_id and task_poll_interval_seconds, clears input_continuation, writes tool_task_started, and moves to PollToolTask. When the runner does not implement poll_tool_task (pollToolTask), or task_id is blank or the interval is not a number, it records failed for a read call and outcome_unknown otherwise, with that error, because the task may run. |
Ends, AwaitQuestionAnswer, or PollToolTask |
CallSingleAttemptTool |
tool call ID | Execute one non_idempotent_write or unclassified call. Options: retry 1 attempt, failure route RecordToolOutcomeUnknown. Same check before tool_call_started, and the same recording. |
Ends, AwaitQuestionAnswer, or PollToolTask |
PollToolTask |
tool call ID | WaitFor a Timer for task_poll_interval_seconds, or a zero Timer while task_answer_pending is set, so a resolved question reaches the task at once. Execute calls poll_tool_task(ToolTaskPoll) once, with pending_answer set to the last input_answers entry while task_answer_pending is set, and an execute_tool span that records the outcome. ToolExecutionResult records succeeded and ToolExecutionError records failed, each publishing ToolCallResults; ToolTaskPending(poll_interval_seconds, continuation) stores the interval, rounded up to whole seconds from 1 to a day, and continuation as input_continuation, where None clears it; ToolInputRequested records awaiting_answer as CallTool does. The poll's commit clears task_answer_pending. The poll with pending_answer reads the question's continuation, and each later poll reads what the last ToolTaskPending returned, so the runner decides how long it recognizes the request it answered. Options: retry 3 attempts; failure route RecordToolFailure for read and idempotent_write calls and RecordToolOutcomeUnknown otherwise. It takes no lock and waits on no cancellation, so steering never abandons a started task. cancel_turn cancels it, including a running poll, whose result is dropped. A PollToolTask that starts after cancel_turn committed waits on a zero Timer and moves to FinishTurnCancellation without polling. |
Ends, itself, AwaitQuestionAnswer, or FinishTurnCancellation |
RecordToolFailure |
tool call ID | Records failed for a read call and outcome_unknown for an idempotent_write call, with the recovery detail in error, and publishes ToolCallResults. It is reached only through crashes, timeouts, or errors other than ToolExecutionError after the attempts of CallTool or PollToolTask ran out. When the recovery error's attempt_progress is NOT_STARTED and the record is still approved with no input_answers, it records failed with TOOL_CALL_NOT_RUN_ERROR_PREFIX, ": ", and the recovery detail instead (see Known limits). In Python and TypeScript, a recovery error of type ValueTooLarge whose detail contains run state of ends the session instead: it sets status failed, last_turn_error to the detail, clears the turn, FrameworkRunState, and PendingInterrupts, keeps the turn's records, writes turn_failed, and returns force_fail with the detail. It stores no turn messages, because this commit meets the same limit. |
Ends, or closes the Flow |
RecordToolOutcomeUnknown |
tool call ID | Records outcome_unknown with the recovery detail in error, and publishes ToolCallResults. It records a call whose tool never ran as failed, as RecordToolFailure does. |
Ends |
ResumeTurn |
expected result count | WaitFor any of ToolCallResults.for_n(count) and TurnControlRequests.for_range(at_least=1, at_most=MAX). Execute locks PendingInterrupts. On a control wake in a steered turn that is not cancelled, it removes each pending interrupt of an awaiting_approval record or of an awaiting_answer record with no task_id, writes its interrupt_resolved with cancellation_reason, and publishes ToolCallCancellations[id] for those records and every waiting_for_timer record; then, as on a wake with nothing to apply, it waits again for the same count. With every result in, it checks that every record of the turn is final; if a consumed result belonged to an earlier turn, it waits again for the unfinished count. It never writes a record. |
RunModel, resumed with the final records; ApplyTurnControl in a steered or cancelled turn; itself |
ApplyTurnControl |
none | WaitFor all of SteeredUserMessages.at_most(MAX) and TurnControlRequests.at_most(MAX), which are non-blocking, so it consumes every steered message and stale wake at once; it is the only consumer of SteeredUserMessages. Execute, options SYNC and the recovery retry, locks TurnCancellationRequest, so no turn starts while cancel_turn decides: with an active turn, every record final, it appends the runner's unfinished-turn messages when the runner implements them and they succeed, otherwise, when the turn has records, the steered or cancelled note. It deletes the turn's records and FrameworkRunState, counts the turn, clears it and last_turn_error, and writes turn_steered or turn_cancelled. With consumed steered messages, it starts a turn whose user messages are all of them, in order. |
RunModel with steered messages, else AwaitUserMessage |
RecordTurnFailure |
CompactContextInput or none, the failing Step's input, which it ignores |
Records last_turn_error. When the runner implements build_unfinished_turn_messages, first appends what it returns; if it raises, or returns anything but messages with a user, assistant, or tool role and a string framework_item, it logs a warning and appends nothing for it, so a runner defect never blocks the failure route. When the turn has final AgentToolCalls records, then appends one turn-failure note; otherwise appends nothing more. Deletes the turn's records and FrameworkRunState, empties PendingInterrupts, and clears the turn. cancel_turn cancels a pending RecordTurnFailure, and the turn ends as cancelled. A recovery error of type ValueTooLarge whose detail contains run state of ends the session instead: see RecordToolFailure. |
AwaitUserMessage, or none for a session that ended |
FinishTurnCancellation |
turn ID | Execute, options SYNC and the recovery retry, locks TurnCancellationRequest and PendingInterrupts, which cancel_turn holds, and decides from committed state. When the turn already ended or is not cancelled: ends. When the status is not running_tools, cancel_turn cancelled the model call or compaction: ApplyTurnControl. Otherwise ResumeTurn still waits, and this Step owns every record whose waiting Step cancel_turn cancelled: an awaiting_approval, awaiting_answer, or waiting_for_timer record with no task_id records rejected with the cancellation result and publishes ToolCallResults, removing its pending interrupt with interrupt_resolved; a record whose answer committed first goes back to its waiting Step, which applies the answer; a waiting_for_task record, or an awaiting_answer record with task_id, keeps waiting_for_task with cancellation_reason turn_cancelled and moves to CancelToolTask. approved records belong to their tool Steps, which run or record the cancellation themselves. It never ends the turn while ResumeTurn waits. |
Ends, ApplyTurnControl, CancelToolTask per abandoned task, or a waiting Step whose answer committed |
CancelToolTask |
tool call ID | Async Execute. Calls the runner's optional cancel_tool_task(ToolTaskCancellation) (cancelToolTask), then records outcome_unknown with error the turn was cancelled while the tool's task was running; the task may have taken effect and publishes ToolCallResults. Options: method timeout 30 s, retry 3 attempts within 2 min, failure route RecordToolOutcomeUnknown, which keeps cancellation_reason and records the same error, so a cancellation that keeps failing still ends the turn. |
Ends |
Rules:
- Every Step sets both its WaitFor and its Execute durability to SYNC explicitly, so a Flow started with an ASYNC Step durability default never replays a Step result.
- A failure-route Step receives the failing Step's input, so each tool call Step and its failure route know their tool call from the input. There is no shared current tool call, because tool call Steps run in parallel.
- Every tool call publishes exactly one
ToolCallCompletedmessage, in the same commit that makes its record final. A tool Step whose record is already final ends without running or publishing. - Before a tool Step calls
execute_deferred_tool_call, it writestool_call_startedand awaits a heartbeat. A Worker that disconnects after it received the task fails the attempt instead of running it again elsewhere. Dex treats a stream that half-closes before any output as one whose task never ran: the attempt returns to pending with the same attempt number, uses no retry, and is handed out again. After output, a half-close is a disconnect that fails the attempt withNOT_COMPLETED. The heartbeat therefore keeps a started tool from being handed out again and later reported asNOT_STARTED. ToolExecutionErrorfromexecute_deferred_tool_callrecordsfailedwith the error message and is not retried. Any other error fails the attempt.- A record with
task_idbelongs to remote work that started. Nothing but the task's own outcome, or the call's failure route by effect, makes it final: it is never recordedrejected, which means the tool never ran. Steering and turn cancellation never cancel it: they skip awaiting_for_taskrecord and anawaiting_answerrecord withtask_id, whose question keeps waiting for its answer, decline, or deadline, and they wait for the task's result. - A runner either stores no messages while tool calls run, or stores them
incrementally.
ToolCallsRequested.new_messages(default empty) is appended in the commit that records the tool calls.TurnCompleted.new_messagescarries the turn's items that the session has not stored yet; for a runner that never sets the incremental field, that is every item the turn produced after its user message, across all of its model calls. Message sequences start at 1.last_turn_erroris cleared when a later turn completes. - A call to a built-in tool enabled in
AgentSessionOptions.built_in_tools(ask_user,durable_wait,write_todos) is routed byRunModelto the built-in's Step instead of a tool Step, or, forwrite_todos, recorded inRunModel's commit, with no approval and thereadeffect. Arguments are validated inRunModel's commit; an invalid call recordsfailedand publishes its result at once;nullfor an optionalask_userargument means the argument is absent. An enabled built-in name is reserved; a disabled one is an ordinary tool name. write_todosreplacesAgentPlanwith revision + 1: no tasks iscleared, aplanningturn writesdraft, every task completed iscompleted, and otherwise it isactive. When a response has more than onewrite_todoscall, each fails withwrite_todos was called more than once in one responseand the plan is unchanged.- A turn's mode comes from one decision that every turn start applies to its
user messages:
planningwhen plan mode is enabled and a message hasplan_modeor adraftplan exists, elsechat. A turn thatexecute_planstarted isexecuting, and an approval that marks adraftactivewritesplan_updated. - Plan mode needs
AgentSessionOptions.plan_mode_enabled, which needswrite_todosand a runner whosesupports_plan_modeis true; the Flow constructor refuses either missing.execute_planserializes on thePendingPlanExecutionlock and records the accepted revision there, so a second approval is refused untilAwaitUserMessageconsumes the first, including while its Execute has not committed.AwaitUserMessageis the only other writer ofPendingPlanExecution, and withRunModelthe only writer ofAgentPlan. - Compaction changes the model input. A runner with the
compaction capability receives in
AgentTurnContext.messagesonly the messages afterContextSummary.last_sequence, and the summary inAgentTurnContext.context_summary. Summarized messages stay stored until retention deletes their chunk (see Message storage below). Every movement intoRunModelandApplyTurnControllists in its load options only theAgentMessageChunksinstances after the summary, andCompactContextlists the chunks it summarizes; each Step also loads the wholeAgentMessageswindow.RunModelcompacts only when the cutoff advances past the summary, and no message is appended betweenCompactContextandRunModel, so the pair cannot loop.RunModelruns only when every tool call record of the turn is final, so compaction never runs while an approval, a question, a durable wait, or a tool task is pending; a task's call and its result are stored like any other call's, and no cut separates them. - Session settings change at turn boundaries only.
StartAgentSessionstoresAgentSessionStartInput.session_settingsinSessionSettings, andupdate_session_settingsreplaces it. The Step that starts a turn copiesSessionSettingsintoTurnSessionSettingsin the same commit, andRunModelandCompactContextread onlyTurnSessionSettings, so a running turn keeps the settings it started with, retries included. Settings hold no secrets, only references the Worker resolves, and their compact JSON text in UTF-8 is at mostMAX_SESSION_SETTINGS_BYTES(16,384) bytes, which Clients check; the session's own checks acceptSESSION_SETTINGS_SIZE_TOLERANCE_BYTES(1,024) more, because the cores write some floats and U+2028/U+2029 differently. Tool Steps do not receive the settings. RunModelandCompactContextboth route failures toRecordTurnFailure, whose input is thereforeCompactContextInputor JSONnull. The Python core declaresCompactContextInput | Noneas the input of all three Steps, because its Registry requires a failure route to share its Step's input type;RunModelalways receivesnull, as before.- Writers in both cores emit times in UTC with milliseconds and
Z, such as2026-11-01T12:00:00.123Z. Readers in both cores, 0.1.0 included, accept that form and the+00:00offset form that Python 0.1.0 wrote.
Steering and cancellation rules (MAX is 2,147,483,647):
- Two flags decide every boundary, from committed state only. A turn is
cancelled while
TurnCancellationRequest.turn_idequals the active turn ID; turn IDs never repeat. It is steered whileSteeredUserMessagesis not empty. A turn that is both ends as cancelled, and the steered messages start the next turn. Thecancellation_reasonisturn_cancelledorsteered. - A call abandoned before it starts records
rejectedwithcancellation_reasonand the structuredrejection_messagebelow. Final statuses do not change. - Steering never cancels a model call or a tool call that has started.
Execute sees the state loaded for its first attempt, and retries reuse it,
so
RunModeldecides only before its model call, a tool Step only beforetool_call_started, and a tool Step that started runs to the end. A steer that commits during a model call applies at the next boundary: the tool Steps it requested,ResumeTurn's control wake, or, after a final answer,AwaitUserMessage. - A steered turn takes its
interaction_modefrom its messages as a queued turn does:planningwhen plan mode is enabled and any steered message hasplan_modeor adraftplan exists. A queued message moved by its ID keepsplan_mode.execute_planreturns false while a steered message is pending. Awrite_todoscall settles inRunModel's own commit, like a built-in call with invalid arguments, so one that a model call made while the user steered still replaces the plan. - Steering abandons a waiting call only while it has taken no effect: an
awaiting_approvalrecord, anawaiting_answerrecord with notask_id, or awaiting_for_timerrecord. One predicate in each core decides this forResumeTurn, the woken Step, and an unraised tool question, so a call whose remote work has started is left to finish. ToolCallCancellationsonly wakes a waiting Step; the flags decide. A woken Step whose turn is neither steered nor cancelled waits again, so a wake-up left from an earlier turn whose call reused the ID changes nothing. A woken approval or question whose interrupt is gone and whose answer is pending waits again, so an answer that committed first wins. Otherwise the Step removes its interrupt when it is still pending, records the call cancelled, and publishes its result.- A Step's waits, locks, and loads come from the Worker that scheduled it. A
turn that a Worker without steering started or advanced can hold such a wait,
which a steer never wakes.
_begin_turnsetsAgentState.accepts_turn_control, every turn end clears it, and an earlier Worker drops it whenever it writesAgentState;steer_user_messageandcancel_turnrefuse a turn without it. While no turn is active, a steer is queued instead, because an idle wait that an earlier Worker began listens only toQueuedUserMessages. A turn that started on 0.2.0 during a mixed rollout is not refused, so applications call the RPCs only after every turn active during the rollout has ended. ResumeTurn,AwaitToolApproval, andAwaitQuestionAnswerserialize on thePendingInterruptslock withresolve_interruptandanswer_question. Either the RPC commits first and its record moves on, orResumeTurnremoves the interrupt first and the RPC returns false. An approval that wins moves to its tool Step, which sees the flag and records the call cancelled before it starts.
The turn-failure note is the user message that build_user_message returns
for this text, with one line per final record in deferral_order:
[Dex] The previous turn failed after these tool calls ran:
- <tool_name> <arguments> -> <status>: <model-visible result>
Do not repeat these tool calls unless the user asks again.
Model-visible results for final records, used in the note and when resuming:
| Status | Text |
|---|---|
succeeded |
output |
failed |
The tool call failed: <error> |
rejected |
rejection_message, or The user rejected this tool call. |
outcome_unknown |
The tool call's outcome is unknown: it stopped without returning a result, so it may or may not have taken effect. It will not be retried automatically. |
Steering and cancel_turn record an abandoned call rejected with one of
these rejection_message values, compact JSON text. A started task that
cancel_turn abandons records outcome_unknown with cancellation_reason
turn_cancelled instead, because the task may have run:
{"status":"cancelled","reason":"superseded_by_steered_user_message"}
{"status":"cancelled","reason":"turn_cancelled_by_user"}
When a steered or cancelled turn ends and the runner stores no
unfinished-turn messages, ApplyTurnControl stores a note in the
turn-failure note's line format and footer, with the first line
[Dex] The user steered the conversation after these tool calls ran: or
[Dex] The user stopped the previous turn after these tool calls ran:.
An expired approval deadline records rejected with rejection_message
approval deadline passed; an expired question deadline records
question deadline passed. A declined question records
The user declined to answer. when the user gave no message. Built-in
argument errors record failed with invalid <tool> arguments: <reason>, and
invalid todos: <reason> for write_todos. A write_todos call that succeeds
records succeeded with {"status":"updated","revision":<n>,"task_count":<k>},
or {"status":"cleared","revision":<n>} for no tasks. Argument text that is
not strict JSON, NaN included, fails as arguments must be a JSON object in
both cores, and the first unknown name is the lowest by code point.
A runner that supports plan mode shows the model the plan with
build_plan_note(plan, interaction_mode), as user text before the turn's
messages:
[Dex] The current plan, revision <n>, is <status>:
- [<task status>] <content>
This turn only plans: write or revise the plan with write_todos, and do not do or claim any of its tasks. The user reviews the plan before it runs.
The last line appears only in a planning turn, where a missing or cleared
plan shows as [Dex] There is no plan yet.; outside a planning turn, such a
plan shows nothing.
The conversation is a Sequentially Chunked AttributeMap with retention.
- Window. The newest messages are
AgentMessagesinstances, one per message, so storing a message is one blind instance write and never rewrites a larger value. Every Step that stores messages loads the whole window:StartAgentSession,AwaitUserMessage,RunModel,ApplyTurnControl, andRecordTurnFailure. - Rollover. When a Step leaves more than
message_window_sizemessages in the window, the same commit moves the oldest ones intoAgentMessageChunksinstances keyed by their first sequence, deletes them from the window, appends each chunk's range toAgentMessageIndex.chunks, and advanceswindow_first_sequence. A chunk closes atmessage_chunk_sizemessages or before the next message would take its encoded size pastmessage_chunk_max_bytes. The size is the UTF-8 length of the core's own JSON encoding: 15 bytes for{"messages":[]}, each message, and a comma between messages. One commit moves at mostMESSAGE_ROLLOVER_MAX_CHUNKS_PER_COMMIT(4) chunks andMESSAGE_ROLLOVER_MAX_BYTES_PER_COMMIT(8 MiB) unless its first chunk alone is larger, so the window can stay above its size until later commits drain it. Chunks are never rewritten. - Oversized messages. A message whose single-message chunk would exceed
message_chunk_max_bytesskips the window: the Step that stores it writes it straight into its own chunk and adds that range toAgentMessageIndex.chunks, where it sits among the window's sequences. The first store thus writes the largest value the message ever needs. A message too large forvalues.maxValueBytesfails that first commit, and the turn fails, as a too-large value did in 0.1.0; no later rollover can fail on it. Rollover steps over such chunks. - Retention. With compaction and a
retained_message_limit, the Step that stores messages andCompactContextdelete the oldest chunk while its last sequence is at or beforeContextSummary.last_sequenceand at leastretained_message_limitmessages are stored after it, then advancefirst_sequence. The newestretained_message_limitmessages and every message after the summary always stay. - Reads. A sequence in a chunk range is in that chunk; any other sequence
from
window_first_sequenceon is inAgentMessages. A reader checks that the chunk holds exactly its range.AgentMessagesPage.deleted_before_sequenceisfirst_sequenceonce retention deleted the messages before it. - Index writes.
AgentMessageIndexis written only when a Step writes or deletes a chunk. A session without it stores every message inAgentMessagesfrom sequence 1. An append with no messages that starts no turn writes nothing. - Counts. The Step that starts a turn writes
TurnReplySequencewith the turn ID and the first sequence after its user messages.turn_completed,turn_steered, andturn_cancelledcount the turn's messages asnext_message_sequence - sequence, so no Step reads the turn's older messages. When the value names another turn, such as one a 0.1.0 Worker started, the count scans back from the newest message, and the next Step that stores a message in that turn records itsTurnReplySequencebefore any rollover. - 0.1.0 sessions. A session that dex-agents 0.1.0 stored has every message
in
AgentMessagesand noAgentMessageIndex, which readers treat as a large window. The rollover of each Step that stores messages moves the older messages into chunks, at most four chunks a commit. Readers handle the half-moved session through the index. - Upgrade rule. Stop every 0.1.0 Worker of a Flow type before any 0.2.0 Worker starts. A 0.1.0 Worker on an upgraded session fails its turns, because it cannot read the messages that moved into chunks.
- Warning. A runner without the compaction capability on a session whose
first_sequenceis past 1 gets a model input without the deleted messages and no summary; the core logs a warning on each such model call.
Options, named alike in every core: message_window_size (64),
message_chunk_size (32), message_chunk_max_bytes (1 MiB, at most 8 MiB),
and retained_message_limit (1,000; None keeps every message). The Go core
has no compaction, so it has no retention.
| Name | Kind | Value |
|---|---|---|
AgentState |
Attribute | AgentState |
AgentMessages |
AttributeMap, key = sequence as 12-digit zero-padded decimal | AgentMessage; the window only: sequences from AgentMessageIndex.window_first_sequence |
AgentMessageChunks |
AttributeMap, key = the chunk's first sequence as 12-digit zero-padded decimal | AgentMessageChunk; immutable, its range listed in AgentMessageIndex.chunks, deleted whole by retention |
AgentMessageIndex |
Attribute | AgentMessageIndex; unset until a Step first writes a chunk |
TurnReplySequence |
Attribute | TurnReplySequence; the Step that starts a turn writes it |
FrameworkRunState |
Attribute | string, opaque framework JSON; present only while a turn waits for tool calls |
AgentToolCalls |
AttributeMap, key = tool call ID | AgentToolCall; deleted when the turn ends |
PendingInterrupts |
Attribute | list of AgentInterrupt |
AgentUsage |
Attribute | AgentUsage |
QueuedUserMessages |
Channel | AgentUserMessage |
InterruptResolutions |
ChannelMap, key = interrupt ID | AgentInterruptResolution |
ToolCallResults |
Channel, internal | ToolCallCompleted |
SteeredUserMessages |
Channel | AgentUserMessage; steer_user_message publishes, ApplyTurnControl consumes |
TurnControlRequests |
Channel, internal | TurnControlRequest; wakes ResumeTurn or AwaitUserMessage |
TurnCancellationRequest |
Attribute | TurnCancellationRequest; only cancel_turn writes it |
ToolCallCancellations |
ChannelMap, internal, key = tool call ID | ToolCallCancellation; ResumeTurn publishes, the call's waiting Step consumes |
AgentTraceContext |
Attribute | string, the W3C traceparent (00-<trace id>-<span id>-<flags>) of the current activation's root span while it has a valid context, sampled or not; an activation whose root has none deletes it |
AgentPlan |
Attribute | AgentPlan; unset until write_todos first succeeds |
PlanExecutions |
ChannelMap, key = revision as decimal | PlanExecutionRequest |
PendingPlanExecution |
Attribute | PlanExecutionRequest; the approval execute_plan accepted that AwaitUserMessage has not consumed |
ContextSummary |
Attribute | ContextSummary; set once the session compacts; only CompactContext writes it |
SessionSettings |
Attribute | JSON object; the settings the next turn starts with; StartAgentSession and update_session_settings write it; unset when there are none |
TurnSessionSettings |
Attribute | TurnSessionSettings; the settings a turn started with, tagged with its turn; the turn-start Steps write it; a copy whose turn_id is not the active turn reads as {} |
AgentEvents |
Stream, capacity 10 MiB by default, configurable | AgentEvent |
Value types:
AgentSessionStartInput {
initial_message: str | null
session_settings: JSON object | null = null # absent in 0.1.0; {} means none
}
UpdateSessionSettingsRequest { session_settings: JSON object | null = null } # replaces; {} clears; null is refused
TurnSessionSettings { turn_id: str, session_settings: JSON object }
AgentState {
status: "waiting_for_user_message" | "running_model" | "running_tools" | "compacting_context" | "failed"
turn_count: int # completed or failed turns
active_turn_id: str | null # "turn-<n>"
next_message_sequence: int
last_turn_error: str | null
interaction_mode: "chat" | "planning" | "executing" = "chat" # of the active or last turn
accepts_turn_control: bool = false # set when a Worker with steering starts the turn
}
AgentMessage {
sequence: int
turn_id: str
role: "user" | "assistant" | "tool"
text: str | null # display text
tool_call_id: str | null
tool_name: str | null
tool_arguments: str | null # JSON text
tool_output: str | null
framework_item: str # framework-native JSON, replayed to the framework unchanged
created_at: str # RFC 3339
}
AgentToolCall {
tool_call_id: str
turn_id: str
tool_name: str
arguments: str # JSON text
effect: "read" | "idempotent_write" | "non_idempotent_write" | "unclassified"
requires_approval: bool
status: "awaiting_approval" | "approved" | "awaiting_answer" | "waiting_for_timer" | "waiting_for_task" # non-final
| "rejected" | "succeeded" | "failed" | "outcome_unknown" # final
output: str | null
error: str | null # ToolExecutionError message, or the recovery detail
rejection_message: str | null
deferral_order: int # zero-based order of the call within its turn
tool_source_reference: str | null # opaque text from DeferredToolCall.tool_source_reference
trace_context: str | null # traceparent of the activation root its tool spans join; set by RunModel and by an approval
wait_until: str | null = null # RFC 3339, durable_wait only
input_continuation: str | null = null # from the last ToolInputRequested, or a task's last ToolTaskPending
input_answers: list of QuestionAnswer = []
input_question: str | null = null # the pending ToolInputRequested question
input_choices: list of str = [] # its choices
input_allows_free_text: bool = true # whether it accepts free text
cancellation_reason: "steered" | "turn_cancelled" | null = null # on a rejected record that steering abandoned
task_id: str | null = null # from ToolTaskStarted; kept after the task ends
task_poll_interval_seconds: int | null = null # whole seconds, at least 1, until the next poll
task_answer_pending: bool = false # input_answers[-1] has not reached the task yet
}
QuestionAnswer { interrupt_id: str, question: str, answer: str,
resolution: "answered" | "declined" | "deadline_passed" = "answered" }
AgentInterrupt {
interrupt_id: str # the tool call ID for approvals and ask_user; "<id>-input-<n>" for tool questions
kind: "tool_approval" | "question"
tool_call_id: str
tool_name: str
arguments: str
created_at: str
deadline: str | null
question: str | null = null
choices: list of str = []
allows_free_text: bool = true
}
AgentInterruptResolution { approved: bool, rejection_message: str | null, answer: str | null = null }
AnswerQuestionRequest { interrupt_id: str, answer: str }
ToolCallCompleted { tool_call_id: str }
PendingUserMessage { message_id: str, message: AgentUserMessage } # message_id is the Channel message ID
SteerUserMessageRequest { message_id: str | null, text: str | null } # exactly one set
DeleteQueuedMessageRequest { message_id: str }
CancelTurnRequest { turn_id: str | null } # null for the active turn
TurnControlRequest { kind: "steer" | "cancel_turn" } # cancel_turn no longer publishes one
TurnCancellationRequest { turn_id: str, requested_at: str }
ToolCallCancellation { reason: "steered" | "turn_cancelled" }
AgentUsage { model_calls: int, input_tokens: int, output_tokens: int, cached_input_tokens: int, reasoning_tokens: int }
AgentUserMessage { text: str, plan_mode: bool = false }
AgentPlan { revision: int, status: "draft" | "active" | "completed" | "cleared", tasks: list of AgentPlanTask, updated_at: str }
AgentPlanTask { content: str, status: "pending" | "in_progress" | "completed" }
ExecutePlanRequest { revision: int }
PlanExecutionRequest { revision: int }
AgentEvent {
type: str # see Events
turn_id: str | null
attempt: int # one-based Step attempt
payload: str # JSON object text
step_execution_id: str # Dex Step execution ID, shared by every attempt; "" when absent
}
ResolveInterruptRequest { interrupt_id: str, approved: bool, rejection_message: str | null }
AgentSnapshot {
state: AgentState | null # null until StartAgentSession commits
messages: list of AgentMessage # the last 200, in sequence order
pending_interrupts: list of AgentInterrupt
tool_calls: list of AgentToolCall # the active turn's records, in deferral_order
usage: AgentUsage
queued_message_count: int
plan: AgentPlan | null = null
queued_messages: list of PendingUserMessage = [] # oldest first
steered_messages: list of PendingUserMessage = [] # not applied yet, oldest first
context_summary: ContextSummary | null = null
session_settings: JSON object | null = null # the next turn's settings
}
ContextSummary {
generation: int # summaries written, starting at 1
first_sequence: int # always 1: each summary folds in the previous one
last_sequence: int # the last summarized message
text: str
created_at: str # RFC 3339
}
CompactContextInput { through_sequence: int }
AgentMessageChunk { messages: list of AgentMessage } # consecutive messages
AgentMessageChunkRange { first_sequence: int, last_sequence: int }
AgentMessageIndex {
chunks: list of AgentMessageChunkRange # every kept chunk, oldest first; archived ones consecutive, then single oversized messages
first_sequence: int # oldest stored message
window_first_sequence: int # oldest message in AgentMessages
}
TurnReplySequence { turn_id: str, sequence: int } # first sequence after the turn's user messages
ListAgentMessagesRequest { before_sequence: int | null, limit: int } # limit defaults to 50
AgentMessagesPage { messages: list of AgentMessage, next_before_sequence: int | null, deleted_before_sequence: int | null }
| RPC | Input | Output | Notes |
|---|---|---|---|
send_user_message |
AgentUserMessage |
bool | Transactional; false for blank text, and for a plan_mode message when plan_mode_enabled is false |
execute_plan |
ExecutePlanRequest |
bool | Locks PendingPlanExecution. False unless the session waits for a user message, AgentPlan has this revision and is draft or active, PendingPlanExecution holds no request for it, and no queued or steered message or interrupt is pending. Otherwise sets PendingPlanExecution and publishes PlanExecutions[revision] in one commit |
steer_user_message |
SteerUserMessageRequest |
bool | Transactional, loads QueuedUserMessages, locks nothing. While no turn is active, it queues text like send_user_message, and a message_id still queued stays queued (true). False when the active turn's accepts_turn_control is false. With message_id, deletes that queued message and publishes it to SteeredUserMessages; with text, publishes a new steered message. Then publishes TurnControlRequest(kind="steer"). False when both or neither field is set, the text is blank, or the message ID is not pending. A message consumed between the read and the commit fails the RPC with CHANNEL_MESSAGE_NOT_FOUND, and nothing commits |
delete_queued_message |
DeleteQueuedMessageRequest |
bool | Transactional, loads QueuedUserMessages; deletes the message; false when it is not pending; CHANNEL_MESSAGE_NOT_FOUND as for steer_user_message |
cancel_turn |
CancelTurnRequest |
bool | Locks TurnCancellationRequest and PendingInterrupts; only short Steps lock them, so it never waits for a model call. False when no turn is active, turn_id names another turn, or the turn's accepts_turn_control is false. True and no change when the turn is already cancelled. Otherwise writes TurnCancellationRequest(turn_id, requested_at) and returns canceling Steps RunModel, CompactContext, RecordTurnFailure, AwaitToolApproval, AwaitQuestionAnswer, AwaitTimer, and PollToolTask, and next Step FinishTurnCancellation(turn_id). Dex cancels the selected Steps at the RPC's commit, including a running attempt. True means the request is recorded; a model call that committed its final answer before the RPC completes the turn |
update_session_settings |
UpdateSessionSettingsRequest |
bool | Transactional, locks nothing; the last update to commit wins. False before StartAgentSession committed, or when the settings are null or missing, are not a JSON object, or exceed MAX_SESSION_SETTINGS_BYTES plus SESSION_SETTINGS_SIZE_TOLERANCE_BYTES (1,024). Otherwise sets SessionSettings, or deletes it for {}. Takes effect when the next turn starts; the active turn keeps TurnSessionSettings |
resolve_interrupt |
ResolveInterruptRequest |
bool | Locks PendingInterrupts; false when the interrupt is not pending or its deadline has passed; removes it and publishes the resolution in one commit, dropping rejection_message on approval. On a question, approved=false declines it and approved=true returns false |
answer_question |
AnswerQuestionRequest |
bool | Locks PendingInterrupts; false when the interrupt is not a pending question, is past its deadline, or the answer is blank or not one of choices while free text is not allowed; removes it and publishes AgentInterruptResolution(approved=true, answer) in one commit |
get_agent_snapshot |
none | AgentSnapshot |
Loads AgentMessages, AgentMessageChunks, and AgentToolCalls, and the QueuedUserMessages and SteeredUserMessages messages; returns state, the last 200 stored messages, pending interrupts, the active turn's tool calls, usage, the queued message count, the plan, the queued and steered messages with their IDs, the context summary, and SessionSettings |
list_agent_messages |
ListAgentMessagesRequest |
AgentMessagesPage |
Loads AgentMessages and AgentMessageChunks. Oldest first. before_sequence null starts after the newest message. Limit clamped to 1..200. next_before_sequence is the page's first sequence, or null when the page starts at the oldest stored message, AgentMessageIndex.first_sequence (1 without an index) |
TypeScript uses the same RPC names.
AgentEvents carries these types. Payloads are JSON objects with snake_case
keys:
| Type | Payload |
|---|---|
turn_started |
{"sequence"}: the turn's user message sequence |
turn_completed |
{"message_count"}: messages the turn appended |
turn_failed |
{"error"} |
turn_steered |
{"steered_message_count", "message_count"}: the steered messages that start the next turn, and the messages the ended turn appended |
turn_cancelled |
{"message_count"} |
text_delta |
{"text"} |
reasoning_delta |
{"text"} |
tool_call_started |
{"tool_call_id", "tool_name"} |
tool_call_finished |
{"tool_call_id", "tool_name", "status"}, once per tool call when its record becomes final, rejections included |
tool_call_deferred |
{"tool_call_id", "tool_name", "effect", "requires_approval"}, once per requested tool call |
interrupt_raised |
{"interrupt_id", "tool_call_id", "tool_name", "deadline", "kind", "question", "choices", "allows_free_text"} |
interrupt_resolved |
{"interrupt_id", "approved", "rejection_message", "kind", "answered", "cancellation_reason"}; cancellation_reason is steered or turn_cancelled when steering or cancel_turn removed the interrupt, else null |
timer_started |
{"tool_call_id", "wait_until", "reason"} |
plan_updated |
{"revision", "status", "task_count"}, when write_todos replaces the plan or an approval marks a draft plan active |
plan_execution_started |
{"revision"}, after the turn_started of an executing turn |
tool_task_started |
{"tool_call_id", "tool_name", "task_id"} |
usage_recorded |
{"recorded", "session"}: two AgentUsage objects, the model call and the session total |
compaction_started |
{"through_sequence"}, when CompactContext starts; a retried attempt writes it again |
compaction_finished |
{"generation", "through_sequence"}, when CompactContext wrote the summary |
Adapter-specific types use an adapter prefix, such as
openai_agents.handoff. Every event carries turn_id, step_execution_id,
and attempt. A UI groups live text by (turn_id, step_execution_id, attempt): within one step_execution_id it keeps only the highest attempt
seen and drops the partial text of lower attempts. Different
step_execution_id values of one turn are separate model call and tool call
Steps, and each keeps its text. After turn_completed, turn_failed,
turn_steered, or turn_cancelled, the UI replaces the turn's live text with
the stored messages. Streams are best
effort; clients rebuild from get_agent_snapshot. The event writer is safe to
call from any thread.
The cores emit OpenTelemetry GenAI spans through the OpenTelemetry API, an
optional dependency: the Python extra dex-agents[otel] (opentelemetry-api
only) and the optional npm peer @opentelemetry/api. Without it, or with
tracing disabled, a core never loads it and writes no AgentTraceContext.
Parent design: C9 in #1132.
- An activation starts when a Step consumes an input from outside the
session:
StartAgentSession,AwaitUserMessage, orApplyTurnControlwith a user message, orAwaitToolApprovalwith an interrupt answer or an expired deadline. That Step's attempt emits the trace rootinvoke_agent {agent name}, linked to the previous root. It stores itstraceparentinAgentTraceContextwhen its span context is valid, sampled or not, so the activation's spans share its sampling decision, and deletes a stored value when it has none. - Each
RunModelattempt emits achatspan when it ends, as a child of the root inAgentTraceContext:CLIENTwith usage when the runner reports one model call,INTERNALwithout usage otherwise. EachCompactContextattempt emits the same span for its summary call, named fromContextSummaryResult.model_call_metadata.RunModelcopies the root onto each tool call record, and an approval replaces it with its own root. EachCallTool,CallSingleAttemptTool, andPollToolTaskattempt emitsexecute_tool {tool name}under the record's root. Every span ends inside its Step attempt, so none stays open across a durable wait, and theinvoke_agentroot ends with its own Step. - Tracing never fails or retries a Step: OpenTelemetry errors are logged and the span is skipped.
- Every span carries the Flow ID as
gen_ai.conversation.id,session.id, anddex.flow.id, plusdex.run.id,dex.step.type,dex.step.execution_id,dex.step.attempt, anddex.agent.turn.id. - Content is by reference by default: message sequence keys and tool call IDs. Message content is opt-in.
- The conventions version is pinned, with
open-telemetry/semantic-conventions-genaiat commitdc3571562603552dc555c667dfa327a638d94ab2(schema1.42.0-dev) as the default and OpenTelemetry semantic conventions1.36.0as the compatibility value.
The core READMEs list every attribute. The Dex SDKs emit no spans; SDK-level tracing is a separate design.
class AgentFrameworkRunner(Protocol[ToolOutcomeT]): # ToolOutcomeT defaults to ToolExecutionResult
def build_user_message(self, text: str) -> FrameworkMessage: ...
async def run_model_step(self, turn: AgentTurnContext) -> AgentTurnOutcome: ...
async def execute_deferred_tool_call(self, call: DeferredToolExecution) -> ToolOutcomeT: ...A bare AgentFrameworkRunner returns ToolExecutionResult, as in 0.1.0. A
runner whose tools ask the user implements
AgentFrameworkRunner[DeferredToolOutcome], where DeferredToolOutcome is
ToolExecutionResult | ToolInputRequested. The TypeScript core declares
AgentFrameworkRunner<ToolOutcome = ToolExecutionResult> the same way.
AgentTurnContext gives the runner the session's stored messages, the
framework run state when continuing the turn, the turn's tool call records (all
final when RunModel runs), the tool policy lookup resolve_tool_policy, an
event writer, a heartbeat function, the Flow ID, the turn ID, and the attempt.
AgentTurnOutcome is either TurnCompleted(new_messages, usage) or
ToolCallsRequested(framework_run_state, tool_calls, usage), where each tool
call is a DeferredToolCall(tool_call_id, tool_name, arguments, policy, tool_source_reference=None). Both take an optional model_call_metadata, a
ModelCallMetadata(request_model, response_model, provider_name, response_id, finish_reasons) that only names and annotates the chat span; the session
never stores it.
run_model_step makes at most one model call and runs no tool it can defer.
FrameworkMessage is the AgentMessage projection plus framework_item.
execute_deferred_tool_call runs the real tool once, possibly concurrently
with other calls, and returns ToolExecutionResult(output), or raises
ToolExecutionError for a failure the model should see. DeferredToolExecution
also carries heartbeat_checkpoint, the checkpoint an earlier attempt saved
with save_heartbeat_checkpoint. It may instead return
ToolInputRequested(question, choices, allows_free_text, continuation) when the
tool needs the user's answer before it takes any effect; the tool Step runs
again with DeferredToolExecution.input_answers. It may also return
ToolTaskStarted(task_id, poll_interval_seconds) when the tool started remote
work, after saving the handle with save_heartbeat_checkpoint so a retried
attempt returns it instead of starting the work again; DeferredToolOutcome
is ToolExecutionResult | ToolInputRequested | ToolTaskStarted.
Optional additions, all inert for a runner that ignores them:
ToolCallsRequested.new_messagesstores items incrementally. A runner that sets it implementsUnfinishedTurnMessageBuilder.build_unfinished_turn_messages(turn), which returns the unstored items, a tool result for every stored tool call included, when a turn ends without a final answer: inRecordTurnFailure, before the turn-failure note, and inApplyTurnControl, instead of the steered or cancelled note. A runner that keeps its items in run state may implement it too, so a steered turn keeps its exact transcript.TurnContinued(framework_run_state, new_messages, usage)(TypeScriptkind: "turn_continued") makes another model call in the same turn, andTurnCompleted.turn_error(turnError) ends a turn as failed without a retry. The built-in loop returnsTurnContinuedwhenAgentDefinition.decide_turn_continuation(decideTurnContinuation) returns a continuation after an answer:new_messagesholds the answer and the continuation message as user text, so the next request ends with a user message, as Anthropic models from Claude 4.6 on require. It returnsturn_erroronce 8 continuations in a row are exceeded. After tool calls a continuation's message follows the tool results in the same model call. The same hook returning"end"after tool calls makes the nextRunModelreturnTurnCompletedwith only the tool results and zero usage, so a turn may end with a tool result instead of an assistant answer. Its run state addsconsecutive_continuationsonly after a continuation.convertAgentSnapshotToUIMessagestreats a user message between two model calls of one turn as the start of the next model call's step.AgentTurnContext.enabled_built_in_toolsnames the enabled built-ins. A runner offers theirBUILT_IN_TOOL_DEFINITIONSto its model like any tool.AgentTurnContext.session_settings(TypeScriptsessionSettings, GoSessionSettings) holdsTurnSessionSettings, an empty object when there are none, andContextSummaryRequest.session_settingsthe same. A runner chooses the model or instructions from it. Stepwise'sAgentDefinition.resolve_model(resolveModel, GoWithModelResolver) maps it to theModelClientof each model call, andInstructionContext.session_settingspasses it to an instructions function.AgentTurnContext.interaction_modeandAgentTurnContext.plancarry the turn's mode and the committed plan. A runner that setssupports_plan_mode(PlanModeRunner; TypeScriptsupportsPlanMode) offers onlywrite_todosin aplanningturn and showsbuild_plan_notebefore the turn's messages. Only such a runner can enable plan mode. Stepwise does; the adapters do not yet. Stepwise'sAgentDefinition.planning_tool_choice(planningToolChoice) isautoby default: a planning turn may answer in text, such as with a clarifying question, and then ends without a draft.requiredforceswrite_todoson the first model call of a planning turn while no draft exists, which some models and Anthropic extended thinking refuse; the Python core refusesrequiredwhen the model client'ssupports_forced_tool_choice(settings)returns false.- A runner whose tools return
ToolTaskStartedimplementsToolTaskPoller.poll_tool_task(poll: ToolTaskPoll) -> ToolExecutionResult | ToolInputRequested | ToolTaskPending(ToolTaskPollOutcome), whereToolTaskPollis{flow_id, tool_call, idempotency_key, attempt, input_answers, events, heartbeat, pending_answer}andToolTaskPendingis{poll_interval_seconds, continuation = null}. A poll only reads the task, and deliverspending_answer, the resolution of the task's last question, when it is set: the answer, a decline, or a cancelled request, by itsresolution. It delivers only to the request thattool_call.input_continuationnames, and idempotently, because a crashed poll runs again; when a retried attempt cannot tell whether an earlier one delivered, it returns the question again instead. A pending poll returns the continuation the next poll needs, and drops an answered question's continuation once the task took the answer, so an identical later question reads as new. A runner that cannot deliver a decline cancels the task best-effort and raises an error other thanToolExecutionError, so the call is recorded by effect.DeferredToolExecution.supports_tool_taskstells a tool whether the runner polls tasks; without it, the tool runs its work to the end inside the call. - A runner that polls tasks may implement
ToolTaskCanceller.cancel_tool_task(cancellation: ToolTaskCancellation)(cancelToolTask), whereToolTaskCancellationis{flow_id, tool_call, idempotency_key, attempt, heartbeat}(TypeScript addscancellationSignal).CancelToolTaskcalls it best-effort for a task thatcancel_turnabandoned, and it must be idempotent bytask_id. A Stepwise tool source may define the same optional method;McpToolSourcesendstasks/canceland treats any answer from the server as done. - Model calls honor cancellation:
cancel_turncancels a runningRunModelorCompactContext. In Python the coroutine is cancelled, which stops the LiteLLM call and closes its response stream; in TypeScriptAgentTurnContext.cancellationSignalaborts, and the AI SDK client passes it asabortSignalthroughModelRequest.cancellationSignal. The framework adapters pass it on: TypeScript adapters give the signal to their framework, and Python adapters run on asyncio, so cancellation reaches the framework's awaits. ContextCompactionRunner, required byAgentSessionOptions.compaction(CompactionPolicy(context_window_tokens, trigger_ratio=0.85, retained_ratio=0.3)); the Flow constructor raises for a runner without it:estimate_input_tokens(turn) -> int, the next model input's size;select_compaction_cutoff(turn, retained_token_budget) -> int | None, the last sequence to summarize, which never separates a tool call from its result; andsummarize_context(ContextSummaryRequest(flow_id, turn_id, attempt, previous_summary, messages, through_sequence, events, heartbeat, input_token_budget)) -> ContextSummaryResult(text, usage, model_call_metadata=None), one model call.input_token_budgetis the policy'scontext_window_tokens. TypeScript makes them optionalAgentFrameworkRunnermethods,estimateInputTokens,selectCompactionCutoff, andsummarizeContext; the first two may return a Promise, and the request also carriesstepExecutionId,cancellationSignal, anddeadline. Stepwise implements all three: its model input starts with[Dex] Summary of the earlier conversation:and the summary text, and it cuts only between model responses whose tool calls all have stored results. When the summary request plus the configuredmax_output_tokenswould exceedinput_token_budget, it leaves out the oldest messages up to such a cut and says so with[Dex] <n> earlier messages did not fit in this request and are left out.([Dex] 1 earlier message did not fit in this request and is left out.for one).
The TypeScript core mirrors these names in camelCase for code and keeps the
snake_case wire format. Its runner-facing AgentToolCallStatus keeps the six
statuses a runner sees; stored records use AgentToolCallRecord, whose status
also allows the three waiting statuses, and which ToolTaskPoll.toolCall
carries. AgentTurnContext.toolPolicies and
defaultToolEffect mirror tool_policies and default_tool_effect. Options
that only one core has, because of each language's conventions: TypeScript
takes interruptDeadlineMs and maxDurableWaitMs in milliseconds where
Python takes interrupt_deadline and max_durable_wait as timedelta, and
TypeScript exposes cancellationSignal and deadline on its contexts,
AgentToolCallRecord, ModelToolChoice, and aiSdkModelClientApi.
Each package runs integration tests against a real Dex Server, using dexcli dev --engine zd from the released dexcli bundle, with a scripted model so no
model key is needed. Every adapter covers this matrix:
- A turn with two model calls and a READ tool produces
RunModel -> CallTool -> ResumeTurn -> RunModel, and the history replays on the next turn. - Parallel tool calls of one model response run as parallel Steps.
- A write tool that needs approval waits in parallel with an unapproved READ tool, survives a Worker restart while waiting, runs once after approval, and its result reaches the model.
- A rejected write never runs, and the model sees the rejection message.
- A Worker killed with SIGKILL during
CallSingleAttemptToolrecordsoutcome_unknown, the tool is not run again, and the model sees the unknown outcome. - A Worker killed during
RunModelreruns only that one model call. resolve_interruptcalled twice returns false the second time, and an approval deadline rejects the call.- A turn that fails after a write ran leaves the turn-failure note, and the write is not repeated.
- A session started with an ASYNC Step durability default never reruns a single-attempt write.
- A message sent during a turn is queued and handled after the turn.
get_agent_snapshotreturns the conversation after the session completes a turn.
Kill tests run the Worker as a child process in its own process group, as
examples/python/tests/integ/ai_agent_processes.py does. Tests assert the Step
shape of a turn from the Flow's history (dexcli flow history).
.github/workflows/agents-ci.yml (workflow name Agents CI) discovers every
package that has an executable agents/<language>/<package>/ci.sh and runs
one matrix job per package, so adding a package never edits the workflow.
Discovery fails when a ci.sh is not executable or when it finds no package.
Each job downloads the pinned dexcli release bundle and verifies its checksum,
then runs ci.sh with DEXCLI set to the dexcli binary; dex-server-zd sits
in the same directory. ci.sh installs the package's locked dependencies, runs
its type checks and lint, and runs its integration tests. Tests start private
dexcli dev --engine zd instances on free ports. The Check Gate maps
agents/** and the workflow file to Agents CI.
- Each package has a README with installation, a complete example, the effect table, its integration mode, and its limits.
- Product docs in English and zh-Hans follow in a separate change, with snippets copied from runnable examples.
- Skills follow the first release that contains the packages.
N/A: the packages add no Dex Web UI. Dex Web shows an agent session as an ordinary Flow, as parent design C9 records, and Step mode gives every framework the same Step shapes.
The cores also carry the protocol adapters of parent design C6, for UIs that
applications build. They read AgentEvents through the Client with a resume
token and need no Server change and no other store.
@superdurable/dex-agents/ui-streamtranslates one turn into the AI SDK UI message stream (x-vercel-ai-ui-message-stream: v1). ARunModelStep execution is one AI SDK step, recognized by itsStepType-NumberStep execution ID; a new attempt of it emitsreset-step. Tool calls are dynamic tool parts whose arguments and results come fromAgentToolCalls, or from the stored messages once the turn ended. An interrupt is atool-approval-requestwhoseapprovalIdis theinterrupt_id, and atool-approval-responsecallsresolve_interruptfor an interrupt pending in the active turn. The stream finishes with reasontool-callswhen the turn waits only for approvals whose interrupts are still inPendingInterrupts, souseChatcan send the answers.- The UI stream resume token encodes the Dex resume token plus the parts the browser holds open, with one token after every part. A reconnect replays the token's turn by default, so any browser state rebuilds; an opt-in exact continuation serves a browser that kept its message. A Dex position the Server rejects restarts the turn from its listed start.
- Turn ends wait for the end's commit, and committed state ends a stream whose events the Stream lost, including with the Stream Store disabled.
- Both cores serve plain Server-Sent Events whose
idis the Dex resume token, so anEventSourceresumes throughLast-Event-ID, and a snapshot with the token to open the events from. The snapshot JSON leaves out the fields only the session's Steps use:framework_item,tool_source_reference,trace_context,input_continuation,input_answers, andtask_answer_pending. A rejected token gets a 400 before the stream starts. - Stop is pending: a durable turn cancel is not in the contract yet, so closing the browser response leaves the turn running.