@superdurable/dex-agents 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +1568 -62
  2. package/dist/src/agent-built-in-tools.d.ts +81 -0
  3. package/dist/src/agent-built-in-tools.js +246 -0
  4. package/dist/src/agent-built-in-tools.js.map +1 -0
  5. package/dist/src/agent-context-compaction.d.ts +108 -0
  6. package/dist/src/agent-context-compaction.js +39 -0
  7. package/dist/src/agent-context-compaction.js.map +1 -0
  8. package/dist/src/agent-framework-runner.d.ts +456 -16
  9. package/dist/src/agent-framework-runner.js +4 -3
  10. package/dist/src/agent-framework-runner.js.map +1 -1
  11. package/dist/src/agent-plans.d.ts +48 -0
  12. package/dist/src/agent-plans.js +86 -0
  13. package/dist/src/agent-plans.js.map +1 -0
  14. package/dist/src/agent-session-cancellation-steps.d.ts +28 -0
  15. package/dist/src/agent-session-cancellation-steps.js +154 -0
  16. package/dist/src/agent-session-cancellation-steps.js.map +1 -0
  17. package/dist/src/agent-session-codecs.d.ts +65 -4
  18. package/dist/src/agent-session-codecs.js +342 -8
  19. package/dist/src/agent-session-codecs.js.map +1 -1
  20. package/dist/src/agent-session-flow.d.ts +295 -27
  21. package/dist/src/agent-session-flow.js +368 -21
  22. package/dist/src/agent-session-flow.js.map +1 -1
  23. package/dist/src/agent-session-persistence.d.ts +81 -4
  24. package/dist/src/agent-session-persistence.js +33 -2
  25. package/dist/src/agent-session-persistence.js.map +1 -1
  26. package/dist/src/agent-session-settings.d.ts +58 -0
  27. package/dist/src/agent-session-settings.js +81 -0
  28. package/dist/src/agent-session-settings.js.map +1 -0
  29. package/dist/src/agent-session-step-options.d.ts +27 -0
  30. package/dist/src/agent-session-step-options.js +39 -0
  31. package/dist/src/agent-session-step-options.js.map +1 -0
  32. package/dist/src/agent-session-step-types.d.ts +20 -0
  33. package/dist/src/agent-session-step-types.js +28 -0
  34. package/dist/src/agent-session-step-types.js.map +1 -0
  35. package/dist/src/agent-session-steps.d.ts +101 -8
  36. package/dist/src/agent-session-steps.js +936 -197
  37. package/dist/src/agent-session-steps.js.map +1 -1
  38. package/dist/src/agent-session-tracer.d.ts +61 -0
  39. package/dist/src/agent-session-tracer.js +608 -0
  40. package/dist/src/agent-session-tracer.js.map +1 -0
  41. package/dist/src/agent-session-transitions.d.ts +189 -26
  42. package/dist/src/agent-session-transitions.js +680 -32
  43. package/dist/src/agent-session-transitions.js.map +1 -1
  44. package/dist/src/agent-session-values.d.ts +690 -29
  45. package/dist/src/agent-session-values.js +39 -4
  46. package/dist/src/agent-session-values.js.map +1 -1
  47. package/dist/src/agent-session-wait-steps.d.ts +53 -0
  48. package/dist/src/agent-session-wait-steps.js +389 -0
  49. package/dist/src/agent-session-wait-steps.js.map +1 -0
  50. package/dist/src/agent-tracing-options.d.ts +107 -0
  51. package/dist/src/agent-tracing-options.js +20 -0
  52. package/dist/src/agent-tracing-options.js.map +1 -0
  53. package/dist/src/ai-sdk/ai-sdk-model-client.d.ts +40 -0
  54. package/dist/src/ai-sdk/ai-sdk-model-client.js +235 -0
  55. package/dist/src/ai-sdk/ai-sdk-model-client.js.map +1 -0
  56. package/dist/src/ai-sdk/index.d.ts +1 -0
  57. package/dist/src/ai-sdk/index.js +9 -0
  58. package/dist/src/ai-sdk/index.js.map +1 -0
  59. package/dist/src/index.d.ts +9 -3
  60. package/dist/src/index.js +7 -2
  61. package/dist/src/index.js.map +1 -1
  62. package/dist/src/mcp/index.d.ts +3 -0
  63. package/dist/src/mcp/index.js +10 -0
  64. package/dist/src/mcp/index.js.map +1 -0
  65. package/dist/src/mcp/mcp-resource-and-prompt-tools.d.ts +29 -0
  66. package/dist/src/mcp/mcp-resource-and-prompt-tools.js +185 -0
  67. package/dist/src/mcp/mcp-resource-and-prompt-tools.js.map +1 -0
  68. package/dist/src/mcp/mcp-server-config.d.ts +86 -0
  69. package/dist/src/mcp/mcp-server-config.js +38 -0
  70. package/dist/src/mcp/mcp-server-config.js.map +1 -0
  71. package/dist/src/mcp/mcp-tool-source.d.ts +470 -0
  72. package/dist/src/mcp/mcp-tool-source.js +1204 -0
  73. package/dist/src/mcp/mcp-tool-source.js.map +1 -0
  74. package/dist/src/stepwise/agent-definition.d.ts +158 -0
  75. package/dist/src/stepwise/agent-definition.js +9 -0
  76. package/dist/src/stepwise/agent-definition.js.map +1 -0
  77. package/dist/src/stepwise/function-tool.d.ts +130 -0
  78. package/dist/src/stepwise/function-tool.js +75 -0
  79. package/dist/src/stepwise/function-tool.js.map +1 -0
  80. package/dist/src/stepwise/index.d.ts +7 -0
  81. package/dist/src/stepwise/index.js +12 -0
  82. package/dist/src/stepwise/index.js.map +1 -0
  83. package/dist/src/stepwise/json-schema-validation.d.ts +9 -0
  84. package/dist/src/stepwise/json-schema-validation.js +141 -0
  85. package/dist/src/stepwise/json-schema-validation.js.map +1 -0
  86. package/dist/src/stepwise/model-client.d.ts +194 -0
  87. package/dist/src/stepwise/model-client.js +9 -0
  88. package/dist/src/stepwise/model-client.js.map +1 -0
  89. package/dist/src/stepwise/scripted-model-client.d.ts +99 -0
  90. package/dist/src/stepwise/scripted-model-client.js +156 -0
  91. package/dist/src/stepwise/scripted-model-client.js.map +1 -0
  92. package/dist/src/stepwise/stepwise-agent-runner.d.ts +189 -0
  93. package/dist/src/stepwise/stepwise-agent-runner.js +804 -0
  94. package/dist/src/stepwise/stepwise-agent-runner.js.map +1 -0
  95. package/dist/src/stepwise/stepwise-agent-session-flow.d.ts +35 -0
  96. package/dist/src/stepwise/stepwise-agent-session-flow.js +56 -0
  97. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +1 -0
  98. package/dist/src/stepwise/stepwise-input-items.d.ts +12 -0
  99. package/dist/src/stepwise/stepwise-input-items.js +172 -0
  100. package/dist/src/stepwise/stepwise-input-items.js.map +1 -0
  101. package/dist/src/stepwise/tool-source.d.ts +103 -0
  102. package/dist/src/stepwise/tool-source.js +9 -0
  103. package/dist/src/stepwise/tool-source.js.map +1 -0
  104. package/dist/src/testing/agent-session-test-client.d.ts +65 -8
  105. package/dist/src/testing/agent-session-test-client.js +95 -22
  106. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  107. package/dist/src/testing/index.d.ts +1 -0
  108. package/dist/src/testing/index.js +1 -0
  109. package/dist/src/testing/index.js.map +1 -1
  110. package/dist/src/testing/private-dex-engine.d.ts +9 -0
  111. package/dist/src/testing/private-dex-engine.js +9 -1
  112. package/dist/src/testing/private-dex-engine.js.map +1 -1
  113. package/dist/src/testing/scripted-model-client.d.ts +1 -0
  114. package/dist/src/testing/scripted-model-client.js +10 -0
  115. package/dist/src/testing/scripted-model-client.js.map +1 -0
  116. package/dist/src/ui-stream/agent-event-json.d.ts +30 -0
  117. package/dist/src/ui-stream/agent-event-json.js +64 -0
  118. package/dist/src/ui-stream/agent-event-json.js.map +1 -0
  119. package/dist/src/ui-stream/agent-event-sse.d.ts +154 -0
  120. package/dist/src/ui-stream/agent-event-sse.js +159 -0
  121. package/dist/src/ui-stream/agent-event-sse.js.map +1 -0
  122. package/dist/src/ui-stream/agent-event-stream-positions.d.ts +42 -0
  123. package/dist/src/ui-stream/agent-event-stream-positions.js +153 -0
  124. package/dist/src/ui-stream/agent-event-stream-positions.js.map +1 -0
  125. package/dist/src/ui-stream/agent-session-record-reader.d.ts +72 -0
  126. package/dist/src/ui-stream/agent-session-record-reader.js +241 -0
  127. package/dist/src/ui-stream/agent-session-record-reader.js.map +1 -0
  128. package/dist/src/ui-stream/agent-ui-message-stream-responses.d.ts +219 -0
  129. package/dist/src/ui-stream/agent-ui-message-stream-responses.js +440 -0
  130. package/dist/src/ui-stream/agent-ui-message-stream-responses.js.map +1 -0
  131. package/dist/src/ui-stream/agent-ui-message-stream.d.ts +138 -0
  132. package/dist/src/ui-stream/agent-ui-message-stream.js +681 -0
  133. package/dist/src/ui-stream/agent-ui-message-stream.js.map +1 -0
  134. package/dist/src/ui-stream/agent-ui-message-types.d.ts +238 -0
  135. package/dist/src/ui-stream/agent-ui-message-types.js +57 -0
  136. package/dist/src/ui-stream/agent-ui-message-types.js.map +1 -0
  137. package/dist/src/ui-stream/agent-ui-messages.d.ts +66 -0
  138. package/dist/src/ui-stream/agent-ui-messages.js +220 -0
  139. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -0
  140. package/dist/src/ui-stream/agent-ui-stream-cursor.d.ts +64 -0
  141. package/dist/src/ui-stream/agent-ui-stream-cursor.js +186 -0
  142. package/dist/src/ui-stream/agent-ui-stream-cursor.js.map +1 -0
  143. package/dist/src/ui-stream/index.d.ts +14 -0
  144. package/dist/src/ui-stream/index.js +22 -0
  145. package/dist/src/ui-stream/index.js.map +1 -0
  146. package/dist/src/ui-stream/server-sent-event-responses.d.ts +6 -0
  147. package/dist/src/ui-stream/server-sent-event-responses.js +34 -0
  148. package/dist/src/ui-stream/server-sent-event-responses.js.map +1 -0
  149. package/package.json +41 -5
package/README.md CHANGED
@@ -7,6 +7,14 @@ the Steps, persistence, RPCs, live events, tool approval, and crash recovery.
7
7
  It runs in Step mode: each model call and each tool call is its own Dex Step,
8
8
  and the tool calls of one model response run in parallel.
9
9
 
10
+ The package also ships the built-in agent loop, **Stepwise mode**, for agents
11
+ defined in Dex terms without a framework (`@superdurable/dex-agents/stepwise`),
12
+ and its AI SDK model client (`@superdurable/dex-agents/ai-sdk`). Stepwise runs
13
+ on the same session Flow as every adapter, with the same Steps, RPCs, and
14
+ events. The session also handles two built-in tools under any runner:
15
+ `ask_user` asks the user a question and waits in `AwaitQuestionAnswer`, and
16
+ `durable_wait` suspends the turn on a Dex Timer in `AwaitTimer`.
17
+
10
18
  The session Flow implements the contract in
11
19
  [`docs/design/plan/agent-framework-adapters.md`](https://github.com/superdurable/dex/blob/main/docs/design/plan/agent-framework-adapters.md).
12
20
  Its Step types, persistence names, RPC names, wire values, and event payloads
@@ -19,19 +27,36 @@ Applications usually install an adapter next to this package. To build a
19
27
  runner of your own, install the core with the Dex SDK it supports:
20
28
 
21
29
  ```bash
22
- npm i @superdurable/dex-agents @superdurable/dex@1.30.0
30
+ npm i @superdurable/dex-agents @superdurable/dex@1.35.0
23
31
  ```
24
32
 
25
- `@superdurable/dex` 1.30.0 is a peer dependency, so the application, the core,
33
+ `@superdurable/dex` 1.35.0 is a peer dependency, so the application, the core,
26
34
  and every adapter share one copy. The Dex Registry matches Flow classes and
27
35
  RPC registrations by identity, so a second copy makes it reject the Flow
28
36
  (`steps must be a StepList`). Adapters list this package as a peer dependency
29
37
  for the same reason. Requirements: Node `^22.0.0 || ^24.0.0`.
30
38
 
31
- The package also exports `@superdurable/dex-agents/testing`. It starts a
32
- private `dexcli dev --engine zd` engine and Worker processes for integration
33
- tests, and the adapters' own tests import it from the published core. It is
34
- for tests only; it needs `DEXCLI` and `dex-server-zd`.
39
+ `@opentelemetry/api` (`^1.3.0`) is an optional peer dependency. Install it and
40
+ an SDK for OpenTelemetry spans; see [Tracing](#tracing).
41
+
42
+ The package also exports `@superdurable/dex-agents/ui-stream`, the
43
+ [UI stream adapters](#ui-streams), and `@superdurable/dex-agents/testing`. The
44
+ testing export starts a private `dexcli dev --engine zd` engine and Worker
45
+ processes for integration tests, and the adapters' own tests import it from
46
+ the published core. It is for tests only; it needs `DEXCLI` and
47
+ `dex-server-zd`.
48
+
49
+ | Subpath | Contents | Extra dependency |
50
+ | --- | --- | --- |
51
+ | `@superdurable/dex-agents` | Session Flow, runner contract, built-in tool definitions | none |
52
+ | `@superdurable/dex-agents/stepwise` | Stepwise mode: `createStepwiseAgentSessionFlow`, `functionTool`, `ToolSource`, `ModelClient`, `ScriptedModelClient` | none |
53
+ | `@superdurable/dex-agents/ai-sdk` | `createAiSdkModelClient` for any AI SDK `LanguageModel` | optional peer `ai@^7.0.159`, the Vercel AI SDK adapter's range |
54
+ | `@superdurable/dex-agents/mcp` | MCP servers' tools as agent tools; see [MCP tools](#mcp-tools-superdurabledex-agentsmcp) | optional peer `@modelcontextprotocol/sdk@^1.32.2` |
55
+ | `@superdurable/dex-agents/ui-stream` | Browser streams of live sessions; see [UI streams](#ui-streams) | none |
56
+ | `@superdurable/dex-agents/testing` | Test engine, Worker processes, waits, and `ScriptedModelClient` (the same class as the stepwise entry point's) | none |
57
+
58
+ Install `ai` and a provider package only for `./ai-sdk`; importing that
59
+ subpath without `ai` fails with `Cannot find package 'ai'`.
35
60
 
36
61
  Develop the package inside this repository with `./ci.sh`. The adapters in
37
62
  this repository depend on it as a `file:../core` devDependency with
@@ -90,7 +115,597 @@ persistence definitions are properties of the Flow (`flow.agentState`,
90
115
  | `toolPolicies` | `{}` | `ToolPolicy` by tool name; overrides framework metadata and the default effect |
91
116
  | `defaultToolEffect` | `"unclassified"` | Effect of a tool without a policy or framework metadata |
92
117
  | `eventStreamCapacityBytes` | 10 MiB | Byte budget of the `AgentEvents` Stream, shared by every session of the Flow type |
93
- | `interruptDeadlineMs` | none | Milliseconds after which a pending approval is rejected with `"approval deadline passed"` |
118
+ | `interruptDeadlineMs` | none | Milliseconds after which a pending approval or question is rejected with `"approval deadline passed"` or `"question deadline passed"` |
119
+ | `builtInTools` | `[]` | Built-in tools the session handles: `"ask_user"`, `"durable_wait"`. An enabled name is reserved; a tool policy for it is refused |
120
+ | `maxDurableWaitMs` | 365 days | Longest `durable_wait`, a positive multiple of 1,000; a longer `duration_seconds` fails the call at once. The Python core's `max_durable_wait` |
121
+ | `messageWindowSize` | 64 | Most messages the `AgentMessages` window holds; at least `messageChunkSize`. See [Message storage and retention](#message-storage-and-retention) |
122
+ | `messageChunkSize` | 32 | Most messages per archive chunk |
123
+ | `messageChunkMaxBytes` | 1 MiB | Most bytes per archive chunk, 1 to 8,388,608; a larger message is stored straight into its own chunk |
124
+ | `retainedMessageLimit` | 1,000 | With `compaction`, the stored messages retention always keeps; `null` keeps every message |
125
+
126
+ Enable built-in tools and call `answer_question` only after every Worker of the
127
+ Flow type runs 0.2.0, because a 0.1.0 Worker cannot decode the new non-final
128
+ statuses.
129
+
130
+ ## Stepwise mode
131
+
132
+ Stepwise is the built-in agent loop. Declare the agent, its function tools,
133
+ and its model client; `createStepwiseAgentSessionFlow` builds the session Flow
134
+ with a `StepwiseAgentRunner` and derives the session options from the
135
+ definition.
136
+
137
+ ```ts
138
+ import { openai } from "@ai-sdk/openai";
139
+ import { createAiSdkModelClient } from "@superdurable/dex-agents/ai-sdk";
140
+ import { createStepwiseAgentSessionFlow, functionTool } from "@superdurable/dex-agents/stepwise";
141
+
142
+ const lookupOrder = functionTool<{ order_id: string }>({
143
+ name: "lookup_order",
144
+ description: "Look up an order by its ID.",
145
+ parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
146
+ effect: "read",
147
+ execute: async ({ order_id }) => orders.get(order_id),
148
+ });
149
+ const refundOrder = functionTool<{ order_id: string }>({
150
+ name: "refund_order",
151
+ description: "Refund an order.",
152
+ parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
153
+ effect: "non_idempotent_write",
154
+ execute: async ({ order_id }, context) => payments.refund(order_id, { idempotencyKey: context.idempotencyKey }),
155
+ });
156
+
157
+ export const supportAgentFlow = createStepwiseAgentSessionFlow(
158
+ {
159
+ name: "support",
160
+ instructions: "You help customers with their orders.",
161
+ model: createAiSdkModelClient(openai("gpt-5-mini")),
162
+ tools: [lookupOrder, refundOrder],
163
+ builtInTools: ["ask_user", "durable_wait"],
164
+ },
165
+ "SupportAgent",
166
+ );
167
+ ```
168
+
169
+ | `AgentDefinition` field | Default | Meaning |
170
+ | --- | --- | --- |
171
+ | `name` | required | Agent name |
172
+ | `instructions` | required | A string, or a function of `{ flowId, turnId, attempt, interactionMode, plan }` evaluated in every `RunModel`, so a deploy changes the next model call |
173
+ | `model` | required | A `ModelClient`, such as `createAiSdkModelClient(model)` |
174
+ | `modelSettings` | none | `temperature`, `maxOutputTokens`, `reasoningEffort`, and `providerOptions`, passed through unchanged |
175
+ | `tools` | `[]` | Function tools from `functionTool` |
176
+ | `toolSources` | `[]` | Tool sources such as `McpToolSource`, listed in every `RunModel`; see [Tool sources](#tool-sources) |
177
+ | `toolPolicies` | `{}` | `ToolPolicy` by tool name; overrides a function tool's own effect and approval |
178
+ | `defaultToolEffect` | `"unclassified"` | Effect of a tool that declares none |
179
+ | `builtInTools` | `[]` | Built-in tools the model sees |
180
+ | `maxModelCallsPerTurn` | 100 | After this many model calls in one turn, the next call offers no tools, so the model must answer. If it still asks for tools, the turn ends and each call gets the result `The tool call was not run: this turn reached its limit of <n> model calls.` |
181
+ | `planModeEnabled` | `false` | Accept `planMode` user messages; requires `write_todos` in `builtInTools`. See [Plans and plan mode](#plans-and-plan-mode) |
182
+ | `interruptDeadlineMs`, `eventStreamCapacityBytes` | as for the session | Passed to the session options |
183
+
184
+ Each `RunModel` makes at most one model call, built from committed state only:
185
+ the instructions, the stored messages, the plan note from `buildPlanNote`
186
+ before the turn's messages when a plan exists or the turn plans, and the
187
+ results of the previous model response from the turn's final records. The runner stores the turn's
188
+ transcript as it goes: `ToolCallsRequested.newMessages` carries the previous
189
+ round's tool results and the new model output, so `FrameworkRunState` holds
190
+ only `{"model_calls": <n>}`. The input builder pairs every stored tool call
191
+ with a result, and a missing one gets the outcome-unknown text, so an orphaned
192
+ call never reaches a provider.
193
+
194
+ A function tool declares its effect, which picks `CallTool` or
195
+ `CallSingleAttemptTool` and the default approval. The runner checks the
196
+ model's arguments against the tool's JSON Schema before `execute` runs, and a
197
+ mismatch or an unknown tool reaches the model as a tool failure. `execute`
198
+ receives the arguments and a `ToolCallContext` with the idempotency key,
199
+ attempt, events, heartbeat, and the answers to the tool's own questions. A
200
+ `string` result is the output, and a `ToolInputRequested` result asks the user
201
+ first. Any other result is JSON with the separators of Python's `json.dumps`,
202
+ so the model sees the same text as from the Python core; the one exception is a
203
+ whole-number float such as `1.0`, which JavaScript cannot tell from `1`. The
204
+ argument check and its messages match the Python core, including Python's `==`
205
+ for `enum`, so `true` matches `1`.
206
+
207
+ The session's tool policies, `turn.toolPolicies`, win over a function tool's own
208
+ effect and `requiresApproval`, whether they come from the agent definition or
209
+ from options passed to `createAgentSessionFlow` with a `StepwiseAgentRunner`. A
210
+ built-in tool enabled only in those options must not share a function tool's
211
+ name; the first `RunModel` fails before it calls the model.
212
+
213
+ `createAiSdkModelClient(model, options?)` makes one `streamText` call per
214
+ `RunModel`, with `stopWhen: stepCountIs(1)`, `maxRetries: 0`, and the tools as
215
+ definitions without `execute`. The AI SDK therefore makes exactly one model
216
+ call and runs no tool loop, no tool, and no retry; a retry is a `RunModel`
217
+ attempt. Text and reasoning deltas stream to `AgentEvents`. Each response is
218
+ stored as the AI SDK assistant message, as JSON text, with every part's
219
+ `providerOptions` (thinking signatures, encrypted reasoning) exactly as the AI
220
+ SDK returned them. It is replayed unchanged to a model of the same provider;
221
+ any other provider gets its text and tool calls only, so a session can change
222
+ model between turns.
223
+
224
+ ### Tool sources
225
+
226
+ `toolSources` takes tools that are listed at run time, such as an MCP server's
227
+ tools. Any object with a `sourceName` and `listTools`, `deferredToolCall`,
228
+ `executeDeferredToolCall`, and `pollToolTask` is a `ToolSource`;
229
+ `McpToolSource` is one:
230
+
231
+ ```ts
232
+ import { McpToolSource } from "@superdurable/dex-agents/mcp";
233
+
234
+ const files = new McpToolSource(
235
+ [{ transport: "stdio", name: "files", command: "mcp-server-files", args: ["/srv/reports"] }],
236
+ { resourceAndPromptToolsEnabled: true },
237
+ );
238
+ export const reportsAgentFlow = createStepwiseAgentSessionFlow(
239
+ {
240
+ name: "reports",
241
+ instructions: "You manage reports.",
242
+ model: createAiSdkModelClient(openai("gpt-5-mini")),
243
+ tools: [lookupOrder],
244
+ toolSources: [files],
245
+ builtInTools: ["ask_user"],
246
+ },
247
+ "ReportsAgent",
248
+ );
249
+ ```
250
+
251
+ In every `RunModel` of a chat or executing turn, the runner awaits each
252
+ source's `listTools`, for at most `toolSourceListingTimeoutMs` (10 seconds),
253
+ and offers the tools after the function tools and before the built-in tools. A
254
+ source whose listing throws or takes longer offers no tools in that model
255
+ call, with a warning, so a down server never fails a turn. A planning turn,
256
+ and a call after the model call limit, list nothing.
257
+
258
+ A call of a listed tool becomes the source's `deferredToolCall`, so the
259
+ source's policy, effect, and `toolSourceReference` apply: approvals, `CallTool`
260
+ or `CallSingleAttemptTool`, the idempotency key, and tool tasks work as for any
261
+ tool. The runner passes the definition it offered as `listedTool`, so a
262
+ listing refreshed during the model call cannot change it. It stores the
263
+ source's reference as `{"stepwise_tool_source": <sourceName>, "reference":
264
+ ...}`, so the tool Step and `PollToolTask` reach the source that built the
265
+ call, with its own reference, on any Worker, without a listing, and whatever
266
+ the order of `toolSources`. Source names must be unique and stable across
267
+ deploys: `McpToolSource` defaults its `sourceName` to `"mcp"`, which adding or
268
+ removing a server keeps, and takes a `sourceName` option. Two `McpToolSource`s
269
+ in one agent must therefore name at least one of them. A stored call whose source name this Worker does not have throws a
270
+ `RangeError`, so its effect decides the outcome. A name that no listing
271
+ offered, such as a tool of a server that was down, fails as `unknown tool
272
+ <name>` without an approval (`unclassified`, no approval): it never reaches a
273
+ source.
274
+
275
+ A listed name that repeats a function tool, an enabled built-in tool, or
276
+ another source's tool fails the `RunModel` attempt with a `TypeError` before
277
+ any model request: `tool <name> uses the name of an enabled built-in tool`,
278
+ `tool <name> of a tool source uses the name of a function tool`, or `two tool
279
+ sources list a tool named <name>`, as in the Python core. A source may define
280
+ `declaredToolNames()`, names it always lists, which the runner checks when it
281
+ is constructed; two `McpToolSource`s with the resource and prompt tools are
282
+ refused there. A source may also define `cachedTools()`, its last listing
283
+ without I/O, which `estimateInputTokens` counts; otherwise it counts the
284
+ runner's last listing of that source, as the Python core does.
285
+
286
+ ### Demo model without a key
287
+
288
+ `ScriptedModelClient` from `@superdurable/dex-agents/stepwise` answers each
289
+ call from a function of the `ModelRequest`, so an agent runs without a model
290
+ key: a local demo mode, a tutorial, or a test. The function returns text,
291
+ reasoning, and tool calls, sync or async; `ScriptedModelClient.userTexts` and
292
+ `ScriptedModelClient.toolResults` read the request. `requests` keeps the
293
+ latest `maxRecordedRequests` requests, 100 by default. It mirrors the Python
294
+ core's. Keep the script deterministic, because a crashed `RunModel` reruns
295
+ it. `@superdurable/dex-agents/testing` exports the same class.
296
+
297
+ ```ts
298
+ import { ScriptedModelClient, type ModelRequest, type ScriptedModelReply } from "@superdurable/dex-agents/stepwise";
299
+
300
+ function replyAsDemoModel(request: ModelRequest): ScriptedModelReply {
301
+ const results = ScriptedModelClient.toolResults(request);
302
+ if (results.length > 0) {
303
+ return { text: results.map((result) => result.output).join("; ") };
304
+ }
305
+ return { text: `Demo reply: ${ScriptedModelClient.userTexts(request).at(-1) ?? ""}` };
306
+ }
307
+
308
+ const model = isDemo ? new ScriptedModelClient(replyAsDemoModel) : createAiSdkModelClient(openai("gpt-5-mini"));
309
+ ```
310
+
311
+ The stored `framework_item` of a Stepwise message is one of
312
+ `{"type":"user_text","text"}`,
313
+ `{"type":"model_output","provider","api","item","text","tool_calls"}`, or
314
+ `{"type":"tool_result","tool_call_id","tool_name","output"}`, the same as the
315
+ Python core.
316
+
317
+ ## Built-in tools
318
+
319
+ `builtInToolDefinitions` holds the name, description, and JSON Schema of each
320
+ built-in tool, with the same text as the Python core. A runner offers the ones
321
+ in `turn.enabledBuiltInTools` to its model and returns their calls like any
322
+ other. `RunModel` routes a call whose name is an enabled built-in tool to the
323
+ session's own handling, with a fixed policy (`read`, no approval), and
324
+ validates its arguments in the same commit. A `null` optional argument counts
325
+ as absent, and a blank check strips the same whitespace as Python's
326
+ `str.strip()`. Invalid arguments record `failed` with
327
+ `invalid <tool> arguments: <reason>`, which the model sees after `ResumeTurn`.
328
+
329
+ | Built-in tool | Arguments | Step | Result the model sees |
330
+ | --- | --- | --- | --- |
331
+ | `ask_user` | `{ question, choices?, allows_free_text? }` | `AwaitQuestionAnswer` | `{"status":"answered","answer":"<text>"}`; `The user declined to answer.` or the rejection message when declined; `question deadline passed` after the deadline |
332
+ | `durable_wait` | `{ duration_seconds, reason }`, 1 to `maxDurableWaitMs` | `AwaitTimer` | `{"status":"completed","waited_seconds":<n>,"reason":"<reason>"}` |
333
+ | `write_todos` | `{ todos: [{ content, status }] }` | none; `RunModel` records it | `{"status":"updated","revision":<n>,"task_count":<k>}` or `{"status":"cleared","revision":<n>}`; see [Plans and plan mode](#plans-and-plan-mode) |
334
+
335
+ `ask_user` raises a `question` interrupt whose ID is the tool call ID. Answer it
336
+ with `answer_question`; decline it with `resolve_interrupt` and `approved`
337
+ `false`. A tool can ask a question too: `executeDeferredToolCall` returns
338
+ `ToolInputRequested { kind: "tool_input_requested", question, choices,
339
+ allowsFreeText, continuation }` before it takes any effect. The tool Step runs
340
+ without the `PendingInterrupts` lock, so it only records `awaiting_answer` and
341
+ the question; `AwaitQuestionAnswer` then raises a question with ID
342
+ `<toolCallId>-input-<n>` under the lock, so parallel tools can ask at once.
343
+ After the answer, the same tool Step type runs the call again with
344
+ `DeferredToolExecution.inputAnswers` and `toolCall.inputContinuation`. If
345
+ `<toolCallId>-input-<n>` is another tool call's ID in the same turn, the asking
346
+ call records `failed` with `the tool's question cannot use interrupt ID <id>,
347
+ which another tool call of this turn uses`, and no question is raised.
348
+
349
+ ## Plans and plan mode
350
+
351
+ The `write_todos` built-in replaces the session's plan, the `AgentPlan`
352
+ Attribute, as a whole. A task's status is `pending`, `in_progress`, or
353
+ `completed`. `RunModel` records the call in its own commit, publishes its
354
+ result, and still moves to `ResumeTurn`; no tool Step runs. Each call that
355
+ succeeds writes revision + 1: no tasks is `cleared`, a planning turn writes a
356
+ `draft`, every task completed is `completed`, and anything else is `active`.
357
+ The session writes `plan_updated`, and again when an approval marks a draft
358
+ `active`. A response with more than one `write_todos`
359
+ call fails each of them with `write_todos was called more than once in one
360
+ response`; invalid arguments fail with `invalid todos: <reason>`.
361
+
362
+ Plan mode reviews a plan before it runs. Enable it with
363
+ `planModeEnabled: true`, which needs `write_todos` in `builtInTools` and a
364
+ runner whose `supportsPlanMode` is `true`, such as the Stepwise runner.
365
+ `AgentDefinition.planningToolChoice` is `"auto"` by default: a planning turn
366
+ offers only `write_todos`, the plan note tells the model to write the plan with
367
+ it, and a turn that answers in text, such as with a clarifying question, ends
368
+ without a draft. `"required"` forces `write_todos` on the first model call of a
369
+ planning turn while no draft exists. It needs a provider and model that accept a
370
+ forced tool choice; Claude Opus 5.5, Claude Sonnet 5.5, and Anthropic extended
371
+ thinking refuse one and fail every attempt of that model call. This core cannot
372
+ check the model when the definition is built, as the Python core does.
373
+
374
+ A decoded TypeScript value omits a field that plans added while it holds its
375
+ default: `AgentState.interactionMode` `chat`, `AgentUserMessage.planMode`
376
+ `false`, and `AgentSnapshot.plan` `null`. A session that uses no plans
377
+ therefore decodes to its 0.1.0 shape, and code that compares whole values keeps
378
+ working. The wire always carries the field, and `null` reads as the default.
379
+
380
+ ```ts
381
+ await client.invokeRPC(flow.sendUserMessage, sessionId, { text: "Plan the migration", planMode: true });
382
+ const snapshot = await client.invokeRPC(flow.getAgentSnapshot, sessionId);
383
+ if (snapshot.plan?.status === "draft") {
384
+ await client.invokeRPC(flow.executePlan, sessionId, { revision: snapshot.plan.revision });
385
+ }
386
+ ```
387
+
388
+ - A message with `planMode`, or any message while a `draft` plan exists, starts
389
+ a turn whose `AgentState.interactionMode` is `planning`. The runner reads it
390
+ from `AgentTurnContext.interactionMode` and offers only `write_todos`. `RunModel` rejects
391
+ any other call of a planning turn with `The tool call was not run: a planning
392
+ turn can only call write_todos.`. Feedback on a draft is an ordinary message
393
+ that revises it.
394
+ - `send_user_message` returns `false` for a `planMode` message when plan mode
395
+ is off.
396
+ - `execute_plan({ revision })` returns `true` only while the session waits for
397
+ a user message, the plan has that revision and is `draft` or `active`, no
398
+ execution of it is pending, and nothing is queued, steered, or pending. It records the
399
+ approval in `PendingPlanExecution` and publishes it to the `PlanExecutions`
400
+ ChannelMap instance of that revision. It locks `PendingPlanExecution`, so a
401
+ concurrent call fails with `RpcLockConflictError`, which is safe to retry,
402
+ and then returns `false`.
403
+ - `AwaitUserMessage` waits on the approval of the current revision only, after
404
+ queued messages, so an approval of a revision that a later turn replaced
405
+ never runs. It marks the plan `active`, writes `plan_execution_started`, and
406
+ starts an `executing` turn with every tool, whose user message is `[Dex] The
407
+ user approved plan revision <n>. Execute it and keep the task statuses
408
+ current with write_todos.`.
409
+ - `buildPlanNote(plan, interactionMode)` returns the note that shows the model
410
+ the plan. Stepwise adds it as user text before the turn's messages in every
411
+ model call, so the model always sees the latest plan.
412
+
413
+ ## Steering and turn cancellation
414
+
415
+ A user message is queued or steered. `send_user_message` queues it: it waits
416
+ until the running turn ends, and the user can still delete or steer it.
417
+ `steer_user_message` steers the running turn with new text or with a queued
418
+ message, and `cancel_turn` stops it with no new turn.
419
+
420
+ ```ts
421
+ await client.invokeRPC(flow.sendUserMessage, sessionId, { text: "Also check order 8" });
422
+ const snapshot = await client.invokeRPC(flow.getAgentSnapshot, sessionId);
423
+ const [queued] = snapshot.queuedMessages ?? [];
424
+ if (queued !== undefined) {
425
+ await client.invokeRPC(flow.steerUserMessage, sessionId, { messageId: queued.messageId, text: null });
426
+ }
427
+ await client.invokeRPC(flow.steerUserMessage, sessionId, { messageId: null, text: "Use staging instead" });
428
+ await client.invokeRPC(flow.cancelTurn, sessionId, { turnId: snapshot.state?.activeTurnId ?? null });
429
+ ```
430
+
431
+ A steered turn ends at its next safe Step boundary. Steering never interrupts
432
+ a model call or a tool call that has started: a running model call finishes,
433
+ and a final answer it returns completes the turn as usual; a tool that has
434
+ started runs to the end, and so does a tool task it started, questions
435
+ included. `cancel_turn` stops the turn now instead: it aborts a running model
436
+ call or compaction through `cancellationSignal`, and its answer is dropped,
437
+ and it cancels every wait for an approval, an answer, a durable wait's Timer,
438
+ or a tool task's next poll. A tool call Step that has started still runs to
439
+ the end, and the turn ends after it. A started tool task is abandoned: its call
440
+ records `outcome_unknown` with `cancellationReason` `"turn_cancelled"`, and a
441
+ runner that implements `cancelToolTask` is asked to cancel the task,
442
+ best-effort; a result the task returns later is dropped. A call that has not
443
+ started records
444
+ `rejected` with `cancellationReason` `"steered"` or `"turn_cancelled"`, and the
445
+ model later sees `toolCallCancelledBySteeringResult` or
446
+ `toolCallCancelledByTurnCancellationResult` as its result. That includes
447
+ approvals, questions, and durable waits that were still waiting. A call that
448
+ is settled first keeps its own result: an `ask_user` answer that commits
449
+ first, a durable wait whose Timer fires first, and a built-in call with invalid
450
+ arguments, which fails at once. A UI can briefly show an approval or a
451
+ question of a turn that is already steered; answering it then returns `false`
452
+ or has no effect. `ApplyTurnControl` then ends the turn. When the runner
453
+ implements `buildUnfinishedTurnMessages`, it stores the turn's exact
454
+ transcript; otherwise it stores a note that lists the calls that ran, starting
455
+ with `turnSteeredNoteHeader` or `turnCancelledNoteHeader`. A steered turn is
456
+ followed by a new turn whose user messages are every steered message, in
457
+ order. That turn is a `planning` turn when any of its messages has `planMode`
458
+ or a `draft` plan exists, as for a queued message; a queued `planMode` message
459
+ moved by its ID keeps `planMode`. A `write_todos` call settles in `RunModel`'s
460
+ own commit, like a built-in call with invalid arguments, so one that a model
461
+ call made while the user steered still replaces the plan.
462
+
463
+ While no turn is active, there is nothing to steer: `steer_user_message` with
464
+ `text` queues the message like `send_user_message`, so it starts the next turn
465
+ at once.
466
+
467
+ - `steer_user_message` and `delete_queued_message` take the Channel message ID
468
+ from `AgentSnapshot.queuedMessages`. Both return `false` for a message that
469
+ is no longer queued. When the session consumes the message while the RPC
470
+ runs, the RPC fails with `ChannelMessageNotFoundError` and changes nothing;
471
+ reload the snapshot and let the user choose again.
472
+ - `cancel_turn` returns `true` when the request is recorded. The turn may still
473
+ complete first, when its model call committed the final answer before the
474
+ RPC committed. A second call for the same turn returns `true` and changes
475
+ nothing.
476
+ - `steer_user_message` and `delete_queued_message` lock nothing. `cancel_turn`
477
+ locks `TurnCancellationRequest` and `PendingInterrupts`, which only short
478
+ Steps lock, so concurrent cancels apply in order and none of them waits for a
479
+ model call; a rare `RpcLockConflictError` is safe to retry.
480
+ - `resolve_interrupt` or `answer_question` racing a steer either commits
481
+ first or returns `false`. An approval or a tool's answer that commits first
482
+ moves its call on, and the tool Step then records it cancelled before the
483
+ tool starts; an `ask_user` answer that commits first is the call's result.
484
+ - A turn that a Worker without steering started or advanced cannot be steered
485
+ or cancelled: `AgentState.acceptsTurnControl` is `false`, and both RPCs
486
+ return `false`. It runs to its end as before. Call the RPCs only after every
487
+ Worker of the Flow type runs this release and every turn that was active
488
+ during the rollout has ended, because such a turn can hold a wait that an
489
+ earlier Worker began, which a steer never wakes.
490
+
491
+ ## Compaction
492
+
493
+ Compaction keeps the model input under the context window. Pass
494
+ `compaction: { contextWindowTokens, triggerRatio?, retainedRatio? }` to
495
+ `createAgentSessionFlow`, or as `AgentDefinition.compaction` for Stepwise. The
496
+ ratios default to 0.85 and 0.3, as in the Python core.
497
+
498
+ ```ts
499
+ const flow = createAgentSessionFlow(runner, "SupportAgent", {
500
+ compaction: { contextWindowTokens: 400_000 },
501
+ });
502
+ ```
503
+
504
+ Before each model call, `RunModel` asks the runner for
505
+ `estimateInputTokens(turn)`. At `triggerRatio` of the window it asks for
506
+ `selectCompactionCutoff(turn, retainedTokenBudget)`, the last message to
507
+ summarize; when that sequence is past the current summary, `RunModel` sets
508
+ status `compacting_context` and moves to `CompactContext` without a model call.
509
+ `CompactContext` has `RunModel`'s timeouts, retries, and failure route. It
510
+ calls `summarizeContext(request)` once, stores the `ContextSummary` Attribute,
511
+ records the call's usage, writes `compaction_started` and
512
+ `compaction_finished`, and moves back to `RunModel`. A crash reruns only the
513
+ summary call. From then on the runner receives the messages after the summary
514
+ in `turn.messages` and the summary in `turn.contextSummary`.
515
+
516
+ Summarized messages stay stored until retention deletes them; see
517
+ [Message storage and retention](#message-storage-and-retention). A compaction policy needs a runner with all
518
+ three optional methods, and `createAgentSessionFlow` throws a `TypeError`
519
+ otherwise. `StepwiseAgentRunner` implements them: its input starts with
520
+ `contextSummaryNoteHeader` (`[Dex] Summary of the earlier conversation:`) and
521
+ the summary, it cuts only between model responses whose tool calls all have
522
+ stored results, so a turn with many tool calls compacts mid-turn without
523
+ separating a call from its result, and it summarizes with
524
+ `contextSummaryInstructions` and no tools. `request.inputTokenBudget` carries
525
+ `contextWindowTokens`; when the summary request plus the definition's
526
+ `maxOutputTokens`, if set, would exceed it, the runner leaves out the oldest
527
+ messages, up to a cut that keeps each model response and each tool call with
528
+ its result whole, and says so with
529
+ `buildContextSummaryOmissionNote(omittedCount)`, the same text as the Python
530
+ core's. Each `CompactContext` attempt emits a `chat` span for the summary call,
531
+ as `RunModel` does; return `modelCallMetadata` from `summarizeContext` to name
532
+ it. The AI SDK UI message stream sends
533
+ the compaction events and the summary call's `usage_recorded` as event data,
534
+ outside the assistant message's steps.
535
+
536
+ ## Session settings
537
+
538
+ Session settings let one Flow type serve sessions that differ, such as by
539
+ model, without a Worker pool per model. They are one JSON object that the
540
+ application sets when it starts the session and can replace later:
541
+
542
+ ```ts
543
+ const models: Record<string, ModelClient> = {
544
+ "gpt-5-mini": createAiSdkModelClient(openai("gpt-5-mini")),
545
+ "gpt-5": createAiSdkModelClient(openai("gpt-5")),
546
+ };
547
+ const flow = createStepwiseAgentSessionFlow(
548
+ {
549
+ name: "support",
550
+ instructions: "You help customers with their orders.",
551
+ model: models["gpt-5-mini"],
552
+ // Choose only from clients the Worker built; the settings come from the application.
553
+ resolveModel: (settings) => models[String(settings.model)] ?? models["gpt-5-mini"],
554
+ },
555
+ "SupportAgent",
556
+ );
557
+
558
+ validateSessionSettings({ model: "gpt-5" });
559
+ await client.startFlow(flow, sessionId, { initialMessage: "Hello", sessionSettings: { model: "gpt-5" } });
560
+ await client.invokeRPC(flow.updateSessionSettings, sessionId, { sessionSettings: { model: "gpt-5-mini" } });
561
+ ```
562
+
563
+ - `StartAgentSession` stores `AgentSessionStartInput.sessionSettings` in the
564
+ `SessionSettings` Attribute. `update_session_settings` replaces it; nothing
565
+ is merged, and `{}` clears it. It returns `false` before the session
566
+ initialized.
567
+ - A change takes effect when the next turn starts, the same boundary as
568
+ `planMode`. The Step that starts a turn copies the settings into
569
+ `TurnSessionSettings`, tagged with the turn, and the turn's model calls,
570
+ retries, and compaction read only that copy, so a running turn keeps the
571
+ settings it started with, even across a Worker crash. A copy that an earlier
572
+ turn left reads as no settings.
573
+ - A runner reads `AgentTurnContext.sessionSettings`, `{}` when there are
574
+ none, such as in a session started on 0.1.0. `ContextSummaryRequest` carries
575
+ the same value. Stepwise's `AgentDefinition.resolveModel` maps it to the
576
+ `ModelClient` of each model call, including summary calls and token
577
+ estimates, and `InstructionContext.sessionSettings` passes it to an
578
+ instructions function.
579
+ - `get_agent_snapshot` returns the next turn's settings as
580
+ `AgentSnapshot.sessionSettings`, not the running turn's: right after an
581
+ update, the running turn may still use the earlier settings.
582
+ - Only model calls see the settings. Tool calls do not receive them, and the
583
+ compaction policy's `contextWindowTokens` is one value for every resolved
584
+ model. This core does not check whether a resolved model accepts the forced
585
+ tool choice of `planningToolChoice: "required"`.
586
+ - **Never put a secret, or anything a browser must not see, in settings.**
587
+ They are Flow state that `get_agent_snapshot` returns, and the UI stream's
588
+ browser snapshot JSON includes them as stored. Put a reference, such as a
589
+ model or credential name, and let the Worker resolve it. An error that
590
+ `resolveModel` throws becomes the turn's `lastTurnError`, which snapshots
591
+ also show, so keep the settings out of its message.
592
+ - The compact JSON text, UTF-8, is at most `MAX_SESSION_SETTINGS_BYTES` (16
593
+ KiB). `validateSessionSettings` and `sessionSettingsSize` check it, and
594
+ `AgentSessionTestClient.startSession` calls it. The cores write some floats
595
+ and U+2028/U+2029 differently, so the session's own checks accept
596
+ `SESSION_SETTINGS_SIZE_TOLERANCE_BYTES` (1 KiB) more:
597
+ `update_session_settings` returns `false` beyond that, or for `null`, and
598
+ `StartAgentSession` fails the session at once, with no retry, with a reason
599
+ that starts with `invalidStartSessionSettingsReason`.
600
+ - Start sessions with settings, and call `update_session_settings`, only after
601
+ every Worker of the Flow type runs 0.2.0. A 0.1.0 Worker drops the start
602
+ settings and starts turns without them.
603
+
604
+ ## Message storage and retention
605
+
606
+ > **Upgrading from 0.1.0: stop every 0.1.0 Worker of a Flow type before any
607
+ > 0.2.0 Worker of it starts.** A 0.2.0 Worker moves an upgraded session's older
608
+ > messages out of `AgentMessages` into `AgentMessageChunks`. If a 0.1.0 Worker
609
+ > still runs, it fails the turns it takes on such a session, because it reads
610
+ > every message from `AgentMessages` and cannot read the ones moved into
611
+ > chunks.
612
+
613
+ A session stores its messages in a small mutable window and immutable
614
+ archive chunks, so neither a Step's load nor Flow state grows with the whole
615
+ session:
616
+
617
+ - `AgentMessages` is the window: the newest messages, one instance per
618
+ message, keyed by `messageSequenceKey(sequence)`. Every Step that stores
619
+ messages loads the whole window, so an append writes only the new instance.
620
+ - When an append takes the window past `messageWindowSize` (default 64), its
621
+ oldest messages move, in the same commit, into `AgentMessageChunks`
622
+ instances keyed by `messageSequenceKey` of their first sequence. A chunk
623
+ closes at `messageChunkSize` messages (default 32) or `messageChunkMaxBytes`
624
+ (default 1 MiB, measured on the JSON this core writes), whichever comes
625
+ first. A message whose chunk of one, the message plus 15 bytes, would pass
626
+ `messageChunkMaxBytes` never enters the window: the Step that stores it
627
+ writes it straight into its own chunk. So its first store already writes the
628
+ largest value it will ever need, and a message too large for the server's
629
+ `values.maxValueBytes` fails that Step, which fails the turn, instead of a
630
+ later rollover. A chunk is never rewritten.
631
+ - One commit moves at most 4 chunks and 8 MiB
632
+ (`messageRolloverMaxChunksPerCommit`, `messageRolloverMaxBytesPerCommit`), so
633
+ a backlog, such as a large 0.1.0 history, moves over several commits, and
634
+ the window can briefly stay over its size, such as after one commit stores
635
+ many messages, until it drains.
636
+ - `AgentMessageIndex` records `chunks`, the first and last sequence of every
637
+ kept chunk in order, `firstSequence` (the oldest stored message), and
638
+ `windowFirstSequence`. A chunk at or after `windowFirstSequence` holds one
639
+ oversized message among the window's. The index is written only when a
640
+ rollover, an oversized message, or retention changes it, not on every turn.
641
+ - `TurnReplySequence` records, for the latest turn, the first sequence after
642
+ its user messages. The Step that starts a turn writes it, so turn message
643
+ counts need no old messages. A value for another turn, such as one a 0.1.0
644
+ Worker started, is ignored, and the count reads the turn's stored messages.
645
+ - `RunModel`, `ApplyTurnControl`, and `CompactContext` load the window plus
646
+ only the chunks that overlap their model input, the messages after
647
+ `ContextSummary.lastSequence`, listed in the movement into the Step. Without
648
+ compaction the model input covers every message, so they load every chunk.
649
+ `RecordTurnFailure`, a failure route with static options, loads every
650
+ kept chunk when the runner implements `buildUnfinishedTurnMessages`.
651
+ `get_agent_snapshot` and `list_agent_messages` load the window and every
652
+ kept chunk.
653
+
654
+ Retention bounds Flow state when compaction is on. `retainedMessageLimit`
655
+ (default 1,000, `null` keeps everything) deletes the oldest chunk while every
656
+ message in it is at or before `ContextSummary.lastSequence` and at least
657
+ `retainedMessageLimit` stored messages come after it. So the newest
658
+ `retainedMessageLimit` messages and every message after the summary always
659
+ stay. `CompactContext` and every message append apply it. Deleted messages
660
+ leave `list_agent_messages` and `get_agent_snapshot`; paging ends at
661
+ `AgentMessageIndex.firstSequence`, and every page carries
662
+ `deletedBeforeSequence`, that sequence, once anything was deleted, so a
663
+ client can tell deleted history from the start of the conversation. An
664
+ application that keeps the full transcript copies new messages with
665
+ `list_agent_messages` before more than `retainedMessageLimit` messages follow
666
+ them, such as after each `turn_completed`.
667
+
668
+ A runner without the compaction methods cannot use the summary. When it meets
669
+ a session whose earlier messages retention deleted, its model input starts at
670
+ the oldest stored message, and the session logs a warning on every model call.
671
+
672
+ ```ts
673
+ const flow = createAgentSessionFlow(runner, "SupportAgent", {
674
+ compaction: { contextWindowTokens: 400_000 },
675
+ messageWindowSize: 64,
676
+ messageChunkSize: 32,
677
+ messageChunkMaxBytes: 1_048_576,
678
+ retainedMessageLimit: 1_000,
679
+ });
680
+ ```
681
+
682
+ A session that a 0.1.0 Worker wrote has no index and keeps every message in
683
+ `AgentMessages`, which is also how a new session starts: no index means every
684
+ message is in the window. Its appends on this release move the old messages
685
+ into chunks a few at a time; readers handle the half-moved session.
686
+
687
+ To read one stored message from outside the Flow, read `AgentMessageIndex`
688
+ first, then the window instance or the chunk that holds it:
689
+
690
+ ```ts
691
+ const sequence = 7;
692
+ const [index] = await client.getAttributes(sessionId, flow.agentMessageIndex);
693
+ let message: AgentMessage | undefined;
694
+ const range = index?.chunks.find((chunk) => chunk.firstSequence <= sequence && sequence <= chunk.lastSequence);
695
+ if (range === undefined) {
696
+ [message] = await client.getAttributes(sessionId, flow.agentMessages.instance(messageSequenceKey(sequence)));
697
+ } else {
698
+ const [chunk] = await client.getAttributes(
699
+ sessionId,
700
+ flow.agentMessageChunks.instance(messageSequenceKey(range.firstSequence)),
701
+ );
702
+ message = chunk?.messages[sequence - range.firstSequence];
703
+ }
704
+ ```
705
+
706
+ A commit between the two reads can move the message into a chunk; read
707
+ again when it is missing, or page with `list_agent_messages`, which reads one
708
+ committed state.
94
709
 
95
710
  ## The runner contract
96
711
 
@@ -101,10 +716,12 @@ implements `AgentFrameworkRunner` and wraps `createAgentSessionFlow` in its own
101
716
  factory.
102
717
 
103
718
  ```ts
104
- interface AgentFrameworkRunner {
719
+ // ToolOutcome is ToolExecutionResult, or DeferredToolOutcome for tools that ask for input.
720
+ interface AgentFrameworkRunner<ToolOutcome = ToolExecutionResult> {
105
721
  buildUserMessage(text: string): FrameworkMessage;
106
722
  runModelStep(turn: AgentTurnContext): Promise<AgentTurnOutcome>;
107
- executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolExecutionResult>;
723
+ executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolOutcome>;
724
+ buildUnfinishedTurnMessages?(turn: AgentTurnContext): FrameworkMessage[]; // optional
108
725
  }
109
726
 
110
727
  type AgentTurnOutcome = TurnCompleted | ToolCallsRequested;
@@ -114,6 +731,7 @@ interface ToolCallsRequested {
114
731
  frameworkRunState: string; // non-empty, opaque to the session
115
732
  toolCalls: DeferredToolCall[]; // at least one, in the order the model made them
116
733
  usage: AgentUsage;
734
+ newMessages?: FrameworkMessage[]; // optional: messages to store now
117
735
  }
118
736
  interface DeferredToolCall { toolCallId: string; toolName: string; arguments: string; policy: ResolvedToolPolicy }
119
737
  interface ResolvedToolPolicy { effect: ToolEffect; requiresApproval: boolean }
@@ -147,8 +765,16 @@ calls it for the turn failure note.
147
765
  | `resolveToolPolicy(name, frameworkEffect?)` | Effective `ResolvedToolPolicy { effect, requiresApproval }` |
148
766
  | `events` | `AgentEventWriter` bound to this turn and attempt |
149
767
  | `cancellationSignal`, `deadline` | Abort signal and attempt deadline from Dex |
768
+ | `stepExecutionId` | Dex Step execution ID of this `RunModel` |
769
+ | `enabledBuiltInTools` | Built-in tools to offer to the model, from `builtInToolDefinitions` |
770
+ | `toolPolicies`, `defaultToolEffect` | The session's explicit policies and default effect, as Python's `tool_policies` and `default_tool_effect`; a framework's own approval default applies only to tools without a policy here |
150
771
  | `heartbeat()` | Extra heartbeat; the session already sends one every 20 seconds |
151
772
 
773
+ `stepExecutionId` and `enabledBuiltInTools` are optional in the type only so
774
+ that hand-built contexts in unit tests keep compiling; the session always sets
775
+ them. The same holds for `DeferredToolExecution.inputAnswers` and the 0.2.0
776
+ fields of `AgentToolCall`.
777
+
152
778
  The runner must:
153
779
 
154
780
  1. Rebuild framework input from `messages[*].frameworkItem` only. The other
@@ -167,13 +793,26 @@ The runner must:
167
793
  `runModelStep` continue the turn, including the messages the turn produced
168
794
  so far; the session stores no partial messages.
169
795
  5. If the response is the final answer, return `TurnCompleted.newMessages` with
170
- every message the turn produced after its user message, across all model
171
- calls, in order: model output, tool requests, tool results, and the answer.
796
+ every message of the turn the session has not stored yet, in order: model
797
+ output, tool requests, tool results, and the answer. Without incremental
798
+ messages, that is every message the turn produced after its user message.
172
799
  6. Report only this model call's usage. Use `emptyAgentUsage()` and
173
- `addAgentUsage()` when a framework reports several usage parts.
800
+ `addAgentUsage()` when a framework reports several usage parts. Optionally
801
+ return `modelCallMetadata: { requestModel, responseModel, providerName,
802
+ responseId, finishReasons }` in either outcome. The session never stores it;
803
+ it names the `chat` span (see [Tracing](#tracing)). Omit a field the
804
+ framework does not expose.
174
805
  7. Throw for an unexpected failure. The model call reruns from the same
175
806
  committed state, up to 3 attempts within 30 minutes; then the turn fails.
176
807
 
808
+ **Incremental turn messages (optional).** A runner may return the turn's
809
+ messages as it goes in `ToolCallsRequested.newMessages`, usually the previous
810
+ round's tool results and the new model output; `RunModel` appends them in the
811
+ same commit that records the tool calls. Such a runner must implement
812
+ `buildUnfinishedTurnMessages(turn)`. `RecordTurnFailure` calls it, with every
813
+ record final, and appends its messages, such as the results of stored tool
814
+ calls, before the turn-failure note. If it throws, only the note is stored.
815
+
177
816
  Never run a tool inside `runModelStep`, unless the framework cannot defer it.
178
817
  Such a tool repeats whenever the model call reruns; the adapter documents it as
179
818
  at-least-once.
@@ -191,10 +830,24 @@ It runs in `CallTool` or `CallSingleAttemptTool`, possibly concurrently with
191
830
  other calls of the same response. `DeferredToolExecution` has `flowId`,
192
831
  `toolCall` (the call's `AgentToolCall` record), `idempotencyKey`
193
832
  (`<flowId>/<turnId>/<toolCallId>`, the same on every attempt), `attempt`,
194
- `events`, `cancellationSignal`, `deadline`, and `heartbeat()`.
833
+ `events`, `cancellationSignal`, `deadline`, `heartbeat()`,
834
+ `heartbeatCheckpoint`, and `saveHeartbeatCheckpoint(text)`.
835
+ `saveHeartbeatCheckpoint` records a heartbeat that carries the text, and every
836
+ later heartbeat of the attempt carries it too; a retried attempt reads it as
837
+ `heartbeatCheckpoint` and can resume work the earlier attempt started instead
838
+ of starting it again. A `DeferredToolCall` may carry `toolSourceReference`,
839
+ opaque text stored on the call's record (wire field `tool_source_reference`),
840
+ so the tool's source can resolve the tool without listing it again.
195
841
 
196
842
  - Run the real tool and return `{ output }`; the text is recorded and shown to
197
843
  the model.
844
+ - Return `ToolInputRequested` to ask the user a question first, only when the
845
+ call has taken no effect; see Built-in tools. Such a runner implements
846
+ `AgentFrameworkRunner<DeferredToolOutcome>`; the default type
847
+ parameter keeps `executeDeferredToolCall` returning `ToolExecutionResult`,
848
+ so code that calls a runner through `AgentFrameworkRunner` compiles as before.
849
+ - Return `ToolTaskStarted` when the tool started remote work that the session
850
+ polls; see [Tool tasks](#tool-tasks).
198
851
  - Throw `ToolExecutionError(message)` for a failure the model should see. The
199
852
  call is recorded as `failed` and not retried.
200
853
  - Throw anything else for an unexpected failure. `CallTool` retries it up to 3
@@ -202,6 +855,118 @@ other calls of the same response. `DeferredToolExecution` has `flowId`,
202
855
  or on a crash or timeout, the call is recorded as `failed` for a `read` tool
203
856
  and `outcome_unknown` for a write. The model then sees
204
857
  `outcomeUnknownToolResult`, because the write may have taken effect.
858
+ - When a tool Step's recovery error has `attemptProgress`
859
+ `StepAttemptProgress.NOT_STARTED`, no attempt reached a Worker and the tool
860
+ never ran. The call is then recorded as `failed` with an `error` that starts with
861
+ `toolCallNotRunErrorPrefix` (`the tool did not run, so it is safe to call
862
+ again`), then `": "` and the recovery detail. The model may call the tool
863
+ again. A call whose tool already asked a question ran before, so it keeps the
864
+ status of its effect.
865
+
866
+ ### Tool tasks
867
+
868
+ Some tools start remote work and get a handle back, such as an MCP task. A
869
+ tool Step must not hold a connection while that work runs, so
870
+ `executeDeferredToolCall` returns `ToolTaskStarted { kind: "tool_task_started",
871
+ taskId, pollIntervalSeconds }`, and the runner implements the optional
872
+ `pollToolTask(poll)`:
873
+
874
+ ```ts
875
+ class ReportRunner implements AgentFrameworkRunner<DeferredToolOutcome> {
876
+ // buildUserMessage and runModelStep omitted
877
+ public async executeDeferredToolCall(call: DeferredToolExecution): Promise<DeferredToolOutcome> {
878
+ if (call.heartbeatCheckpoint !== undefined) {
879
+ // An earlier attempt started the job.
880
+ return { kind: "tool_task_started", taskId: call.heartbeatCheckpoint, pollIntervalSeconds: 5 };
881
+ }
882
+ const jobId = await reports.start(call.idempotencyKey);
883
+ await call.saveHeartbeatCheckpoint?.(jobId);
884
+ return { kind: "tool_task_started", taskId: jobId, pollIntervalSeconds: 5 };
885
+ }
886
+
887
+ public async pollToolTask(poll: ToolTaskPoll): Promise<ToolTaskPollOutcome> {
888
+ let job = await reports.get(poll.toolCall.taskId!);
889
+ const answered = poll.toolCall.inputContinuation; // ID of the question answered last
890
+ const resolved = poll.pendingAnswer; // its resolution, on the poll right after it
891
+ if (resolved !== null && job.questionId === answered) {
892
+ // Keyed by the question ID, so a retried poll never answers a later question.
893
+ job = resolved.resolution === "answered"
894
+ ? await reports.answer(job.id, answered, resolved.answer)
895
+ : await reports.skip(job.id, answered); // declined, or the deadline passed
896
+ }
897
+ if (job.questionId !== null && job.questionId !== answered) {
898
+ return { kind: "tool_input_requested", question: job.question, choices: job.choices,
899
+ continuation: job.questionId };
900
+ }
901
+ if (job.done) {
902
+ return { output: job.summary };
903
+ }
904
+ // Keep the answered ID only while the job still shows that question.
905
+ const kept = job.questionId === answered ? answered : null;
906
+ return { kind: "tool_task_pending", pollIntervalSeconds: 10, continuation: kept };
907
+ }
908
+ }
909
+ ```
910
+
911
+ The tool Step records `waiting_for_task` with `taskId` and
912
+ `taskPollIntervalSeconds`, writes `tool_task_started`, and ends.
913
+ `PollToolTask` waits on a Timer for the interval, rounded up to whole seconds
914
+ from 1 to a day, and calls `pollToolTask` once per execution:
915
+
916
+ - `{ output }` records `succeeded`, and `ToolExecutionError` records `failed`.
917
+ - `ToolTaskPending { kind: "tool_task_pending", pollIntervalSeconds,
918
+ continuation? }` stores the next interval, stores `continuation` as
919
+ `inputContinuation`, and polls again; omitted or `null` clears it.
920
+ - `ToolInputRequested` asks the user through `AwaitQuestionAnswer`, with the
921
+ interrupt ID `<toolCallId>-input-<n>`. The task has started, so the question
922
+ never ends the call: an answer, a decline, and a passed deadline each go back
923
+ to the task as a `QuestionAnswer` whose `resolution` is `answered`,
924
+ `declined`, or `deadline_passed`. `PollToolTask` polls at once and hands it
925
+ over as `poll.pendingAnswer`, only on that first poll; a retried attempt of
926
+ it gets it again. That poll reads the question's continuation in
927
+ `toolCall.inputContinuation`, and later polls read what the last
928
+ `ToolTaskPending` returned. Keep the answered question's continuation only
929
+ while the task may still show that question, so that a later identical
930
+ question reads as new. Deliver idempotently, keyed by a question ID when the
931
+ task has one. When a retried attempt cannot tell whether an earlier one
932
+ delivered, return the question again instead: the user answers twice rather
933
+ than a stale answer reaching a later question. When the task cannot take a
934
+ decline, cancel it best-effort and throw an error other than
935
+ `ToolExecutionError`; the call is then recorded by effect.
936
+ - Any other error fails the poll attempt. A poll gets three attempts; then
937
+ `RecordToolFailure` records `failed` for a `read` call and `outcome_unknown`
938
+ for a write, and `RecordToolOutcomeUnknown` records `outcome_unknown` for a
939
+ single-attempt call, because the task may still run.
940
+
941
+ No Step holds a connection while the work runs, and a Worker restart only
942
+ delays the next poll. A crash during a poll repeats only that poll, so a poll
943
+ must only read the task, or deliver an answer idempotently. A `CallTool`
944
+ attempt that dies after the remote work started runs again: save the handle
945
+ with `saveHeartbeatCheckpoint` before you return it, and return the saved
946
+ handle when `heartbeatCheckpoint` holds one. Dex may drop a heartbeat
947
+ checkpoint, so an attempt that dies before its checkpoint is saved and before
948
+ the tool Step commits can still start the work twice; pass the idempotency key
949
+ on so the remote system can drop the duplicate. `PollToolTask` owns the record
950
+ until it is final and takes no lock. Steering waits for a started task, its
951
+ questions included, and a call with `taskId` is never recorded `rejected`.
952
+ `cancel_turn` abandons the task instead, so Stop never waits for it: it
953
+ cancels `PollToolTask`, drops whatever a running poll returns, and records the
954
+ call `outcome_unknown` with `cancellationReason` `"turn_cancelled"` in
955
+ `CancelToolTask`. Implement `cancelToolTask` to stop the remote work
956
+ best-effort; make it idempotent by `taskId`, because a crash or a failed
957
+ attempt calls it again. When it keeps failing, the call is still recorded and
958
+ the turn ends:
959
+
960
+ ```ts
961
+ public async cancelToolTask(cancellation: ToolTaskCancellation): Promise<void> {
962
+ await reports.cancel(cancellation.toolCall.taskId!);
963
+ }
964
+ ```
965
+
966
+ `DeferredToolExecution.supportsToolTasks` tells a tool whether the runner polls tasks. A runner without `pollToolTask`
967
+ that returns `ToolTaskStarted` anyway gets `failed` for a `read` call and
968
+ `outcome_unknown` otherwise, with `missingToolTaskPoller`. Each poll is a Step
969
+ in the session's history, so poll at the slowest interval the task allows.
205
970
 
206
971
  ### Events
207
972
 
@@ -217,15 +982,33 @@ characters, and drops writes after the handler returns.
217
982
 
218
983
  ```text
219
984
  StartAgentSession -> RunModel (initial message) | AwaitUserMessage
220
- AwaitUserMessage -> RunModel
221
- RunModel -> AwaitUserMessage (final answer)
985
+ AwaitUserMessage -> RunModel (a message, or an approved plan revision)
986
+ | ApplyTurnControl (steer)
987
+ | AwaitUserMessage (a stale approval)
988
+ RunModel -> ApplyTurnControl (steered or cancelled: ends the turn, no model call)
989
+ | AwaitUserMessage (final answer)
990
+ | CompactContext({ through_sequence }) -> RunModel (compaction policy, input over the threshold)
222
991
  | one Step per tool call + ResumeTurn(n) (tool calls, all at once)
223
992
  | RecordTurnFailure -> AwaitUserMessage (retries exhausted)
224
993
  AwaitToolApproval(id) -> CallTool(id) | CallSingleAttemptTool(id) (approved)
225
994
  | dead end, after publishing to ToolCallResults (rejected or deadline)
995
+ AwaitQuestionAnswer(id) -> dead end (ask_user answered, declined, or past its deadline)
996
+ | CallTool(id) | CallSingleAttemptTool(id) (a tool's question answered)
997
+ AwaitTimer(id) -> dead end, after publishing to ToolCallResults
998
+ CallTool(id), CallSingleAttemptTool(id) -> AwaitQuestionAnswer(id) (ToolInputRequested)
999
+ | PollToolTask(id) (ToolTaskStarted)
1000
+ PollToolTask(id) -> itself (ToolTaskPending) | AwaitQuestionAnswer(id) (the task's question)
1001
+ | dead end, after publishing to ToolCallResults (a result, or a failure route)
1002
+ AwaitQuestionAnswer(id) -> PollToolTask(id) (a task's question answered)
226
1003
  CallTool(id), CallSingleAttemptTool(id), RecordToolFailure(id), RecordToolOutcomeUnknown(id)
227
1004
  -> dead end, after publishing to ToolCallResults
228
- ResumeTurn(n) -> RunModel
1005
+ ResumeTurn(n) -> RunModel | ApplyTurnControl (steered or cancelled) | ResumeTurn(n) (control wake)
1006
+ ApplyTurnControl -> RunModel (steered messages) | AwaitUserMessage
1007
+ cancel_turn -> cancels RunModel, CompactContext, RecordTurnFailure, and the waiting Steps
1008
+ -> FinishTurnCancellation(turnId)
1009
+ FinishTurnCancellation -> ApplyTurnControl (the model call was cancelled)
1010
+ | CancelToolTask(id) per abandoned task | dead end (ResumeTurn ends the turn)
1011
+ CancelToolTask(id) -> dead end, after publishing to ToolCallResults (or RecordToolOutcomeUnknown(id))
229
1012
  ```
230
1013
 
231
1014
  A turn with one read tool and a final answer therefore runs `RunModel`,
@@ -234,15 +1017,22 @@ A turn with one read tool and a final answer therefore runs `RunModel`,
234
1017
  | Step type | Input | Behavior | Options |
235
1018
  | --- | --- | --- | --- |
236
1019
  | `StartAgentSession` | `AgentSessionStartInput` | Initializes `AgentState`, `AgentUsage`, `PendingInterrupts`; starts a turn when `initial_message` is set | |
237
- | `AwaitUserMessage` | none | Waits for one queued message, appends it, starts `turn-<n>` | |
238
- | `RunModel` | none | Calls `runModelStep`. On `TurnCompleted`, appends the messages and clears the turn. On `ToolCallsRequested`, stores the run state, records, and interrupts, then moves to one Step per call plus `ResumeTurn` | 15 min method timeout, 2 min heartbeat timeout, 3 attempts within 30 min, failure route `RecordTurnFailure` |
239
- | `AwaitToolApproval` | tool call ID | Waits for the call's `InterruptResolutions` instance or its deadline Timer. Approved: moves to the call's tool Step. Rejected or expired: records `rejected` | Locks `PendingInterrupts` in Execute |
240
- | `CallTool` | tool call ID | Runs one `read` or `idempotent_write` call | 3 attempts, failure route `RecordToolFailure` |
1020
+ | `AwaitUserMessage` | none | Waits for any of, in this order, a `TurnControlRequests` message, one queued message, or, while the plan is `draft` or `active`, an approval in `PlanExecutions[<revision>]`. A steer: moves to `ApplyTurnControl`. A message: appends it and starts a `chat` or `planning` `turn-<n>`. An approval: clears `PendingPlanExecution`, marks the plan `active`, and starts an `executing` turn | Locks `TurnCancellationRequest` in Execute, so no turn starts while `cancel_turn` decides |
1021
+ | `RunModel` | none | Ends the turn and moves to `ApplyTurnControl` without a model call when the turn is steered or cancelled, or else to `CompactContext` when a compaction policy's threshold is crossed. Otherwise calls `runModelStep`. On `TurnCompleted`, appends the messages and clears the turn. On `ToolCallsRequested`, stores the run state, records, and interrupts, then moves to one Step per call plus `ResumeTurn`. A `write_todos` call, and every call but `write_todos` of a planning turn, is final in this commit | 15 min method timeout, 2 min heartbeat timeout, 3 attempts within 30 min, failure route `RecordTurnFailure` |
1022
+ | `CompactContext` | `CompactContextInput` | Calls `summarizeContext` once for the messages after the current summary through `through_sequence`, stores `ContextSummary`, and moves to `RunModel` | `RunModel`'s options and failure route |
1023
+ | `AwaitToolApproval` | tool call ID | Waits for the call's `ToolCallCancellations` instance, its `InterruptResolutions` instance, or its deadline Timer. A cancellation records `rejected` with its reason. Approved: moves to the call's tool Step. Rejected or expired: records `rejected` | Locks `PendingInterrupts` in Execute |
1024
+ | `AwaitQuestionAnswer` | tool call ID | Waits for the call's `ToolCallCancellations` instance, the question's `InterruptResolutions` instance, or its deadline Timer. A tool's unraised question waits on a zero Timer, and Execute raises it and waits again. Records the answer, the decline, or the passed deadline; a tool's answered question returns the record to `approved` and moves to its tool Step, and a task's answered question returns it to `waiting_for_task` and moves to `PollToolTask`. Steering never cancels a task's question; `cancel_turn` cancels the wait and abandons the task | Locks `PendingInterrupts` in Execute |
1025
+ | `AwaitTimer` | tool call ID | Waits on a Timer until the record's `wait_until`, or the call's `ToolCallCancellations` instance, then records `succeeded` or, when cancelled, `rejected` | |
1026
+ | `CallTool` | tool call ID | Runs one `read` or `idempotent_write` call, unless the turn is steered or cancelled before `tool_call_started` | 3 attempts, failure route `RecordToolFailure` |
241
1027
  | `CallSingleAttemptTool` | tool call ID | Runs one `non_idempotent_write` or `unclassified` call | 1 attempt, failure route `RecordToolOutcomeUnknown` |
242
- | `RecordToolFailure` | tool call ID | Records `failed` for a `read` call and `outcome_unknown` for a write, with the recovery error text | Retry backoff capped at 5 s |
243
- | `RecordToolOutcomeUnknown` | tool call ID | Records `outcome_unknown` with the recovery error text | Retry backoff capped at 5 s |
244
- | `ResumeTurn` | expected result count | Waits for that many `ToolCallResults` messages; if a record is still open, waits for the rest; then moves to `RunModel` | |
245
- | `RecordTurnFailure` | none | Records `last_turn_error` and clears the turn; first appends the turn failure note when the turn has final tool call records | Retry backoff capped at 5 s |
1028
+ | `PollToolTask` | tool call ID | Waits on a Timer for `taskPollIntervalSeconds`, or a zero Timer while `taskAnswerPending`, then calls `pollToolTask`; records the result, stores the next interval and continuation, or records the task's question. Steering waits for the task's result; `cancel_turn` cancels the poll and drops its result | 3 attempts, failure route `RecordToolFailure` for `read` and `idempotent_write` calls, else `RecordToolOutcomeUnknown`; no lock |
1029
+ | `RecordToolFailure` | tool call ID | Records `failed` for a `read` call and `outcome_unknown` for a write, with the recovery error text. A call whose tool Step never started (`StepAttemptProgress.NOT_STARTED`) records `failed` with `toolCallNotRunErrorPrefix` instead | Retry backoff capped at 5 s |
1030
+ | `RecordToolOutcomeUnknown` | tool call ID | Records `outcome_unknown` with the recovery error text. A call whose tool Step never started records `failed` with `toolCallNotRunErrorPrefix` instead | Retry backoff capped at 5 s |
1031
+ | `ResumeTurn` | expected result count | Waits for that many `ToolCallResults` messages or a `TurnControlRequests` message. A steer wake cancels the pending interrupts of the turn's approvals and of questions no task asked, wakes each such approval, question, and durable wait through `ToolCallCancellations`, and waits again. With every result in, moves to `RunModel`, or to `ApplyTurnControl` when steered or cancelled | Locks `PendingInterrupts` in Execute |
1032
+ | `ApplyTurnControl` | none | Consumes every steered message and stale wake. Ends an active steered or cancelled turn with the runner's unfinished-turn messages or a note, and writes `turn_steered` or `turn_cancelled`; then starts a turn with the steered messages | Retry backoff capped at 5 s |
1033
+ | `RecordTurnFailure` | the failing Step's input, ignored | Records `last_turn_error` and clears the turn; first appends the runner's unfinished-turn messages, when it has the method, and the turn failure note when the turn has final tool call records | Retry backoff capped at 5 s |
1034
+ | `FinishTurnCancellation` | turn ID | When `cancel_turn` cancelled the model call or compaction, moves to `ApplyTurnControl`. While tool calls run, records each call whose wait was cancelled as `rejected`, hands each started task to `CancelToolTask`, and ends; `ResumeTurn` then ends the turn | Locks `TurnCancellationRequest` and `PendingInterrupts`; retry backoff capped at 5 s |
1035
+ | `CancelToolTask` | tool call ID | Calls `cancelToolTask` when the runner has it, then records `outcome_unknown` | 30 s method timeout, 3 attempts within 2 min, failure route `RecordToolOutcomeUnknown` |
246
1036
 
247
1037
  Every tool Step receives the tool call ID as its input, and a failure route
248
1038
  receives the failed Step's input, so each Step knows its own call without any
@@ -267,47 +1057,82 @@ Do not repeat these tool calls unless the user asks again.
267
1057
  | Persistence name | Kind | Value |
268
1058
  | --- | --- | --- |
269
1059
  | `AgentState` | Attribute | `AgentState` |
270
- | `AgentMessages` | AttributeMap, key = `messageSequenceKey(sequence)` (12-digit zero-padded) | `AgentMessage` |
1060
+ | `AgentMessages` | AttributeMap, key = `messageSequenceKey(sequence)` (12-digit zero-padded) | `AgentMessage`; only the window, the messages from `AgentMessageIndex.windowFirstSequence` on |
1061
+ | `AgentMessageChunks` | AttributeMap, key = `messageSequenceKey(first sequence)` | `AgentMessageChunk { messages }`, the older messages of one `AgentMessageIndex.chunks` range, immutable |
1062
+ | `AgentMessageIndex` | Attribute | `AgentMessageIndex { chunks: [{ first_sequence, last_sequence }], first_sequence, window_first_sequence }`; unset until the first chunk is written, which means every message is in the window |
1063
+ | `TurnReplySequence` | Attribute | `TurnReplySequence { turn_id, sequence }`: where the latest turn's replies start; the Step that starts a turn writes it |
271
1064
  | `FrameworkRunState` | Attribute | string; set only between a turn's model calls |
272
- | `AgentToolCalls` | AttributeMap, key = tool call ID | `AgentToolCall`; only the active turn's records |
1065
+ | `AgentToolCalls` | AttributeMap, key = tool call ID | `AgentToolCallRecord`; only the active turn's records |
273
1066
  | `PendingInterrupts` | Attribute | `AgentInterrupt[]` |
274
1067
  | `AgentUsage` | Attribute | cumulative `AgentUsage` |
275
1068
  | `QueuedUserMessages` | Channel | `AgentUserMessage` |
276
1069
  | `InterruptResolutions` | ChannelMap, key = interrupt ID | `AgentInterruptResolution` |
277
1070
  | `ToolCallResults` | Channel, internal | `ToolCallCompleted { tool_call_id }` |
1071
+ | `SteeredUserMessages` | Channel | `AgentUserMessage` |
1072
+ | `TurnControlRequests` | Channel, internal | `TurnControlRequest { kind }` |
1073
+ | `TurnCancellationRequest` | Attribute | `TurnCancellationRequest { turn_id, requested_at }`, written only by `cancel_turn` |
1074
+ | `ToolCallCancellations` | ChannelMap, internal, key = tool call ID | `ToolCallCancellation { reason }` |
1075
+ | `AgentTraceContext` | Attribute | string, the W3C `traceparent` of the current activation's root span when its context is valid, sampled or not; deleted otherwise |
1076
+ | `AgentPlan` | Attribute | `AgentPlan`, unset until `write_todos` first succeeds |
1077
+ | `PlanExecutions` | ChannelMap, key = `planExecutionKey(revision)` (decimal) | `PlanExecutionRequest { revision }` |
1078
+ | `PendingPlanExecution` | Attribute | `PlanExecutionRequest` that `execute_plan` accepted and `AwaitUserMessage` has not consumed |
1079
+ | `ContextSummary` | Attribute | `ContextSummary`, set once the session compacts |
278
1080
  | `AgentEvents` | Stream | `AgentEvent` |
279
1081
 
280
- `AgentState.status` is `waiting_for_user_message`, `running_model`, or
281
- `running_tools`. Wire values are snake_case JSON; TypeScript values are the
1082
+ `AgentState.status` is `waiting_for_user_message`, `running_model`,
1083
+ `running_tools`, or `compacting_context`, and `AgentState.interactionMode` is
1084
+ `chat`, `planning`, or `executing`. A stored record's status can also be
1085
+ `awaiting_answer`, `waiting_for_timer`, or `waiting_for_task`; these non-final
1086
+ statuses appear only in `AgentToolCallRecord`, which snapshots,
1087
+ `client.getAttributes`, and `ToolTaskPoll.toolCall` carry. Runners receive
1088
+ `AgentToolCall`, whose `AgentToolCallStatus` keeps its six values, because a
1089
+ runner never sees a waiting record outside `pollToolTask`. The record gains
1090
+ `wait_until`, `input_continuation`, `input_answers`, `input_question`,
1091
+ `input_choices`, `input_allows_free_text`, `cancellation_reason`, `task_id`,
1092
+ `task_poll_interval_seconds`, and `task_answer_pending`. Wire values are snake_case JSON; TypeScript values are the
282
1093
  same fields in camelCase. Message sequences start at 1 and have no gaps. Turn
283
1094
  IDs are `turn-<n>`, where `n` is `turn_count + 1` when the turn starts.
284
1095
 
285
1096
  | RPC | Input | Output |
286
1097
  | --- | --- | --- |
287
- | `send_user_message` | `{ text }` | `false` for blank text; a message sent during a turn starts the next turn |
288
- | `resolve_interrupt` | `{ interrupt_id, approved, rejection_message }` | `false` when not pending or past its deadline; may throw `RpcLockConflictError`, which is safe to retry |
289
- | `get_agent_snapshot` | none | `{ state, messages, pending_interrupts, tool_calls, usage, queued_message_count }`; `state` is `null` before the session starts |
290
- | `list_agent_messages` | `{ before_sequence, limit }` | `{ messages, next_before_sequence }`, oldest first; `limit` clamped to 1..200, default 50 |
1098
+ | `send_user_message` | `{ text, plan_mode }` | `false` for blank text, using the whitespace of Python's `str.strip()`, and for a `plan_mode` message when plan mode is off; a message sent during a turn starts the next turn |
1099
+ | `execute_plan` | `{ revision }` | `true` only while the session waits for a user message, the plan has this revision and is `draft` or `active`, no execution of it is pending, and nothing is queued, steered, or pending; locks `PendingPlanExecution`, so a concurrent call may throw `RpcLockConflictError`, which is safe to retry |
1100
+ | `steer_user_message` | `{ message_id, text }`, exactly one set | queues `text` while no turn is active; `false` for both or neither, blank text, a message no longer queued, or a turn that does not accept steering; fails with `ChannelMessageNotFoundError` when the message was consumed while the RPC ran |
1101
+ | `delete_queued_message` | `{ message_id }` | `false` for a message no longer queued |
1102
+ | `cancel_turn` | `{ turn_id }`, `null` for the active turn | `false` when no turn is active, `turn_id` names another turn, or the turn does not accept cancellation; locks `TurnCancellationRequest` and `PendingInterrupts`, cancels the turn's model call, compaction, and waiting Steps, and moves to `FinishTurnCancellation` |
1103
+ | `resolve_interrupt` | `{ interrupt_id, approved, rejection_message }` | `false` when not pending, past its deadline, or a question with `approved` `true`; `approved` `false` declines a question; may throw `RpcLockConflictError`, which is safe to retry |
1104
+ | `answer_question` | `{ interrupt_id, answer }` | `false` when not pending, not a question, past its deadline, blank, or outside `choices` while free text is not allowed; locks `PendingInterrupts` like `resolve_interrupt` |
1105
+ | `get_agent_snapshot` | none | `{ state, messages, pending_interrupts, tool_calls, usage, queued_message_count, plan, queued_messages, steered_messages, context_summary }`; `state` is `null` before the session starts |
1106
+ | `list_agent_messages` | `{ before_sequence, limit }` | `{ messages, next_before_sequence, deleted_before_sequence }`, oldest first; `limit` clamped to 1..200, default 50; `deleted_before_sequence` is the oldest stored sequence once retention deleted earlier messages, else null |
291
1107
 
292
1108
  | Event type | Payload |
293
1109
  | --- | --- |
294
1110
  | `turn_started` | `{"sequence"}` of the user message |
295
- | `turn_completed` | `{"message_count"}` appended by the turn |
1111
+ | `turn_completed` | `{"message_count"}` appended by the turn after its user message, including incremental messages |
296
1112
  | `turn_failed` | `{"error"}` |
1113
+ | `turn_steered` | `{"steered_message_count","message_count"}` |
1114
+ | `turn_cancelled` | `{"message_count"}` |
297
1115
  | `text_delta`, `reasoning_delta` | `{"text"}` |
298
1116
  | `tool_call_deferred` | `{"tool_call_id","tool_name","effect","requires_approval"}`; the model asked for a call, which runs in its own Step |
299
1117
  | `tool_call_started` | `{"tool_call_id","tool_name"}` |
300
1118
  | `tool_call_finished` | `{"tool_call_id","tool_name","status"}`; the record became final, including a rejection |
301
- | `interrupt_raised` | `{"interrupt_id","tool_call_id","tool_name","deadline"}` |
302
- | `interrupt_resolved` | `{"interrupt_id","approved","rejection_message"}` |
1119
+ | `interrupt_raised` | `{"interrupt_id","kind","tool_call_id","tool_name","deadline","question","choices","allows_free_text"}` |
1120
+ | `interrupt_resolved` | `{"interrupt_id","kind","approved","answered","rejection_message","cancellation_reason"}`; `answered` is `true` only for an answered question, and `cancellation_reason` is `"steered"` or `"turn_cancelled"` when steering or cancellation removed the interrupt |
1121
+ | `timer_started` | `{"tool_call_id","wait_until","reason"}` for a `durable_wait` call |
1122
+ | `plan_updated` | `{"revision","status","task_count"}` after `write_todos` replaced the plan, or when an approval marks a draft `active` |
1123
+ | `plan_execution_started` | `{"revision"}`, after the `turn_started` of an `executing` turn |
1124
+ | `tool_task_started` | `{"tool_call_id","tool_name","task_id"}`; the tool started remote work that `PollToolTask` polls |
303
1125
  | `usage_recorded` | `{"recorded","session"}`, each an `AgentUsage` object |
1126
+ | `compaction_started` | `{"through_sequence"}`, when `CompactContext` starts |
1127
+ | `compaction_finished` | `{"generation","through_sequence"}`, when `CompactContext` stored the summary |
304
1128
 
305
1129
  Every event carries `turn_id`, the Step `attempt`, and `step_execution_id`.
306
1130
  Group live text by `(turn_id, step_execution_id, attempt)`: within one
307
1131
  `step_execution_id`, keep only the highest `attempt` seen and drop the partial
308
1132
  text of lower attempts; different `step_execution_id` values of one turn are
309
- its separate model call and tool call Steps. After `turn_completed` or
310
- `turn_failed`, replace the turn's live text with the stored messages.
1133
+ its separate model call and tool call Steps. After `turn_completed`,
1134
+ `turn_failed`, `turn_steered`, or `turn_cancelled`, replace the turn's live
1135
+ text with the stored messages.
311
1136
 
312
1137
  ## Effects
313
1138
 
@@ -323,6 +1148,594 @@ tool's effect: its `ToolPolicy`, then the framework effect the runner passes,
323
1148
  then `defaultToolEffect`. No tool runs inside `RunModel`, unless the framework
324
1149
  cannot defer it.
325
1150
 
1151
+ ## MCP tools (`@superdurable/dex-agents/mcp`)
1152
+
1153
+ `@superdurable/dex-agents/mcp` turns the tools of MCP servers into tools of an
1154
+ agent session. Every MCP tool call runs in its own tool Step, with an effect
1155
+ class from the tool's annotations, so a crash never repeats a write silently.
1156
+ It needs `@modelcontextprotocol/sdk` 1.x, an optional peer dependency; the main
1157
+ entry point never imports it:
1158
+
1159
+ ```bash
1160
+ npm i @superdurable/dex-agents @superdurable/dex@1.35.0 @modelcontextprotocol/sdk zod
1161
+ ```
1162
+
1163
+ Configure the servers on the Worker, never from Flow input, a user message, or
1164
+ a browser: a stdio server's command runs with the Worker's privileges.
1165
+
1166
+ ```ts
1167
+ import { McpToolSource } from "@superdurable/dex-agents/mcp";
1168
+
1169
+ const tools = new McpToolSource([
1170
+ { transport: "stdio", name: "files", command: "mcp-server-files", args: ["/srv/reports"] },
1171
+ {
1172
+ transport: "streamable_http",
1173
+ name: "tickets",
1174
+ url: "https://tickets.example.com/mcp",
1175
+ headers: { Authorization: `Bearer ${process.env.TICKETS_TOKEN}` },
1176
+ },
1177
+ ]);
1178
+ ```
1179
+
1180
+ A Stepwise agent takes the source in `toolSources`; see
1181
+ [Tool sources](#tool-sources). Another runner offers the tools and defers every
1182
+ call; the tool Step runs it:
1183
+
1184
+ ```ts
1185
+ class MyRunner implements AgentFrameworkRunner {
1186
+ // buildUserMessage omitted
1187
+ public async runModelStep(turn: AgentTurnContext): Promise<AgentTurnOutcome> {
1188
+ const offered = await tools.listTools(); // McpTool: name, description, inputSchema
1189
+ const response = await callMyModel(turn.messages, offered);
1190
+ return {
1191
+ kind: "tool_calls_requested",
1192
+ frameworkRunState: JSON.stringify(response),
1193
+ usage: response.usage,
1194
+ toolCalls: response.toolCalls.map((call) =>
1195
+ tools.deferredToolCall(turn, { toolCallId: call.id, toolName: call.name, arguments: call.arguments }),
1196
+ ),
1197
+ };
1198
+ }
1199
+ public async executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolExecutionResult> {
1200
+ const handlers = { ...this.localHandlers, ...(await tools.deferredToolHandlers()) };
1201
+ return handlers[call.toolCall.toolName]!(call);
1202
+ }
1203
+ }
1204
+ ```
1205
+
1206
+ `listTools()` lists each server on its own and caches its tools per Worker.
1207
+ The first listing of a server is awaited; after `listingTtlMs` (5 minutes) a
1208
+ server is listed again in the background while its earlier tools are
1209
+ returned. A server that cannot be listed is left out with a warning and is not
1210
+ tried again for `listingFailureBackoffMs` (30 seconds), then in the
1211
+ background, so one bad server never hides the others' tools or slows every
1212
+ model call. One listing per server runs at a time. `listTools({ refresh: true
1213
+ })` lists every server again now. `deferredToolCall` stores the
1214
+ server, the tool's MCP name, and its task support on the call's record, so the
1215
+ tool Step calls that server directly, without a listing; `callTool` finds the
1216
+ server through the listing. Tool names are `<server>__<tool>`, with characters
1217
+ other than letters, digits, `_`, and `-` replaced by `_` and names over 64
1218
+ characters shortened with a hash. When two tools get the same name, the first
1219
+ in server order is offered and the other is left out with a warning. Key tool
1220
+ policies by that name.
1221
+
1222
+ ### Resources and prompts
1223
+
1224
+ With `resourceAndPromptToolsEnabled: true`, `listTools` also offers three
1225
+ `read` tools after the servers' tools, when at least one server is configured.
1226
+ Each takes a configured server's name as `server`, an `enum` of the names, and
1227
+ opens a new connection to that server with the source's transport, headers,
1228
+ and `connectionTimeoutMs`:
1229
+
1230
+ | Tool | Arguments | Output |
1231
+ | --- | --- | --- |
1232
+ | `mcp_list_resources_and_prompts` | `server` | `{"prompts": [...], "resource_templates": [...], "resources": [...]}`, every page |
1233
+ | `mcp_read_resource` | `server`, `uri` | `{"contents": [...]}` |
1234
+ | `mcp_get_prompt` | `server`, `name`, optional `arguments` (strings by name) | `{"description": ..., "messages": [...]}` |
1235
+
1236
+ The names, descriptions, argument schemas, and output text match the Python
1237
+ core: outputs are the server's items in JSON with sorted keys and Python's
1238
+ separators, and whole numbers such as a `priority` of 1 are written alike. A
1239
+ server that does not offer resources or prompts lists none. A connection
1240
+ failure is retried `connectionAttempts` times. An MCP error answer, a server
1241
+ that stays unreachable, and an unconfigured server throw `ToolExecutionError`.
1242
+ Enable the tools on one source of an agent only. Every
1243
+ request these tools send is in `@modelcontextprotocol/sdk` 1.32.2, so this core
1244
+ has no gap against the Python core. Neither core follows resource subscriptions
1245
+ or list-change notifications. The names are exported as
1246
+ `mcpListResourcesAndPromptsToolName`, `mcpReadResourceToolName`, and
1247
+ `mcpGetPromptToolName`; key a tool policy by them to require approval.
1248
+
1249
+ ### Effects
1250
+
1251
+ `toolEffectFromMcpToolAnnotations` classifies each tool:
1252
+
1253
+ | MCP annotations | Effect | Tool Step | Approval by default |
1254
+ | --- | --- | --- | --- |
1255
+ | `readOnlyHint: true` | `read` | `CallTool` | No |
1256
+ | `idempotentHint: true`, whatever `destructiveHint` says | `idempotent_write` | `CallTool` | No |
1257
+ | anything else, or none | `defaultToolEffect`, `unclassified` by default | `CallSingleAttemptTool` | Yes |
1258
+
1259
+ The Python core classifies the same way. A policy in `toolPolicies` wins over
1260
+ the annotations. `trustsToolAnnotations` defaults to `true`, so a server with a
1261
+ wrong `readOnlyHint` on a write gets that write run without approval and
1262
+ retried up to three times. Set `trustsToolAnnotations: false` on a server whose
1263
+ hints you do not trust.
1264
+
1265
+ ### What a call does
1266
+
1267
+ The source uses `@modelcontextprotocol/sdk` 1.x, which speaks MCP up to
1268
+ 2025-11-25. Every request opens a new connection, performs the `initialize`
1269
+ handshake within `connectionTimeoutMs` (30 seconds by default), and then ends
1270
+ the protocol session with an HTTP `DELETE` and closes the connection, so no
1271
+ protocol session outlives a request and a server restart between listing and
1272
+ calling is harmless. Every
1273
+ `tools/call` carries the session's idempotency key,
1274
+ `<flowId>/<turnId>/<toolCallId>`, in `_meta` under `mcpIdempotencyKeyMetaKey`
1275
+ (`io.superdurable.dex/idempotencyKey`), and the tool Step's cancellation
1276
+ signal aborts the request.
1277
+
1278
+ | What happens | Recorded | Re-issued? |
1279
+ | --- | --- | --- |
1280
+ | A result | `succeeded`: text blocks joined with newlines, other content as JSON | - |
1281
+ | An `isError` result, or a JSON-RPC error response other than `-32603` | `failed` with the text (`ToolExecutionError`) | No |
1282
+ | The call was never sent: its server is not configured, or 3 connection attempts failed | `failed` | No |
1283
+ | The connection broke, the server exited, or the request timed out after it was sent; an internal error (`-32603`), an HTTP error status, or an unreadable body, which a gateway can return after the server ran the call | `McpToolCallInterruptedError`, a Step failure | `read` and `idempotent_write`: `CallTool` sends a new request, same idempotency key, up to 3 attempts. Other effects: `outcome_unknown`, never sent again |
1284
+
1285
+ `notifications/progress` become `mcp.tool_progress` events on `AgentEvents`.
1286
+
1287
+ ### Long-running MCP tasks
1288
+
1289
+ A tool whose `execution.taskSupport` is `required` is called as a task, the
1290
+ 2025-11-25 experimental tasks of the SDK. For a Streamable HTTP server, the
1291
+ tool Step saves the task ID as its heartbeat checkpoint before anything else,
1292
+ so a `CallTool` attempt that dies after `tools/call` returned the task resumes
1293
+ the same task on its retry instead of calling the tool again, once the
1294
+ checkpoint reached Dex; a lost checkpoint leaves only the idempotency key to
1295
+ drop a second task. A runner without `pollToolTask` gets every task polled
1296
+ inside the tool Step, as described below for stdio servers.
1297
+
1298
+ A Streamable HTTP server without protocol sessions keeps its tasks in a store
1299
+ that any connection can read, so its task becomes a [tool task](#tool-tasks):
1300
+ the tool Step returns `ToolTaskStarted` and ends, and `PollToolTask` calls
1301
+ `pollToolTask` on a Timer. Each poll sends `tasks/get` once, writes an
1302
+ `mcp.task_status` event when the status changed, and fetches a completed
1303
+ task's result with
1304
+ `tasks/result`. The next interval is the server's `pollInterval`, rounded up to
1305
+ whole seconds, or, when that is shorter, double the previous interval, up to a
1306
+ minute. No Step holds a connection while the task runs, and a Worker restart
1307
+ only delays the next poll. A server that no longer knows the task, or cannot be
1308
+ reached for three poll attempts, records `failed` for a `read` tool and
1309
+ `outcome_unknown` otherwise. `cancel_turn` abandons the task, and
1310
+ `cancelToolTask` sends `tasks/cancel`; forward it from your runner's
1311
+ `cancelToolTask`, as the Stepwise runner does. Any answer from the server ends
1312
+ the cancellation, so a retried one is harmless.
1313
+
1314
+ The source requests such a task's TTL as `taskTtlMs`, 24 hours by default. The
1315
+ TTL starts when the server creates the task, and the poll may span Worker
1316
+ restarts, so it must cover the task's run time plus the longest time no Worker
1317
+ serves the session. A task whose TTL passes before the session reads its result
1318
+ is lost and recorded by effect, as above. The server may grant a shorter TTL.
1319
+
1320
+ A stdio server's task lives in its process, and a server that keeps protocol
1321
+ sessions, as the SDK's task store does for a stateful Streamable HTTP server,
1322
+ binds its tasks to the session that the request ends. Neither can be polled
1323
+ from another Step. The tool Step polls such a task with `tasks/get`, with a
1324
+ heartbeat per poll, for at most `taskWaitTimeoutMs` (10 minutes by default)
1325
+ per attempt, holding a Worker slot, and requests a TTL of four times
1326
+ `taskWaitTimeoutMs`, so the task outlives every attempt's wait. When the wait
1327
+ passes, or the Worker dies, `CallTool` retries a `read` or `idempotent_write`
1328
+ call; a session-bound task is invisible to the retry's new session, so the
1329
+ retry calls the tool again. Other effects record `outcome_unknown`.
1330
+ `callTool`, the framework adapter path, polls every task inside the call.
1331
+
1332
+ ### Input requests
1333
+
1334
+ A task in `input_required` is cancelled and records `failed` with `the MCP
1335
+ server asked for input that this agent session cannot provide yet (...)`. In
1336
+ the 2025-11-25 tasks, the server sends its input requests as requests of its
1337
+ own during `tasks/result` and must get the answer on that live connection, so
1338
+ no durable question can carry them; the Python core, on 2026-07-28, turns a
1339
+ task's input request into a session question. The source declares no
1340
+ elicitation, sampling, or roots capability, so a server cannot send those
1341
+ requests directly.
1342
+
1343
+ ### With a framework adapter
1344
+
1345
+ The runner above is the core's runner-agnostic path. A framework adapter uses
1346
+ the source when it accepts tools defined by a JSON Schema: wrap each `McpTool`
1347
+ as a framework tool that calls `tools.callTool(name, input, { idempotencyKey })`
1348
+ with the key from the adapter's tool context, and pass
1349
+ `{ ...tools.toolPolicies(), ...yourPolicies }` as the adapter's `toolPolicies`.
1350
+ No adapter package changes for this.
1351
+
1352
+ | Adapter | Schema-only tool | Key inside the tool |
1353
+ | --- | --- | --- |
1354
+ | `@superdurable/dex-agents-vercel-ai` | `tool({ inputSchema: jsonSchema(...), execute })` | `getAgentToolExecutionScope()?.idempotencyKey` |
1355
+ | `@superdurable/dex-agents-openai-agents` | `tool({ parameters: <JSON Schema>, strict: false, execute })` | `getDeferredToolExecution()`, the whole call |
1356
+ | `@superdurable/dex-agents-mastra` | `createTool(...)` or an AI SDK tool | `requestContext.get(idempotencyKeyRequestContextKey)` |
1357
+ | `@superdurable/dex-agents-claude-agent-sdk` | an SDK MCP tool; convert the schema to a zod raw shape | `currentDeferredToolExecution()`; write calls only, reads run inside the harness |
1358
+
1359
+ Vercel AI SDK and Mastra also defer their own MCP client tools into tool
1360
+ Steps; use the source there for the shared idempotency key and annotation
1361
+ handling. The OpenAI Agents SDK runs its own MCP servers inline in `RunModel`,
1362
+ so a wrapper is its only path to tool Steps.
1363
+
1364
+ ## UI streams
1365
+
1366
+ `@superdurable/dex-agents/ui-stream` lets a browser render a live session
1367
+ from its `AgentEvents` Stream and recover after a reconnect. It needs no store
1368
+ besides Dex: the resume position rides in a token the browser sends back. It
1369
+ offers three things:
1370
+
1371
+ - an [AI SDK](https://ai-sdk.dev) UI message stream (protocol
1372
+ `x-vercel-ai-ui-message-stream: v1`) for `useChat`;
1373
+ - a plain Server-Sent Events stream whose event `id` is the Dex resume token,
1374
+ so an `EventSource` resumes through `Last-Event-ID`;
1375
+ - snapshots that map `get_agent_snapshot` to AI SDK `UIMessage` objects or to
1376
+ wire JSON without the fields only the session's Steps use.
1377
+
1378
+ It has no runtime dependency on `ai`. Its part and message types are
1379
+ structural subsets of the AI SDK `UIMessageChunk` and `UIMessage` types, and
1380
+ the tests check that they stay assignable. The helpers return Fetch API
1381
+ `Response` objects, so they mount in any server that speaks `Request` and
1382
+ `Response`.
1383
+
1384
+ **Authorize every route.** The session ID, the approval decisions, and the
1385
+ resume token all come from the browser. Each route must check that the caller
1386
+ may read, or write to, the session it names before it calls a helper. The
1387
+ helpers only check that a decision answers an interrupt pending in that
1388
+ session's active turn.
1389
+
1390
+ ### useChat
1391
+
1392
+ Mount three routes, with the chat ID as the session ID:
1393
+
1394
+ ```ts
1395
+ import {
1396
+ agentResumeTokenHeaderName,
1397
+ createAgentUIMessageStreamResponse,
1398
+ readAgentSessionUIMessages,
1399
+ readAgentToolApprovalResponses,
1400
+ resolveAgentToolApprovalsWithUIMessageStream,
1401
+ sendAgentUserMessageWithUIMessageStream,
1402
+ } from "@superdurable/dex-agents/ui-stream";
1403
+ import type { UIMessage } from "ai";
1404
+
1405
+ // GET /api/chat/:id: the stored messages and the active turn's resume token.
1406
+ export async function getChat(request: Request, sessionId: string): Promise<Response> {
1407
+ await authorizeSession(request, sessionId);
1408
+ return Response.json(await readAgentSessionUIMessages({ client, flow, flowId: sessionId }));
1409
+ }
1410
+
1411
+ // POST /api/chat: useChat sends a user message, or the answers to tool approvals.
1412
+ export async function postChat(request: Request): Promise<Response> {
1413
+ const { id, messages } = (await request.json()) as { id: string; messages: UIMessage[] };
1414
+ await authorizeSession(request, id);
1415
+ const lastMessage = messages.at(-1);
1416
+ if (lastMessage?.role === "assistant") {
1417
+ return resolveAgentToolApprovalsWithUIMessageStream({
1418
+ client, flow, flowId: id,
1419
+ approvals: readAgentToolApprovalResponses(lastMessage),
1420
+ signal: request.signal,
1421
+ });
1422
+ }
1423
+ const text = (lastMessage?.parts ?? [])
1424
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
1425
+ .join("");
1426
+ return sendAgentUserMessageWithUIMessageStream({ client, flow, flowId: id, text, signal: request.signal });
1427
+ }
1428
+
1429
+ // GET /api/chat/:id/stream: useChat reconnects.
1430
+ export async function getChatStream(request: Request, sessionId: string): Promise<Response> {
1431
+ await authorizeSession(request, sessionId);
1432
+ return createAgentUIMessageStreamResponse({
1433
+ client, flow, flowId: sessionId,
1434
+ resumeToken: request.headers.get(agentResumeTokenHeaderName) ?? undefined,
1435
+ signal: request.signal,
1436
+ });
1437
+ }
1438
+ ```
1439
+
1440
+ In the browser, keep the latest resume token in memory and send it in a header
1441
+ when the stream reconnects. A token can reach 64 KiB, so keep it out of URLs:
1442
+
1443
+ ```tsx
1444
+ import { useChat } from "@ai-sdk/react";
1445
+ import {
1446
+ DefaultChatTransport,
1447
+ lastAssistantMessageIsCompleteWithApprovalResponses,
1448
+ type UIMessage,
1449
+ } from "ai";
1450
+ import { useRef } from "react";
1451
+
1452
+ export function AgentChat(props: {
1453
+ sessionId: string;
1454
+ initial: { messages: UIMessage[]; resumeToken: string | null };
1455
+ }) {
1456
+ const resumeToken = useRef(props.initial.resumeToken ?? undefined);
1457
+ const { messages, sendMessage, addToolApprovalResponse } = useChat({
1458
+ id: props.sessionId,
1459
+ messages: props.initial.messages,
1460
+ resume: props.initial.resumeToken !== null,
1461
+ onData: (part) => {
1462
+ if (part.type === "data-agent-resume-token") resumeToken.current = part.data as string;
1463
+ },
1464
+ onFinish: ({ finishReason }) => {
1465
+ // "other": the turn ended without its events reaching the Stream; reload the stored messages.
1466
+ },
1467
+ transport: new DefaultChatTransport({
1468
+ api: "/api/chat",
1469
+ prepareReconnectToStreamRequest: ({ id }) => ({
1470
+ api: `/api/chat/${id}/stream`,
1471
+ headers: resumeToken.current === undefined ? {} : { "x-agent-resume-token": resumeToken.current },
1472
+ }),
1473
+ }),
1474
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
1475
+ });
1476
+ // Render messages; answer a part in state "approval-requested" with
1477
+ // addToolApprovalResponse({ id: part.approval.id, approved: true }).
1478
+ }
1479
+ ```
1480
+
1481
+ Events become parts as follows. Each part belongs to the turn's assistant
1482
+ message, whose ID is `<turnId>-assistant`.
1483
+
1484
+ | `AgentEvent` | UI message stream parts |
1485
+ | --- | --- |
1486
+ | `turn_started` | `start` |
1487
+ | `text_delta`, `reasoning_delta` of a `RunModel` Step | `text-*` or `reasoning-*`; `start-step` (after `finish-step`) for a new `RunModel` Step execution; `reset-step` when a new attempt of the same Step execution starts |
1488
+ | `tool_call_deferred` | `tool-input-available` with `dynamic: true` and the parsed arguments |
1489
+ | `interrupt_raised` | `tool-approval-request`; its `approvalId` is the `interrupt_id` |
1490
+ | `interrupt_resolved` | `tool-approval-response` |
1491
+ | `tool_call_finished` | `tool-output-available` (`succeeded`), `tool-output-error` (`failed`, `outcome_unknown`), or `tool-output-denied` (`rejected`) |
1492
+ | `turn_completed` | `finish` with reason `stop`, once the turn's end committed |
1493
+ | `turn_failed` | `error`, then `finish` with reason `error`, once the turn's end committed |
1494
+ | `usage_recorded`, `tool_call_started`, text written by a tool Step, adapter-specific events | transient `data-agent-event` with the event's wire JSON |
1495
+ | every part | a transient `data-agent-resume-token` after it |
1496
+
1497
+ - **Resume.** A reconnect with a token replays the token's turn from its
1498
+ first retained event. Its `start` part makes the default
1499
+ `DefaultChatTransport` reset the message and rebuild it, so the result is
1500
+ right whether the connection dropped, closed cleanly, or the page reloaded.
1501
+ A reconnect without a token replays the active turn. To continue exactly
1502
+ after the last part instead, without resending the turn, pass
1503
+ `continueAfterResumeToken: true`, but only while the browser still holds
1504
+ that message: with `useChat`, while `status` is `"error"` after a dropped
1505
+ connection. Each part has its own token, so a cut between two parts of one
1506
+ event repeats neither.
1507
+ - **Rejected positions.** Before it answers 200, the stream reads once to
1508
+ check the token's Dex position. A position Dex rejects, such as a forged one,
1509
+ restarts the turn from its first retained event.
1510
+ - **Turn ends.** `turn_completed` and `turn_failed` reach the Stream before
1511
+ their commit, so the stream waits up to 3 seconds for the commit before it
1512
+ finishes, and keeps reading when a retry follows. When the Stream lost a
1513
+ turn's events, the session's committed state still ends the stream, with
1514
+ reason `other`; that includes a Server whose Stream Store is disabled.
1515
+ - **Approvals.** When the turn waits only for approvals, the stream finishes
1516
+ with reason `tool-calls`, so `sendAutomaticallyWhen` can send the answers.
1517
+ It decides that from the committed records and `PendingInterrupts`, so a
1518
+ replay that reaches an approval request keeps reading when the call was
1519
+ already answered, including after `resolve_interrupt` and before the
1520
+ approval Step commits.
1521
+ `resolveAgentToolApprovalsWithUIMessageStream` reads at most 100 decisions,
1522
+ keeps the first per approval ID, calls `resolve_interrupt` only for
1523
+ interrupts pending in the active turn, and streams the rest of the turn into
1524
+ the same message. `readAgentToolApprovalResponses` binds each decision to
1525
+ the turn of the message it came from, and a decision bound to another turn,
1526
+ such as from a stale browser tab, is not applied. A decision that is no
1527
+ longer pending gets the call's outcome, or an error part, so the browser
1528
+ does not send it again. To keep
1529
+ one stream open instead and resolve approvals from your own endpoint with
1530
+ `resolveAgentToolApproval`, pass `finishWhenAwaitingApproval: false`.
1531
+ - **Retries.** A `reset-step` drops the partial text of an interrupted
1532
+ `RunModel` attempt, such as after a Worker crash.
1533
+ - **Tool details.** Tool arguments and results come from the committed
1534
+ `AgentToolCalls` records, because events carry only IDs and statuses. The
1535
+ stream waits up to 3 seconds for the commit that follows an event, and reads
1536
+ the stored messages once the turn has deleted its records.
1537
+ - **Lower-level APIs.** `streamAgentUIMessageChunks` yields the parts for
1538
+ `createUIMessageStreamResponse` or your own response.
1539
+ `convertAgentSnapshotToUIMessages` maps a snapshot you already have.
1540
+
1541
+ ### Plain Server-Sent Events
1542
+
1543
+ For a UI that applies the event rules itself, serve the snapshot and the
1544
+ events:
1545
+
1546
+ ```ts
1547
+ import {
1548
+ convertAgentEventStreamSnapshotToJson,
1549
+ createAgentEventSseResponse,
1550
+ readAgentEventStreamSnapshot,
1551
+ } from "@superdurable/dex-agents/ui-stream";
1552
+
1553
+ // GET /sessions/:id/snapshot
1554
+ export async function getSnapshot(request: Request, sessionId: string): Promise<Response> {
1555
+ await authorizeSession(request, sessionId);
1556
+ const value = await readAgentEventStreamSnapshot({ client, flow, flowId: sessionId });
1557
+ return Response.json(convertAgentEventStreamSnapshotToJson(value));
1558
+ }
1559
+
1560
+ // GET /sessions/:id/events
1561
+ export async function getEvents(request: Request, sessionId: string): Promise<Response> {
1562
+ await authorizeSession(request, sessionId);
1563
+ const resumeToken =
1564
+ request.headers.get("last-event-id") ?? new URL(request.url).searchParams.get("resumeToken") ?? "";
1565
+ return createAgentEventSseResponse({ client, flow, flowId: sessionId, resumeToken, signal: request.signal });
1566
+ }
1567
+ ```
1568
+
1569
+ The browser loads the snapshot, then opens the event stream from its token:
1570
+
1571
+ ```ts
1572
+ const { resume_token } = await (await fetch(`/sessions/${sessionId}/snapshot`)).json();
1573
+ const events = new EventSource(`/sessions/${sessionId}/events?resumeToken=${encodeURIComponent(resume_token)}`);
1574
+ events.onmessage = (message) => applyAgentEvent(JSON.parse(message.data));
1575
+ ```
1576
+
1577
+ Each event's data is the `AgentEvent` wire JSON with `payload` as JSON text.
1578
+ On a reconnect the browser sends `Last-Event-ID`, which takes precedence over
1579
+ the query parameter, and the stream continues with the next event. A token
1580
+ that Dex rejects, such as one of another session, gets a 400, which stops the
1581
+ `EventSource`; load the snapshot again. While a turn is active, the
1582
+ snapshot's token replays that turn's events. The snapshot and event JSON is
1583
+ the same as the Python core's `dex_agents.ui_stream`.
1584
+
1585
+ The snapshot JSON is the `get_agent_snapshot` wire format without the fields
1586
+ only the session's Steps use. Messages leave out `framework_item`, the
1587
+ framework's replay data, which can hold the agent's instructions. Tool calls
1588
+ leave out `tool_source_reference`, `trace_context`, `input_continuation`,
1589
+ `input_answers`, and `task_answer_pending`. Read them with `getAgentSnapshot` on the server when needed. It
1590
+ includes `session_settings` as stored, so keep anything a browser must not see out of session settings, or
1591
+ remove the key.
1592
+
1593
+ ### UI stream limits
1594
+
1595
+ - **Stop closes the response.** Stopping in the browser only closes the HTTP
1596
+ response; the turn keeps running in Dex. To stop the turn, the application's
1597
+ stop route calls `cancel_turn`. A steered or cancelled turn ends its stream
1598
+ with `finish` reason `other`.
1599
+ - **Streams are best effort.** Stream events live in the memory of the active
1600
+ Dex Server process, so a restart or failover loses them. A server with
1601
+ `streamStore.backend: disabled` drops Step Stream writes. Trimming can also
1602
+ remove old events. Without the events, the UI message stream ends with
1603
+ reason `other` once the turn ends, and `readAgentSessionUIMessages` shows the
1604
+ active turn's tool calls and approvals with a `null` token; poll it until the
1605
+ turn ends.
1606
+ - **Stored results.** A stored tool result is the text the model saw, so after
1607
+ a reload a failed or rejected call shows that text in `output-available`.
1608
+ - **Queued messages.** `sendAgentUserMessageWithUIMessageStream` follows the
1609
+ next turn that starts. When other messages were queued first, that turn
1610
+ answers the oldest of them.
1611
+ - **Exact continuation.** With `continueAfterResumeToken`, a cut that falls
1612
+ between a part and its token, which arrive in one write, still repeats that
1613
+ part.
1614
+
1615
+ ## Tracing
1616
+
1617
+ The session emits OpenTelemetry GenAI spans through `@opentelemetry/api`, an
1618
+ optional peer dependency. The application brings the SDK and an exporter:
1619
+
1620
+ ```bash
1621
+ npm i @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/exporter-trace-otlp-proto
1622
+ ```
1623
+
1624
+ Tracing is on by default and uses the global tracer provider, so an
1625
+ application that registers the SDK gets spans without further setup. Without
1626
+ `@opentelemetry/api`, the session never loads it. With the API but no SDK, the
1627
+ spans are no-ops and the session stores nothing extra. `tracing` in
1628
+ `AgentSessionOptions` changes the defaults:
1629
+
1630
+ ```ts
1631
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
1632
+ import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
1633
+ import { createAgentSessionFlow } from "@superdurable/dex-agents";
1634
+
1635
+ const tracerProvider = new NodeTracerProvider({
1636
+ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
1637
+ });
1638
+ const flow = createAgentSessionFlow(new MyFrameworkRunner(agent), "TriageAgent", {
1639
+ tracing: { tracerProvider, agentName: "Triage" },
1640
+ });
1641
+ ```
1642
+
1643
+ | Option | Default | Meaning |
1644
+ | --- | --- | --- |
1645
+ | `isEnabled` | `true` | `false` emits nothing and never loads `@opentelemetry/api`. |
1646
+ | `tracerProvider` | the global provider | The provider to emit with. Any `TracerProvider` fits `OpenTelemetryTracerProvider`. |
1647
+ | `agentName` | the Flow type | `gen_ai.agent.name` and the `invoke_agent` span name. |
1648
+ | `shouldCaptureMessageContent` | `false` | Put message text, tool arguments, and tool results on spans. |
1649
+ | `semanticConventionsVersion` | `"1.42.0-dev"` | The GenAI semantic conventions the spans follow. |
1650
+
1651
+ The spans, attributes, and content are identical to the Python core's; see its
1652
+ [Tracing section](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md#tracing)
1653
+ for the full attribute table. In short:
1654
+
1655
+ - **One trace per activation.** An activation is a burst of Steps between
1656
+ durable waits. It starts when `StartAgentSession`, `AwaitUserMessage`, or
1657
+ `ApplyTurnControl` consumes a user message, or `AwaitToolApproval` an
1658
+ interrupt answer or an expired deadline. That Step's attempt emits the root, `invoke_agent {agent
1659
+ name}`, with a span link to the previous activation's root. Each `RunModel`
1660
+ and `CompactContext` attempt emits a `chat` span when it ends, and each
1661
+ `CallTool`, `CallSingleAttemptTool`, and `PollToolTask` attempt an
1662
+ `execute_tool {tool name}` span. A turn
1663
+ without approvals is one trace; each approval answer and each new turn
1664
+ starts another.
1665
+ - **A near-zero root.** Every span ends inside its Step attempt, so none stays
1666
+ open across a durable wait. The `invoke_agent` root ends with its own Step,
1667
+ so its duration is near zero, and the activation's later spans start after
1668
+ it ended. That is on purpose: a span must end in the process that started
1669
+ it, and the OpenTelemetry API cannot give a span an ID before it starts, so a
1670
+ root covering the whole activation would stay open across Steps, Workers,
1671
+ and possibly days, and backends drop or hide such traces.
1672
+ - **Current span.** While a tool call runs, its `execute_tool` span is active.
1673
+ While a model call runs, the activation's root is active, because the `chat`
1674
+ span starts when the attempt ends. Framework and HTTP client instrumentation
1675
+ nests under the activation when a context manager is registered.
1676
+ - **Across processes.** The root stores its `traceparent`, about 55 bytes, in
1677
+ the `AgentTraceContext` Attribute in the same commit as its Step. `RunModel`
1678
+ reads it for its `chat` span and copies it onto each tool call record it
1679
+ creates, as `AgentToolCall.traceContext`; an approval replaces the copy with
1680
+ its own root. Tool Steps parent their spans from the record, so overlapping
1681
+ approvals never swap tool spans. The Attribute is written whenever the root
1682
+ has a valid span context, sampled or not, so under a parent-based sampler a
1683
+ sampled-out root's other spans are dropped with it. A root without a valid
1684
+ context (tracing disabled, or no API or SDK) deletes the stored value, so
1685
+ the activation's later spans start traces of their own instead of joining
1686
+ the previous activation's trace.
1687
+ - **Retries.** Every attempt is its own span with `dex.step.attempt`, and the
1688
+ attempts of one Step execution share `dex.step.execution_id`. A failed
1689
+ attempt has status `ERROR` and `error.type` set to the error's `name`; a
1690
+ `ToolExecutionError` sets `ToolExecutionError`. The recovery Steps and
1691
+ `ResumeTurn` emit no span.
1692
+ - **Every span** carries the Flow ID as `gen_ai.conversation.id`, `session.id`,
1693
+ and `dex.flow.id`, plus `dex.run.id`, `dex.step.type`,
1694
+ `dex.step.execution_id`, `dex.step.attempt`, and `dex.agent.turn.id`.
1695
+ - **Model and usage.** The `chat` span is named `chat {requestModel}` when the
1696
+ runner returns `modelCallMetadata.requestModel`, and `chat` otherwise. Each
1697
+ framework adapter in this repository returns `modelCallMetadata` with what
1698
+ its framework reports for the call and leaves the rest unset; its README
1699
+ lists the fields. Usage attributes appear when the runner reports
1700
+ `modelCalls: 1` and a non-zero input or output count. A runner that reports any other
1701
+ `modelCalls`, such as a whole-turn harness, gets an `INTERNAL` `chat` span
1702
+ without usage, so provider instrumentation that traces each call does not
1703
+ count the tokens twice. A single model call can still be counted twice when
1704
+ provider client instrumentation also traces it. Sum usage from one
1705
+ instrumentation scope in the backend, or remove the session's usage
1706
+ attributes, for example with an OpenTelemetry Collector transform processor:
1707
+
1708
+ ```yaml
1709
+ processors:
1710
+ transform/drop_dex_agents_usage:
1711
+ trace_statements:
1712
+ - context: span
1713
+ statements:
1714
+ - delete_matching_keys(attributes, "^gen_ai\\.usage\\.") where instrumentation_scope.name == "@superdurable/dex-agents"
1715
+ ```
1716
+ - **Content by reference.** By default spans carry message sequence keys
1717
+ (`dex.agent.input_messages.last_key`, and `dex.agent.output_messages.keys` for
1718
+ what this call produced) and tool call IDs (`gen_ai.tool.call.id`,
1719
+ `dex.agent.requested_tool_call.ids`), because the content is already durable
1720
+ in Dex. With `shouldCaptureMessageContent: true`, they also carry
1721
+ `gen_ai.input.messages`, `gen_ai.output.messages`,
1722
+ `gen_ai.tool.call.arguments`, and `gen_ai.tool.call.result`, built from the
1723
+ message projections. No `_ref` attributes are set: they take an absolute URI,
1724
+ and Dex has no URI for a stored message.
1725
+ - **Failures.** Tracing never fails or retries a Step. Every OpenTelemetry call
1726
+ (spans, context, content serialization) is guarded; an error, such as one
1727
+ thrown by a span processor, is logged with `console.warn` the first time and
1728
+ `console.debug` after that, and that span is skipped.
1729
+ - **Version switch.** `"1.42.0-dev"` follows
1730
+ [`open-telemetry/semantic-conventions-genai`](https://github.com/open-telemetry/semantic-conventions-genai)
1731
+ at commit [`dc3571562603552dc555c667dfa327a638d94ab2`](https://github.com/open-telemetry/semantic-conventions-genai/tree/dc3571562603552dc555c667dfa327a638d94ab2)
1732
+ (2026-11-01), whose schema URL names `1.42.0-dev`; the repository has no
1733
+ release tag yet. `"1.36.0"` follows OpenTelemetry semantic conventions
1734
+ 1.36.0 for backends that still read the older names: `gen_ai.system` instead
1735
+ of `gen_ai.provider.name`, no cache or reasoning token counts, and no
1736
+ content. `genAISemanticConventionsSchemaUrls` lists the schema URL set on the
1737
+ tracer.
1738
+
326
1739
  ## Testing export
327
1740
 
328
1741
  `@superdurable/dex-agents/testing` runs adapter integration tests against a
@@ -330,13 +1743,14 @@ real Dex Server with no model key:
330
1743
 
331
1744
  | Export | Purpose |
332
1745
  | --- | --- |
333
- | `PrivateDexEngine.start()` | Private `dexcli dev --engine zd` on free ports with its own state directory. Reads `DEXCLI`; `dex-server-zd` comes from `DEX_SERVER_ZD_BINARY` or sits next to `dexcli` |
1746
+ | `PrivateDexEngine.start()` | Private `dexcli dev --engine zd` on free ports with its own state directory. Reads `DEXCLI`; `dex-server-zd` comes from `DEX_SERVER_ZD_BINARY` or sits next to `dexcli`. The engine runs with `dev.durabilityMode: processCrashSafe`, which skips fsync and survives a process kill, through a `-zd-config` file that also works with `dexcli` releases that predate `--durability`. `serverZdConfigPath` passes `dex-server-zd` overrides instead, such as a disabled Stream Store; set `dev.durabilityMode` there too |
334
1747
  | `KillableWorkerProcess.start({ entryModulePath, serverAddress, logPath, environment })` | Worker child in its own process group; `kill()` sends SIGKILL to the group, `stop()` drains |
335
1748
  | `runAgentWorkerProcess(flows)` | Body of the Worker child module; 8 concurrent tasks; prints `dex-agents test Worker ready` once registered |
336
1749
  | `AgentTestEnvironment.start({ serverAddress, flows, isWorkerInProcess })` | Registry, BlobCache, Client, and optional in-process Worker with 8 concurrent tasks |
337
1750
  | `AgentSessionTestClient` | Typed RPC calls (`resolveInterrupt` retries lock conflicts) and Worker-free waits on committed Attributes |
338
1751
  | `readStepHistory(serverAddress, flowId)` | Completed Step methods from `dexcli flow history`, to assert a turn's Step shape |
339
1752
  | `FileGate`, `InvocationLog` | Pause Worker code at an exact point; count real tool runs across processes |
1753
+ | `ScriptedModelClient` | Stepwise `ModelClient` that answers from a script, like the Python core's; the same class as `@superdurable/dex-agents/stepwise` exports. See [Demo model without a key](#demo-model-without-a-key) |
340
1754
  | `pollUntil(probe, options)` | Deadline polling where the Client has no direct wait |
341
1755
  | `findFreeLoopbackPorts(count)` | Distinct free loopback ports, chosen with every listener held open until all are picked |
342
1756
 
@@ -382,8 +1796,17 @@ DEXCLI=/path/to/dexcli ./ci.sh
382
1796
  ```
383
1797
 
384
1798
  `ci.sh` runs `npm ci`, `npm run typecheck`, and `npm run test:integration`,
385
- which builds with `tsc` and runs `node --test` over `dist/test/integ`. Strict
386
- `tsc` (`noUnusedLocals`, `noUncheckedIndexedAccess`,
1799
+ which builds with `tsc` and runs `node --test` over `dist/test/integ`. It skips
1800
+ `test/long`, the 2,000-turn compaction session behind the agent deployment
1801
+ guide's run budget numbers, which runs for minutes; `test/integ` has an
1802
+ 800-turn retention guard instead. The `Agents Long Tests Nightly` workflow runs `test/long` every
1803
+ night. Run it by hand with:
1804
+
1805
+ ```bash
1806
+ DEXCLI=/path/to/dexcli npm run test:long
1807
+ ```
1808
+
1809
+ Strict `tsc` (`noUnusedLocals`, `noUncheckedIndexedAccess`,
387
1810
  `exactOptionalPropertyTypes`, and related flags) is the lint gate. The tests
388
1811
  use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
389
1812
 
@@ -401,7 +1824,50 @@ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
401
1824
  also under an ASYNC step durability FlowConfig;
402
1825
  - SIGKILL during `CallTool`, retried with the same idempotency key;
403
1826
  - an approval that survives a Worker restart, `send_user_message` during a
404
- turn, and snapshots and message paging.
1827
+ turn, and snapshots and message paging;
1828
+ - the UI stream adapters through the AI SDK `Chat` client and
1829
+ `DefaultChatTransport` over HTTP: a streamed turn that matches the stored
1830
+ messages; a dropped connection continued exactly, a clean close replayed into
1831
+ the same message, and a cut between two parts of one event, each with no
1832
+ repeated or missing part; an approval and a rejection round trip; a stale
1833
+ approval that does not resend in a loop; untrusted, duplicate, and excess
1834
+ decisions, decisions bound to an older turn, and an approval sent with the
1835
+ paused stream's token; a reconnect after an approval while the approved call
1836
+ runs; a `turn_completed` or `turn_failed` that never commits; a page reload,
1837
+ also with a forged Dex position; plain Server-Sent Events resumed with
1838
+ `Last-Event-ID` and a rejected token; a turn finished from committed state
1839
+ with the Stream Store disabled; the resume token size limit; and
1840
+ `reset-step` after SIGKILL during `RunModel`;
1841
+ - a tool that asks for input with `ToolInputRequested`, runs again with the
1842
+ answer, and records `outcome_unknown` once when its second run is killed;
1843
+ - two tools of one response that ask at once, both answerable, and a sibling
1844
+ approval resolved while a tool runs that never reappears as pending.
1845
+
1846
+ The Stepwise tests (`test/integ/stepwise-*.integration.test.ts`) run the
1847
+ built-in loop on the AI SDK's `MockLanguageModelV4`, scripted from its prompt
1848
+ (`test/fixtures/stepwise-scripted-language-model.ts`), and cover:
1849
+
1850
+ - a read tool turn whose transcript is stored while the tool runs, history
1851
+ replay, one model call per `RunModel`, and the Step shape;
1852
+ - an approved write in `CallSingleAttemptTool`; a schema mismatch; the model
1853
+ call limit; reserved built-in names;
1854
+ - `ask_user` answered by `answer_question`, with refused, duplicate, declined,
1855
+ and expired answers;
1856
+ - `durable_wait` on a Timer in `AwaitTimer`, and an invalid duration that fails
1857
+ without a Step;
1858
+ - reasoning and tool call parts replayed with their provider metadata
1859
+ unchanged, and replay to another provider without reasoning;
1860
+ - a failed turn that stores its tool results before the turn-failure note;
1861
+ - SIGKILL during `RunModel`, during a single-attempt write, and while waiting
1862
+ on a question or a Timer;
1863
+ - a session policy that wins over a tool's own approval, `null` `ask_user`
1864
+ arguments, a one-day wait ended with **SkipTimer**, a model that asks for
1865
+ tools past the call limit, a built-in name clash before any model call, the
1866
+ argument messages shared with Python, and `ScriptedModelClient`.
1867
+
1868
+ `test/integ/wire-compatibility.integration.test.ts` decodes 0.1.0 wire values in
1869
+ 0.2.0 and checks that 0.2.0 values keep every 0.1.0 field, and
1870
+ `test/compat/agents-0.1.0-source-compatibility.ts` keeps 0.1.0 code compiling.
405
1871
 
406
1872
  ## Design decisions
407
1873
 
@@ -433,11 +1899,15 @@ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
433
1899
  - **`deferral_order`.** `AgentToolCall` carries a zero-based `deferral_order`,
434
1900
  as in the Python core; records and the snapshot list calls in the order the
435
1901
  model made them.
436
- - **Output before a single-attempt tool runs.** Dex re-delivers an attempt,
437
- with the same attempt number, when its Worker disconnects before sending any
438
- output within the dispatch wait. A tool Step therefore writes
439
- `tool_call_started` and awaits a heartbeat before calling the runner, so a
440
- crash during the tool fails the attempt instead of re-running the tool.
1902
+ - **Output before a single-attempt tool runs.** A tool Step writes
1903
+ `tool_call_started` and awaits a heartbeat before calling the runner. A
1904
+ crash during the tool fails the attempt instead of re-running the tool. Dex
1905
+ treats a stream that half-closes before any output as one whose task never
1906
+ ran: the attempt returns to pending with the same attempt number, uses no
1907
+ retry, and is handed out again. After output, a half-close is a disconnect
1908
+ that fails the attempt with `NOT_COMPLETED`. The heartbeat therefore keeps a
1909
+ started tool from being returned as not run at the same attempt number and
1910
+ later reported as `NOT_STARTED`.
441
1911
  - **Background heartbeats.** `RunModel` and the tool Steps heartbeat every 20
442
1912
  seconds while the runner works, so a silent model or tool call stays within
443
1913
  the heartbeat timeout. Method timeouts still bound hung calls.
@@ -446,31 +1916,67 @@ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
446
1916
  - **TypeScript extras.** `AgentTurnContext` and `DeferredToolExecution` add
447
1917
  `cancellationSignal` and `deadline`. `ToolPolicy.requiresApproval` is optional
448
1918
  and defaults by effect, as in Python.
1919
+ - **Two record types.** The wire status gains non-final values, but
1920
+ `AgentToolCallStatus` keeps its six, so exhaustive mappings in adapters still
1921
+ compile. Stored records use `AgentToolCallRecord`, whose status also allows
1922
+ `awaiting_answer`, `waiting_for_timer`, and `waiting_for_task`.
1923
+ - **One `streamText` step.** The AI SDK client uses only the public `ai` API.
1924
+ `streamText` with `stopWhen: stepCountIs(1)`, `maxRetries: 0`, and tools
1925
+ without `execute` makes exactly one model call. It accepts every
1926
+ `LanguageModel` form, and the AI SDK's own prompt conversion and middleware
1927
+ apply as in any `streamText` call.
1928
+ - **0.1.0 source compatibility.** Every field added to a 0.1.0 type is
1929
+ optional, and `test/compat/agents-0.1.0-source-compatibility.ts` is 0.1.0
1930
+ code that the typecheck compiles. One break remains: `AgentSnapshot.toolCalls`
1931
+ and records read with `client.getAttributes` are `AgentToolCallRecord`, whose
1932
+ status can be a waiting status, so code that assigns them to `AgentToolCall`
1933
+ must widen the type.
449
1934
 
450
1935
  ## Limits
451
1936
 
452
- - `RunModel`, `get_agent_snapshot`, and `list_agent_messages` load the whole
453
- `AgentMessages` map, and every model call replays the full history. There is
454
- no compaction yet.
1937
+ - `get_agent_snapshot` and `list_agent_messages` load every retained chunk,
1938
+ and so does `RunModel` without compaction. Retention bounds that load only
1939
+ with compaction.
455
1940
  - Each model call and each tool call adds a Step round trip.
456
- - A crash in `RunModel` reruns that model call. When the crashed attempt sent
457
- no output, Dex re-delivers it with the same `attempt`, so `attempt` does not
458
- reliably count reruns.
459
- - The single-attempt guarantee depends on the tool Step's output frame reaching
460
- Dex before the tool runs. The Step awaits a heartbeat first, but that Promise
461
- resolves when grpc-js takes the frame locally: it does not prove that the
462
- frame left the Worker, let alone that Dex received it. If the Worker dies
463
- before Dex receives the frame, whether the frame was still in the Worker's
464
- buffers or on the network, and within the 10 second dispatch wait, Dex can
465
- re-deliver the attempt and the tool can run a second time. The window is
466
- normally well under a second.
1941
+ - A crash in `RunModel` reruns that model call as its next attempt.
1942
+ - The tool Steps use SYNC durability, and Dex never hands one attempt number to
1943
+ a second Worker, so a Worker crash during a single-attempt tool records
1944
+ `outcome_unknown` instead of running the tool again. Cancellation is
1945
+ cooperative, though: a cancelled run may keep running while the failure
1946
+ route starts.
1947
+ - `StepAttemptProgress.NOT_COMPLETED` is conservative, so a call whose tool never
1948
+ ran can still record `outcome_unknown`, such as when Dex Server crashed after
1949
+ it recorded the attempt start but before the task reached the Worker.
467
1950
  - Tools a framework cannot defer run inside `RunModel` and repeat with it; each
468
1951
  adapter documents them as at-least-once.
469
- - Interrupts are only tool approvals. Queued user messages are consumed one per
470
- turn; there is no steering of a running turn.
1952
+ - Queued user messages are consumed one per turn, and a queued message does
1953
+ not end a `durable_wait`; a steered one does. Steering never stops a model
1954
+ call or a tool call that has started, or a tool task it started.
1955
+ `cancel_turn` stops a model call and abandons a tool task, but never stops a
1956
+ tool call Step that has started.
1957
+ - Each `PollToolTask` poll is a Step in the session's history.
471
1958
  - A failed turn keeps its user message in history without an assistant reply;
472
1959
  only the turn failure note records what its tools did.
473
1960
  - Deadlines use the Worker clock when the Timer is computed.
474
1961
  - Sessions never complete on their own; stop them with `Client.stopFlow`.
475
1962
  - `AgentEvents` is best effort and its byte budget is shared by every session
476
1963
  of the Flow type; rebuild from `get_agent_snapshot`.
1964
+ - `@superdurable/dex-agents/mcp` uses `@modelcontextprotocol/sdk` 1.x, which
1965
+ speaks MCP up to 2025-11-25: no stateless 2026-07-28 requests, no
1966
+ `io.modelcontextprotocol/tasks` extension, no `input_required` results, and no
1967
+ `traceparent` in `_meta`. It calls only `taskSupport: required` tools as
1968
+ tasks. The Python core speaks 2026-07-28.
1969
+ - A stdio server's MCP task, and a task of a server that keeps protocol
1970
+ sessions, is polled inside its tool Step, holding a Worker slot. A task in
1971
+ `input_required` records `failed`, because 2025-11-25 input requests need a
1972
+ live connection.
1973
+ - Every MCP request opens a new connection and runs the `initialize`
1974
+ handshake. An unreachable server is tried again, with its connection
1975
+ attempts, on every `listTools` call until it answers. A server that binds
1976
+ tasks to its protocol session, such as a stateful server on the SDK's task
1977
+ store, cannot resume a task across attempts; the retry calls the tool again.
1978
+ - Tracing covers the session's Steps only. The Dex SDK emits no spans, so Dex
1979
+ Server and Worker internals do not appear in the traces.
1980
+ - The adapters report only the model information their framework exposes, so
1981
+ some `chat` spans carry no provider, response ID, or finish reasons. Each
1982
+ adapter's README lists what it sets.