@superdurable/dex-agents 0.1.0 → 0.3.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 (168) hide show
  1. package/README.md +1608 -64
  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 +254 -0
  6. package/dist/src/agent-context-compaction.js +287 -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 +343 -8
  19. package/dist/src/agent-session-codecs.js.map +1 -1
  20. package/dist/src/agent-session-failure.d.ts +58 -0
  21. package/dist/src/agent-session-failure.js +126 -0
  22. package/dist/src/agent-session-failure.js.map +1 -0
  23. package/dist/src/agent-session-flow-graph-cli.d.ts +2 -0
  24. package/dist/src/agent-session-flow-graph-cli.js +47 -0
  25. package/dist/src/agent-session-flow-graph-cli.js.map +1 -0
  26. package/dist/src/agent-session-flow-graph.d.ts +38 -0
  27. package/dist/src/agent-session-flow-graph.js +54 -0
  28. package/dist/src/agent-session-flow-graph.js.map +1 -0
  29. package/dist/src/agent-session-flow-graph.json +2187 -0
  30. package/dist/src/agent-session-flow.d.ts +317 -28
  31. package/dist/src/agent-session-flow.js +373 -21
  32. package/dist/src/agent-session-flow.js.map +1 -1
  33. package/dist/src/agent-session-persistence.d.ts +81 -4
  34. package/dist/src/agent-session-persistence.js +33 -2
  35. package/dist/src/agent-session-persistence.js.map +1 -1
  36. package/dist/src/agent-session-settings.d.ts +58 -0
  37. package/dist/src/agent-session-settings.js +81 -0
  38. package/dist/src/agent-session-settings.js.map +1 -0
  39. package/dist/src/agent-session-spend-limit.d.ts +156 -0
  40. package/dist/src/agent-session-spend-limit.js +42 -0
  41. package/dist/src/agent-session-spend-limit.js.map +1 -0
  42. package/dist/src/agent-session-start.d.ts +49 -0
  43. package/dist/src/agent-session-start.js +67 -0
  44. package/dist/src/agent-session-start.js.map +1 -0
  45. package/dist/src/agent-session-step-options.d.ts +78 -0
  46. package/dist/src/agent-session-step-options.js +71 -0
  47. package/dist/src/agent-session-step-options.js.map +1 -0
  48. package/dist/src/agent-session-step-types.d.ts +20 -0
  49. package/dist/src/agent-session-step-types.js +28 -0
  50. package/dist/src/agent-session-step-types.js.map +1 -0
  51. package/dist/src/agent-session-steps.d.ts +103 -8
  52. package/dist/src/agent-session-steps.js +997 -197
  53. package/dist/src/agent-session-steps.js.map +1 -1
  54. package/dist/src/agent-session-tracer.d.ts +61 -0
  55. package/dist/src/agent-session-tracer.js +608 -0
  56. package/dist/src/agent-session-tracer.js.map +1 -0
  57. package/dist/src/agent-session-transitions.d.ts +200 -26
  58. package/dist/src/agent-session-transitions.js +732 -32
  59. package/dist/src/agent-session-transitions.js.map +1 -1
  60. package/dist/src/agent-session-values.d.ts +696 -30
  61. package/dist/src/agent-session-values.js +39 -4
  62. package/dist/src/agent-session-values.js.map +1 -1
  63. package/dist/src/agent-session-wait-steps.d.ts +53 -0
  64. package/dist/src/agent-session-wait-steps.js +389 -0
  65. package/dist/src/agent-session-wait-steps.js.map +1 -0
  66. package/dist/src/agent-tracing-options.d.ts +107 -0
  67. package/dist/src/agent-tracing-options.js +20 -0
  68. package/dist/src/agent-tracing-options.js.map +1 -0
  69. package/dist/src/ai-sdk/ai-sdk-model-client.d.ts +40 -0
  70. package/dist/src/ai-sdk/ai-sdk-model-client.js +235 -0
  71. package/dist/src/ai-sdk/ai-sdk-model-client.js.map +1 -0
  72. package/dist/src/ai-sdk/index.d.ts +1 -0
  73. package/dist/src/ai-sdk/index.js +9 -0
  74. package/dist/src/ai-sdk/index.js.map +1 -0
  75. package/dist/src/index.d.ts +14 -3
  76. package/dist/src/index.js +12 -2
  77. package/dist/src/index.js.map +1 -1
  78. package/dist/src/mcp/index.d.ts +3 -0
  79. package/dist/src/mcp/index.js +10 -0
  80. package/dist/src/mcp/index.js.map +1 -0
  81. package/dist/src/mcp/mcp-resource-and-prompt-tools.d.ts +29 -0
  82. package/dist/src/mcp/mcp-resource-and-prompt-tools.js +185 -0
  83. package/dist/src/mcp/mcp-resource-and-prompt-tools.js.map +1 -0
  84. package/dist/src/mcp/mcp-server-config.d.ts +86 -0
  85. package/dist/src/mcp/mcp-server-config.js +38 -0
  86. package/dist/src/mcp/mcp-server-config.js.map +1 -0
  87. package/dist/src/mcp/mcp-tool-source.d.ts +470 -0
  88. package/dist/src/mcp/mcp-tool-source.js +1204 -0
  89. package/dist/src/mcp/mcp-tool-source.js.map +1 -0
  90. package/dist/src/stepwise/agent-definition.d.ts +166 -0
  91. package/dist/src/stepwise/agent-definition.js +9 -0
  92. package/dist/src/stepwise/agent-definition.js.map +1 -0
  93. package/dist/src/stepwise/function-tool.d.ts +130 -0
  94. package/dist/src/stepwise/function-tool.js +75 -0
  95. package/dist/src/stepwise/function-tool.js.map +1 -0
  96. package/dist/src/stepwise/index.d.ts +8 -0
  97. package/dist/src/stepwise/index.js +13 -0
  98. package/dist/src/stepwise/index.js.map +1 -0
  99. package/dist/src/stepwise/json-schema-validation.d.ts +9 -0
  100. package/dist/src/stepwise/json-schema-validation.js +141 -0
  101. package/dist/src/stepwise/json-schema-validation.js.map +1 -0
  102. package/dist/src/stepwise/model-client.d.ts +194 -0
  103. package/dist/src/stepwise/model-client.js +9 -0
  104. package/dist/src/stepwise/model-client.js.map +1 -0
  105. package/dist/src/stepwise/scripted-model-client.d.ts +99 -0
  106. package/dist/src/stepwise/scripted-model-client.js +156 -0
  107. package/dist/src/stepwise/scripted-model-client.js.map +1 -0
  108. package/dist/src/stepwise/stepwise-agent-runner.d.ts +171 -0
  109. package/dist/src/stepwise/stepwise-agent-runner.js +777 -0
  110. package/dist/src/stepwise/stepwise-agent-runner.js.map +1 -0
  111. package/dist/src/stepwise/stepwise-agent-session-flow.d.ts +35 -0
  112. package/dist/src/stepwise/stepwise-agent-session-flow.js +57 -0
  113. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +1 -0
  114. package/dist/src/stepwise/stepwise-input-items.d.ts +12 -0
  115. package/dist/src/stepwise/stepwise-input-items.js +172 -0
  116. package/dist/src/stepwise/stepwise-input-items.js.map +1 -0
  117. package/dist/src/stepwise/tool-source.d.ts +103 -0
  118. package/dist/src/stepwise/tool-source.js +9 -0
  119. package/dist/src/stepwise/tool-source.js.map +1 -0
  120. package/dist/src/testing/agent-session-test-client.d.ts +65 -8
  121. package/dist/src/testing/agent-session-test-client.js +92 -22
  122. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  123. package/dist/src/testing/context-window.d.ts +194 -0
  124. package/dist/src/testing/context-window.js +360 -0
  125. package/dist/src/testing/context-window.js.map +1 -0
  126. package/dist/src/testing/index.d.ts +2 -0
  127. package/dist/src/testing/index.js +2 -0
  128. package/dist/src/testing/index.js.map +1 -1
  129. package/dist/src/testing/private-dex-engine.d.ts +9 -0
  130. package/dist/src/testing/private-dex-engine.js +9 -1
  131. package/dist/src/testing/private-dex-engine.js.map +1 -1
  132. package/dist/src/testing/scripted-model-client.d.ts +1 -0
  133. package/dist/src/testing/scripted-model-client.js +10 -0
  134. package/dist/src/testing/scripted-model-client.js.map +1 -0
  135. package/dist/src/ui-stream/agent-event-json.d.ts +30 -0
  136. package/dist/src/ui-stream/agent-event-json.js +64 -0
  137. package/dist/src/ui-stream/agent-event-json.js.map +1 -0
  138. package/dist/src/ui-stream/agent-event-sse.d.ts +155 -0
  139. package/dist/src/ui-stream/agent-event-sse.js +167 -0
  140. package/dist/src/ui-stream/agent-event-sse.js.map +1 -0
  141. package/dist/src/ui-stream/agent-event-stream-positions.d.ts +42 -0
  142. package/dist/src/ui-stream/agent-event-stream-positions.js +153 -0
  143. package/dist/src/ui-stream/agent-event-stream-positions.js.map +1 -0
  144. package/dist/src/ui-stream/agent-session-record-reader.d.ts +74 -0
  145. package/dist/src/ui-stream/agent-session-record-reader.js +246 -0
  146. package/dist/src/ui-stream/agent-session-record-reader.js.map +1 -0
  147. package/dist/src/ui-stream/agent-ui-message-stream-responses.d.ts +219 -0
  148. package/dist/src/ui-stream/agent-ui-message-stream-responses.js +441 -0
  149. package/dist/src/ui-stream/agent-ui-message-stream-responses.js.map +1 -0
  150. package/dist/src/ui-stream/agent-ui-message-stream.d.ts +138 -0
  151. package/dist/src/ui-stream/agent-ui-message-stream.js +695 -0
  152. package/dist/src/ui-stream/agent-ui-message-stream.js.map +1 -0
  153. package/dist/src/ui-stream/agent-ui-message-types.d.ts +238 -0
  154. package/dist/src/ui-stream/agent-ui-message-types.js +57 -0
  155. package/dist/src/ui-stream/agent-ui-message-types.js.map +1 -0
  156. package/dist/src/ui-stream/agent-ui-messages.d.ts +68 -0
  157. package/dist/src/ui-stream/agent-ui-messages.js +222 -0
  158. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -0
  159. package/dist/src/ui-stream/agent-ui-stream-cursor.d.ts +64 -0
  160. package/dist/src/ui-stream/agent-ui-stream-cursor.js +186 -0
  161. package/dist/src/ui-stream/agent-ui-stream-cursor.js.map +1 -0
  162. package/dist/src/ui-stream/index.d.ts +14 -0
  163. package/dist/src/ui-stream/index.js +22 -0
  164. package/dist/src/ui-stream/index.js.map +1 -0
  165. package/dist/src/ui-stream/server-sent-event-responses.d.ts +6 -0
  166. package/dist/src/ui-stream/server-sent-event-responses.js +34 -0
  167. package/dist/src/ui-stream/server-sent-event-responses.js.map +1 -0
  168. package/package.json +44 -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.38.0
23
31
  ```
24
32
 
25
- `@superdurable/dex` 1.30.0 is a peer dependency, so the application, the core,
33
+ `@superdurable/dex` 1.38.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
@@ -52,7 +77,7 @@ agents packages share one version line; see the
52
77
 
53
78
  ```ts
54
79
  import { Client, Registry, Worker, openBlobCache } from "@superdurable/dex";
55
- import { createAgentSessionFlow } from "@superdurable/dex-agents";
80
+ import { createAgentSessionFlow, startAgentSession } from "@superdurable/dex-agents";
56
81
 
57
82
  const flow = createAgentSessionFlow(new MyFrameworkRunner(agent), "SupportAgent", {
58
83
  toolPolicies: { search_orders: { effect: "read" } },
@@ -65,7 +90,7 @@ await worker.start();
65
90
  await worker.waitPullRegistered({ timeoutMs: 10_000 });
66
91
  const client = new Client(registry, blobCache, { serverAddress: "127.0.0.1:8801" });
67
92
 
68
- await client.startFlow(flow, "session-42", { initialMessage: "Where is order 42?" });
93
+ await startAgentSession(client, flow, "session-42", { initialMessage: "Where is order 42?" });
69
94
  const snapshot = await client.invokeRPC(flow.getAgentSnapshot, "session-42");
70
95
  for (const interrupt of snapshot.pendingInterrupts) {
71
96
  await client.invokeRPC(flow.resolveInterrupt, "session-42", {
@@ -90,7 +115,612 @@ 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
+ A framework adapter cannot measure or rebuild the framework's own request, so
537
+ the core exports one shared rule. `estimateStoredInputTokens(turn,
538
+ fixedInputTokens)` counts each stored `frameworkItem` with `estimateTextTokens`,
539
+ three UTF-8 bytes per token, which errs high for English text.
540
+ `selectTurnBoundaryCutoff(turn, retainedTokenBudget)` cuts only between turns,
541
+ never inside the running turn, and returns `null` while `turn.frameworkRunState`
542
+ is set, because a resumed call continues from that state.
543
+ `buildContextSummaryPrompt(request)` writes the range as one plain text prompt,
544
+ starting with `contextSummaryTranscriptHeader`, that fits `inputTokenBudget`,
545
+ byte for byte as the Python core writes it. `CompactionPolicy.reservedInputTokens`
546
+ adds what an adapter's estimate cannot see, such as a framework's system prompt.
547
+ The adapters of this repository use them; see each adapter's README.
548
+ `ContextWindowSessionScenario` from `/testing` runs the same session past a
549
+ small context window for any adapter.
550
+
551
+ ## Session settings
552
+
553
+ Session settings let one Flow type serve sessions that differ, such as by
554
+ model, without a Worker pool per model. They are one JSON object that the
555
+ application sets when it starts the session and can replace later:
556
+
557
+ ```ts
558
+ const models: Record<string, ModelClient> = {
559
+ "gpt-5-mini": createAiSdkModelClient(openai("gpt-5-mini")),
560
+ "gpt-5": createAiSdkModelClient(openai("gpt-5")),
561
+ };
562
+ const flow = createStepwiseAgentSessionFlow(
563
+ {
564
+ name: "support",
565
+ instructions: "You help customers with their orders.",
566
+ model: models["gpt-5-mini"],
567
+ // Choose only from clients the Worker built; the settings come from the application.
568
+ resolveModel: (settings) => models[String(settings.model)] ?? models["gpt-5-mini"],
569
+ },
570
+ "SupportAgent",
571
+ );
572
+
573
+ validateSessionSettings({ model: "gpt-5" });
574
+ await startAgentSession(client, flow, sessionId, { initialMessage: "Hello", sessionSettings: { model: "gpt-5" } });
575
+ await client.invokeRPC(flow.updateSessionSettings, sessionId, { sessionSettings: { model: "gpt-5-mini" } });
576
+ ```
577
+
578
+ - `StartAgentSession` stores `AgentSessionStartInput.sessionSettings` in the
579
+ `SessionSettings` Attribute. `update_session_settings` replaces it; nothing
580
+ is merged, and `{}` clears it. It returns `false` before the session
581
+ initialized.
582
+ - A change takes effect when the next turn starts, the same boundary as
583
+ `planMode`. The Step that starts a turn copies the settings into
584
+ `TurnSessionSettings`, tagged with the turn, and the turn's model calls,
585
+ retries, and compaction read only that copy, so a running turn keeps the
586
+ settings it started with, even across a Worker crash. A copy that an earlier
587
+ turn left reads as no settings.
588
+ - A runner reads `AgentTurnContext.sessionSettings`, `{}` when there are
589
+ none, such as in a session started on 0.1.0. `ContextSummaryRequest` carries
590
+ the same value. Stepwise's `AgentDefinition.resolveModel` maps it to the
591
+ `ModelClient` of each model call, including summary calls and token
592
+ estimates, and `InstructionContext.sessionSettings` passes it to an
593
+ instructions function.
594
+ - `get_agent_snapshot` returns the next turn's settings as
595
+ `AgentSnapshot.sessionSettings`, not the running turn's: right after an
596
+ update, the running turn may still use the earlier settings.
597
+ - Only model calls see the settings. Tool calls do not receive them, and the
598
+ compaction policy's `contextWindowTokens` is one value for every resolved
599
+ model. This core does not check whether a resolved model accepts the forced
600
+ tool choice of `planningToolChoice: "required"`.
601
+ - **Never put a secret, or anything a browser must not see, in settings.**
602
+ They are Flow state that `get_agent_snapshot` returns, and the UI stream's
603
+ browser snapshot JSON includes them as stored. Put a reference, such as a
604
+ model or credential name, and let the Worker resolve it. An error that
605
+ `resolveModel` throws becomes the turn's `lastTurnError`, which snapshots
606
+ also show, so keep the settings out of its message.
607
+ - The compact JSON text, UTF-8, is at most `MAX_SESSION_SETTINGS_BYTES` (16
608
+ KiB). `validateSessionSettings` and `sessionSettingsSize` check it, and
609
+ `AgentSessionTestClient.startSession` calls it. The cores write some floats
610
+ and U+2028/U+2029 differently, so the session's own checks accept
611
+ `SESSION_SETTINGS_SIZE_TOLERANCE_BYTES` (1 KiB) more:
612
+ `update_session_settings` returns `false` beyond that, or for `null`, and
613
+ `StartAgentSession` fails the session at once, with no retry, with a reason
614
+ that starts with `invalidStartSessionSettingsReason`.
615
+ - Start sessions with settings, and call `update_session_settings`, only after
616
+ every Worker of the Flow type runs 0.2.0. A 0.1.0 Worker drops the start
617
+ settings and starts turns without them.
618
+
619
+ ## Message storage and retention
620
+
621
+ > **Upgrading from 0.1.0: stop every 0.1.0 Worker of a Flow type before any
622
+ > 0.2.0 Worker of it starts.** A 0.2.0 Worker moves an upgraded session's older
623
+ > messages out of `AgentMessages` into `AgentMessageChunks`. If a 0.1.0 Worker
624
+ > still runs, it fails the turns it takes on such a session, because it reads
625
+ > every message from `AgentMessages` and cannot read the ones moved into
626
+ > chunks.
627
+
628
+ A session stores its messages in a small mutable window and immutable
629
+ archive chunks, so neither a Step's load nor Flow state grows with the whole
630
+ session:
631
+
632
+ - `AgentMessages` is the window: the newest messages, one instance per
633
+ message, keyed by `messageSequenceKey(sequence)`. Every Step that stores
634
+ messages loads the whole window, so an append writes only the new instance.
635
+ - When an append takes the window past `messageWindowSize` (default 64), its
636
+ oldest messages move, in the same commit, into `AgentMessageChunks`
637
+ instances keyed by `messageSequenceKey` of their first sequence. A chunk
638
+ closes at `messageChunkSize` messages (default 32) or `messageChunkMaxBytes`
639
+ (default 1 MiB, measured on the JSON this core writes), whichever comes
640
+ first. A message whose chunk of one, the message plus 15 bytes, would pass
641
+ `messageChunkMaxBytes` never enters the window: the Step that stores it
642
+ writes it straight into its own chunk. So its first store already writes the
643
+ largest value it will ever need, and a message too large for the server's
644
+ `values.maxValueBytes` fails that Step, which fails the turn, instead of a
645
+ later rollover. A chunk is never rewritten.
646
+ - One commit moves at most 4 chunks and 8 MiB
647
+ (`messageRolloverMaxChunksPerCommit`, `messageRolloverMaxBytesPerCommit`), so
648
+ a backlog, such as a large 0.1.0 history, moves over several commits, and
649
+ the window can briefly stay over its size, such as after one commit stores
650
+ many messages, until it drains.
651
+ - `AgentMessageIndex` records `chunks`, the first and last sequence of every
652
+ kept chunk in order, `firstSequence` (the oldest stored message), and
653
+ `windowFirstSequence`. A chunk at or after `windowFirstSequence` holds one
654
+ oversized message among the window's. The index is written only when a
655
+ rollover, an oversized message, or retention changes it, not on every turn.
656
+ - `TurnReplySequence` records, for the latest turn, the first sequence after
657
+ its user messages. The Step that starts a turn writes it, so turn message
658
+ counts need no old messages. A value for another turn, such as one a 0.1.0
659
+ Worker started, is ignored, and the count reads the turn's stored messages.
660
+ - `RunModel`, `ApplyTurnControl`, and `CompactContext` load the window plus
661
+ only the chunks that overlap their model input, the messages after
662
+ `ContextSummary.lastSequence`, listed in the movement into the Step. Without
663
+ compaction the model input covers every message, so they load every chunk.
664
+ `RecordTurnFailure`, a failure route with static options, loads every
665
+ kept chunk when the runner implements `buildUnfinishedTurnMessages`.
666
+ `get_agent_snapshot` and `list_agent_messages` load the window and every
667
+ kept chunk.
668
+
669
+ Retention bounds Flow state when compaction is on. `retainedMessageLimit`
670
+ (default 1,000, `null` keeps everything) deletes the oldest chunk while every
671
+ message in it is at or before `ContextSummary.lastSequence` and at least
672
+ `retainedMessageLimit` stored messages come after it. So the newest
673
+ `retainedMessageLimit` messages and every message after the summary always
674
+ stay. `CompactContext` and every message append apply it. Deleted messages
675
+ leave `list_agent_messages` and `get_agent_snapshot`; paging ends at
676
+ `AgentMessageIndex.firstSequence`, and every page carries
677
+ `deletedBeforeSequence`, that sequence, once anything was deleted, so a
678
+ client can tell deleted history from the start of the conversation. An
679
+ application that keeps the full transcript copies new messages with
680
+ `list_agent_messages` before more than `retainedMessageLimit` messages follow
681
+ them, such as after each `turn_completed`.
682
+
683
+ A runner without the compaction methods cannot use the summary. When it meets
684
+ a session whose earlier messages retention deleted, its model input starts at
685
+ the oldest stored message, and the session logs a warning on every model call.
686
+
687
+ ```ts
688
+ const flow = createAgentSessionFlow(runner, "SupportAgent", {
689
+ compaction: { contextWindowTokens: 400_000 },
690
+ messageWindowSize: 64,
691
+ messageChunkSize: 32,
692
+ messageChunkMaxBytes: 1_048_576,
693
+ retainedMessageLimit: 1_000,
694
+ });
695
+ ```
696
+
697
+ A session that a 0.1.0 Worker wrote has no index and keeps every message in
698
+ `AgentMessages`, which is also how a new session starts: no index means every
699
+ message is in the window. Its appends on this release move the old messages
700
+ into chunks a few at a time; readers handle the half-moved session.
701
+
702
+ To read one stored message from outside the Flow, read `AgentMessageIndex`
703
+ first, then the window instance or the chunk that holds it:
704
+
705
+ ```ts
706
+ const sequence = 7;
707
+ const [index] = await client.getAttributes(sessionId, flow.agentMessageIndex);
708
+ let message: AgentMessage | undefined;
709
+ const range = index?.chunks.find((chunk) => chunk.firstSequence <= sequence && sequence <= chunk.lastSequence);
710
+ if (range === undefined) {
711
+ [message] = await client.getAttributes(sessionId, flow.agentMessages.instance(messageSequenceKey(sequence)));
712
+ } else {
713
+ const [chunk] = await client.getAttributes(
714
+ sessionId,
715
+ flow.agentMessageChunks.instance(messageSequenceKey(range.firstSequence)),
716
+ );
717
+ message = chunk?.messages[sequence - range.firstSequence];
718
+ }
719
+ ```
720
+
721
+ A commit between the two reads can move the message into a chunk; read
722
+ again when it is missing, or page with `list_agent_messages`, which reads one
723
+ committed state.
94
724
 
95
725
  ## The runner contract
96
726
 
@@ -101,10 +731,12 @@ implements `AgentFrameworkRunner` and wraps `createAgentSessionFlow` in its own
101
731
  factory.
102
732
 
103
733
  ```ts
104
- interface AgentFrameworkRunner {
734
+ // ToolOutcome is ToolExecutionResult, or DeferredToolOutcome for tools that ask for input.
735
+ interface AgentFrameworkRunner<ToolOutcome = ToolExecutionResult> {
105
736
  buildUserMessage(text: string): FrameworkMessage;
106
737
  runModelStep(turn: AgentTurnContext): Promise<AgentTurnOutcome>;
107
- executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolExecutionResult>;
738
+ executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolOutcome>;
739
+ buildUnfinishedTurnMessages?(turn: AgentTurnContext): FrameworkMessage[]; // optional
108
740
  }
109
741
 
110
742
  type AgentTurnOutcome = TurnCompleted | ToolCallsRequested;
@@ -114,6 +746,7 @@ interface ToolCallsRequested {
114
746
  frameworkRunState: string; // non-empty, opaque to the session
115
747
  toolCalls: DeferredToolCall[]; // at least one, in the order the model made them
116
748
  usage: AgentUsage;
749
+ newMessages?: FrameworkMessage[]; // optional: messages to store now
117
750
  }
118
751
  interface DeferredToolCall { toolCallId: string; toolName: string; arguments: string; policy: ResolvedToolPolicy }
119
752
  interface ResolvedToolPolicy { effect: ToolEffect; requiresApproval: boolean }
@@ -147,8 +780,16 @@ calls it for the turn failure note.
147
780
  | `resolveToolPolicy(name, frameworkEffect?)` | Effective `ResolvedToolPolicy { effect, requiresApproval }` |
148
781
  | `events` | `AgentEventWriter` bound to this turn and attempt |
149
782
  | `cancellationSignal`, `deadline` | Abort signal and attempt deadline from Dex |
783
+ | `stepExecutionId` | Dex Step execution ID of this `RunModel` |
784
+ | `enabledBuiltInTools` | Built-in tools to offer to the model, from `builtInToolDefinitions` |
785
+ | `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
786
  | `heartbeat()` | Extra heartbeat; the session already sends one every 20 seconds |
151
787
 
788
+ `stepExecutionId` and `enabledBuiltInTools` are optional in the type only so
789
+ that hand-built contexts in unit tests keep compiling; the session always sets
790
+ them. The same holds for `DeferredToolExecution.inputAnswers` and the 0.2.0
791
+ fields of `AgentToolCall`.
792
+
152
793
  The runner must:
153
794
 
154
795
  1. Rebuild framework input from `messages[*].frameworkItem` only. The other
@@ -167,13 +808,26 @@ The runner must:
167
808
  `runModelStep` continue the turn, including the messages the turn produced
168
809
  so far; the session stores no partial messages.
169
810
  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.
811
+ every message of the turn the session has not stored yet, in order: model
812
+ output, tool requests, tool results, and the answer. Without incremental
813
+ messages, that is every message the turn produced after its user message.
172
814
  6. Report only this model call's usage. Use `emptyAgentUsage()` and
173
- `addAgentUsage()` when a framework reports several usage parts.
815
+ `addAgentUsage()` when a framework reports several usage parts. Optionally
816
+ return `modelCallMetadata: { requestModel, responseModel, providerName,
817
+ responseId, finishReasons }` in either outcome. The session never stores it;
818
+ it names the `chat` span (see [Tracing](#tracing)). Omit a field the
819
+ framework does not expose.
174
820
  7. Throw for an unexpected failure. The model call reruns from the same
175
821
  committed state, up to 3 attempts within 30 minutes; then the turn fails.
176
822
 
823
+ **Incremental turn messages (optional).** A runner may return the turn's
824
+ messages as it goes in `ToolCallsRequested.newMessages`, usually the previous
825
+ round's tool results and the new model output; `RunModel` appends them in the
826
+ same commit that records the tool calls. Such a runner must implement
827
+ `buildUnfinishedTurnMessages(turn)`. `RecordTurnFailure` calls it, with every
828
+ record final, and appends its messages, such as the results of stored tool
829
+ calls, before the turn-failure note. If it throws, only the note is stored.
830
+
177
831
  Never run a tool inside `runModelStep`, unless the framework cannot defer it.
178
832
  Such a tool repeats whenever the model call reruns; the adapter documents it as
179
833
  at-least-once.
@@ -191,10 +845,24 @@ It runs in `CallTool` or `CallSingleAttemptTool`, possibly concurrently with
191
845
  other calls of the same response. `DeferredToolExecution` has `flowId`,
192
846
  `toolCall` (the call's `AgentToolCall` record), `idempotencyKey`
193
847
  (`<flowId>/<turnId>/<toolCallId>`, the same on every attempt), `attempt`,
194
- `events`, `cancellationSignal`, `deadline`, and `heartbeat()`.
848
+ `events`, `cancellationSignal`, `deadline`, `heartbeat()`,
849
+ `heartbeatCheckpoint`, and `saveHeartbeatCheckpoint(text)`.
850
+ `saveHeartbeatCheckpoint` records a heartbeat that carries the text, and every
851
+ later heartbeat of the attempt carries it too; a retried attempt reads it as
852
+ `heartbeatCheckpoint` and can resume work the earlier attempt started instead
853
+ of starting it again. A `DeferredToolCall` may carry `toolSourceReference`,
854
+ opaque text stored on the call's record (wire field `tool_source_reference`),
855
+ so the tool's source can resolve the tool without listing it again.
195
856
 
196
857
  - Run the real tool and return `{ output }`; the text is recorded and shown to
197
858
  the model.
859
+ - Return `ToolInputRequested` to ask the user a question first, only when the
860
+ call has taken no effect; see Built-in tools. Such a runner implements
861
+ `AgentFrameworkRunner<DeferredToolOutcome>`; the default type
862
+ parameter keeps `executeDeferredToolCall` returning `ToolExecutionResult`,
863
+ so code that calls a runner through `AgentFrameworkRunner` compiles as before.
864
+ - Return `ToolTaskStarted` when the tool started remote work that the session
865
+ polls; see [Tool tasks](#tool-tasks).
198
866
  - Throw `ToolExecutionError(message)` for a failure the model should see. The
199
867
  call is recorded as `failed` and not retried.
200
868
  - Throw anything else for an unexpected failure. `CallTool` retries it up to 3
@@ -202,6 +870,118 @@ other calls of the same response. `DeferredToolExecution` has `flowId`,
202
870
  or on a crash or timeout, the call is recorded as `failed` for a `read` tool
203
871
  and `outcome_unknown` for a write. The model then sees
204
872
  `outcomeUnknownToolResult`, because the write may have taken effect.
873
+ - When a tool Step's recovery error has `attemptProgress`
874
+ `StepAttemptProgress.NOT_STARTED`, no attempt reached a Worker and the tool
875
+ never ran. The call is then recorded as `failed` with an `error` that starts with
876
+ `toolCallNotRunErrorPrefix` (`the tool did not run, so it is safe to call
877
+ again`), then `": "` and the recovery detail. The model may call the tool
878
+ again. A call whose tool already asked a question ran before, so it keeps the
879
+ status of its effect.
880
+
881
+ ### Tool tasks
882
+
883
+ Some tools start remote work and get a handle back, such as an MCP task. A
884
+ tool Step must not hold a connection while that work runs, so
885
+ `executeDeferredToolCall` returns `ToolTaskStarted { kind: "tool_task_started",
886
+ taskId, pollIntervalSeconds }`, and the runner implements the optional
887
+ `pollToolTask(poll)`:
888
+
889
+ ```ts
890
+ class ReportRunner implements AgentFrameworkRunner<DeferredToolOutcome> {
891
+ // buildUserMessage and runModelStep omitted
892
+ public async executeDeferredToolCall(call: DeferredToolExecution): Promise<DeferredToolOutcome> {
893
+ if (call.heartbeatCheckpoint !== undefined) {
894
+ // An earlier attempt started the job.
895
+ return { kind: "tool_task_started", taskId: call.heartbeatCheckpoint, pollIntervalSeconds: 5 };
896
+ }
897
+ const jobId = await reports.start(call.idempotencyKey);
898
+ await call.saveHeartbeatCheckpoint?.(jobId);
899
+ return { kind: "tool_task_started", taskId: jobId, pollIntervalSeconds: 5 };
900
+ }
901
+
902
+ public async pollToolTask(poll: ToolTaskPoll): Promise<ToolTaskPollOutcome> {
903
+ let job = await reports.get(poll.toolCall.taskId!);
904
+ const answered = poll.toolCall.inputContinuation; // ID of the question answered last
905
+ const resolved = poll.pendingAnswer; // its resolution, on the poll right after it
906
+ if (resolved !== null && job.questionId === answered) {
907
+ // Keyed by the question ID, so a retried poll never answers a later question.
908
+ job = resolved.resolution === "answered"
909
+ ? await reports.answer(job.id, answered, resolved.answer)
910
+ : await reports.skip(job.id, answered); // declined, or the deadline passed
911
+ }
912
+ if (job.questionId !== null && job.questionId !== answered) {
913
+ return { kind: "tool_input_requested", question: job.question, choices: job.choices,
914
+ continuation: job.questionId };
915
+ }
916
+ if (job.done) {
917
+ return { output: job.summary };
918
+ }
919
+ // Keep the answered ID only while the job still shows that question.
920
+ const kept = job.questionId === answered ? answered : null;
921
+ return { kind: "tool_task_pending", pollIntervalSeconds: 10, continuation: kept };
922
+ }
923
+ }
924
+ ```
925
+
926
+ The tool Step records `waiting_for_task` with `taskId` and
927
+ `taskPollIntervalSeconds`, writes `tool_task_started`, and ends.
928
+ `PollToolTask` waits on a Timer for the interval, rounded up to whole seconds
929
+ from 1 to a day, and calls `pollToolTask` once per execution:
930
+
931
+ - `{ output }` records `succeeded`, and `ToolExecutionError` records `failed`.
932
+ - `ToolTaskPending { kind: "tool_task_pending", pollIntervalSeconds,
933
+ continuation? }` stores the next interval, stores `continuation` as
934
+ `inputContinuation`, and polls again; omitted or `null` clears it.
935
+ - `ToolInputRequested` asks the user through `AwaitQuestionAnswer`, with the
936
+ interrupt ID `<toolCallId>-input-<n>`. The task has started, so the question
937
+ never ends the call: an answer, a decline, and a passed deadline each go back
938
+ to the task as a `QuestionAnswer` whose `resolution` is `answered`,
939
+ `declined`, or `deadline_passed`. `PollToolTask` polls at once and hands it
940
+ over as `poll.pendingAnswer`, only on that first poll; a retried attempt of
941
+ it gets it again. That poll reads the question's continuation in
942
+ `toolCall.inputContinuation`, and later polls read what the last
943
+ `ToolTaskPending` returned. Keep the answered question's continuation only
944
+ while the task may still show that question, so that a later identical
945
+ question reads as new. Deliver idempotently, keyed by a question ID when the
946
+ task has one. When a retried attempt cannot tell whether an earlier one
947
+ delivered, return the question again instead: the user answers twice rather
948
+ than a stale answer reaching a later question. When the task cannot take a
949
+ decline, cancel it best-effort and throw an error other than
950
+ `ToolExecutionError`; the call is then recorded by effect.
951
+ - Any other error fails the poll attempt. A poll gets three attempts; then
952
+ `RecordToolFailure` records `failed` for a `read` call and `outcome_unknown`
953
+ for a write, and `RecordToolOutcomeUnknown` records `outcome_unknown` for a
954
+ single-attempt call, because the task may still run.
955
+
956
+ No Step holds a connection while the work runs, and a Worker restart only
957
+ delays the next poll. A crash during a poll repeats only that poll, so a poll
958
+ must only read the task, or deliver an answer idempotently. A `CallTool`
959
+ attempt that dies after the remote work started runs again: save the handle
960
+ with `saveHeartbeatCheckpoint` before you return it, and return the saved
961
+ handle when `heartbeatCheckpoint` holds one. Dex may drop a heartbeat
962
+ checkpoint, so an attempt that dies before its checkpoint is saved and before
963
+ the tool Step commits can still start the work twice; pass the idempotency key
964
+ on so the remote system can drop the duplicate. `PollToolTask` owns the record
965
+ until it is final and takes no lock. Steering waits for a started task, its
966
+ questions included, and a call with `taskId` is never recorded `rejected`.
967
+ `cancel_turn` abandons the task instead, so Stop never waits for it: it
968
+ cancels `PollToolTask`, drops whatever a running poll returns, and records the
969
+ call `outcome_unknown` with `cancellationReason` `"turn_cancelled"` in
970
+ `CancelToolTask`. Implement `cancelToolTask` to stop the remote work
971
+ best-effort; make it idempotent by `taskId`, because a crash or a failed
972
+ attempt calls it again. When it keeps failing, the call is still recorded and
973
+ the turn ends:
974
+
975
+ ```ts
976
+ public async cancelToolTask(cancellation: ToolTaskCancellation): Promise<void> {
977
+ await reports.cancel(cancellation.toolCall.taskId!);
978
+ }
979
+ ```
980
+
981
+ `DeferredToolExecution.supportsToolTasks` tells a tool whether the runner polls tasks. A runner without `pollToolTask`
982
+ that returns `ToolTaskStarted` anyway gets `failed` for a `read` call and
983
+ `outcome_unknown` otherwise, with `missingToolTaskPoller`. Each poll is a Step
984
+ in the session's history, so poll at the slowest interval the task allows.
205
985
 
206
986
  ### Events
207
987
 
@@ -213,19 +993,60 @@ adapter-specific events a prefix, such as `"vercel_ai.step_finished"`. The
213
993
  writer coalesces consecutive deltas of one type for up to 100 ms or 1,024
214
994
  characters, and drops writes after the handler returns.
215
995
 
996
+ ## Finding sessions that need a person
997
+
998
+ `startAgentSession` makes a session searchable by the Steps it waits in, so one query finds every session waiting
999
+ for a person, across agents:
1000
+
1001
+ ```ts
1002
+ await startAgentSession(client, flow, "session-42", { initialMessage: "Refund order 7" });
1003
+ const waiting = await client.searchFlows('ActiveStepTypes = "AwaitToolApproval"', 50);
1004
+ ```
1005
+
1006
+ `AwaitQuestionAnswer` finds questions the same way. A session leaves the search when it moves on. `Client.startFlow`
1007
+ does not turn this on; pass `configOverride: { activeStepSearchMode: agentSessionActiveStepSearchMode }` there, or use
1008
+ `Client.updateFlowConfig` for a running session. See
1009
+ [Find sessions that need a person](https://docs.superdurable.io/agent-frameworks/deploy-agents#find-sessions).
1010
+
1011
+ ## Spend limits
1012
+
1013
+ `ModelSpendLimit` is an application's spend limit, usually per account or API key
1014
+ across many sessions. The session asks it before each model call and reports what
1015
+ the call used. Counters and limits belong to the application. Without one, the
1016
+ session makes no checks. See the interface's documentation and
1017
+ [Spend limits](https://docs.superdurable.io/agent-frameworks/deploy-agents#spend-limits).
1018
+
216
1019
  ## Flow shape
217
1020
 
218
1021
  ```text
219
1022
  StartAgentSession -> RunModel (initial message) | AwaitUserMessage
220
- AwaitUserMessage -> RunModel
221
- RunModel -> AwaitUserMessage (final answer)
1023
+ AwaitUserMessage -> RunModel (a message, or an approved plan revision)
1024
+ | ApplyTurnControl (steer)
1025
+ | AwaitUserMessage (a stale approval)
1026
+ RunModel -> ApplyTurnControl (steered or cancelled: ends the turn, no model call)
1027
+ | AwaitUserMessage (final answer)
1028
+ | CompactContext({ through_sequence }) -> RunModel (compaction policy, input over the threshold)
222
1029
  | one Step per tool call + ResumeTurn(n) (tool calls, all at once)
223
1030
  | RecordTurnFailure -> AwaitUserMessage (retries exhausted)
224
1031
  AwaitToolApproval(id) -> CallTool(id) | CallSingleAttemptTool(id) (approved)
225
1032
  | dead end, after publishing to ToolCallResults (rejected or deadline)
1033
+ AwaitQuestionAnswer(id) -> dead end (ask_user answered, declined, or past its deadline)
1034
+ | CallTool(id) | CallSingleAttemptTool(id) (a tool's question answered)
1035
+ AwaitTimer(id) -> dead end, after publishing to ToolCallResults
1036
+ CallTool(id), CallSingleAttemptTool(id) -> AwaitQuestionAnswer(id) (ToolInputRequested)
1037
+ | PollToolTask(id) (ToolTaskStarted)
1038
+ PollToolTask(id) -> itself (ToolTaskPending) | AwaitQuestionAnswer(id) (the task's question)
1039
+ | dead end, after publishing to ToolCallResults (a result, or a failure route)
1040
+ AwaitQuestionAnswer(id) -> PollToolTask(id) (a task's question answered)
226
1041
  CallTool(id), CallSingleAttemptTool(id), RecordToolFailure(id), RecordToolOutcomeUnknown(id)
227
1042
  -> dead end, after publishing to ToolCallResults
228
- ResumeTurn(n) -> RunModel
1043
+ ResumeTurn(n) -> RunModel | ApplyTurnControl (steered or cancelled) | ResumeTurn(n) (control wake)
1044
+ ApplyTurnControl -> RunModel (steered messages) | AwaitUserMessage
1045
+ cancel_turn -> cancels RunModel, CompactContext, RecordTurnFailure, and the waiting Steps
1046
+ -> FinishTurnCancellation(turnId)
1047
+ FinishTurnCancellation -> ApplyTurnControl (the model call was cancelled)
1048
+ | CancelToolTask(id) per abandoned task | dead end (ResumeTurn ends the turn)
1049
+ CancelToolTask(id) -> dead end, after publishing to ToolCallResults (or RecordToolOutcomeUnknown(id))
229
1050
  ```
230
1051
 
231
1052
  A turn with one read tool and a final answer therefore runs `RunModel`,
@@ -234,15 +1055,22 @@ A turn with one read tool and a final answer therefore runs `RunModel`,
234
1055
  | Step type | Input | Behavior | Options |
235
1056
  | --- | --- | --- | --- |
236
1057
  | `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` |
1058
+ | `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 |
1059
+ | `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` |
1060
+ | `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 |
1061
+ | `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 |
1062
+ | `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 |
1063
+ | `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` | |
1064
+ | `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
1065
  | `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 |
1066
+ | `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 |
1067
+ | `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 |
1068
+ | `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 |
1069
+ | `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 |
1070
+ | `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 |
1071
+ | `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 |
1072
+ | `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 |
1073
+ | `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
1074
 
247
1075
  Every tool Step receives the tool call ID as its input, and a failure route
248
1076
  receives the failed Step's input, so each Step knows its own call without any
@@ -267,47 +1095,82 @@ Do not repeat these tool calls unless the user asks again.
267
1095
  | Persistence name | Kind | Value |
268
1096
  | --- | --- | --- |
269
1097
  | `AgentState` | Attribute | `AgentState` |
270
- | `AgentMessages` | AttributeMap, key = `messageSequenceKey(sequence)` (12-digit zero-padded) | `AgentMessage` |
1098
+ | `AgentMessages` | AttributeMap, key = `messageSequenceKey(sequence)` (12-digit zero-padded) | `AgentMessage`; only the window, the messages from `AgentMessageIndex.windowFirstSequence` on |
1099
+ | `AgentMessageChunks` | AttributeMap, key = `messageSequenceKey(first sequence)` | `AgentMessageChunk { messages }`, the older messages of one `AgentMessageIndex.chunks` range, immutable |
1100
+ | `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 |
1101
+ | `TurnReplySequence` | Attribute | `TurnReplySequence { turn_id, sequence }`: where the latest turn's replies start; the Step that starts a turn writes it |
271
1102
  | `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 |
1103
+ | `AgentToolCalls` | AttributeMap, key = tool call ID | `AgentToolCallRecord`; only the active turn's records |
273
1104
  | `PendingInterrupts` | Attribute | `AgentInterrupt[]` |
274
1105
  | `AgentUsage` | Attribute | cumulative `AgentUsage` |
275
1106
  | `QueuedUserMessages` | Channel | `AgentUserMessage` |
276
1107
  | `InterruptResolutions` | ChannelMap, key = interrupt ID | `AgentInterruptResolution` |
277
1108
  | `ToolCallResults` | Channel, internal | `ToolCallCompleted { tool_call_id }` |
1109
+ | `SteeredUserMessages` | Channel | `AgentUserMessage` |
1110
+ | `TurnControlRequests` | Channel, internal | `TurnControlRequest { kind }` |
1111
+ | `TurnCancellationRequest` | Attribute | `TurnCancellationRequest { turn_id, requested_at }`, written only by `cancel_turn` |
1112
+ | `ToolCallCancellations` | ChannelMap, internal, key = tool call ID | `ToolCallCancellation { reason }` |
1113
+ | `AgentTraceContext` | Attribute | string, the W3C `traceparent` of the current activation's root span when its context is valid, sampled or not; deleted otherwise |
1114
+ | `AgentPlan` | Attribute | `AgentPlan`, unset until `write_todos` first succeeds |
1115
+ | `PlanExecutions` | ChannelMap, key = `planExecutionKey(revision)` (decimal) | `PlanExecutionRequest { revision }` |
1116
+ | `PendingPlanExecution` | Attribute | `PlanExecutionRequest` that `execute_plan` accepted and `AwaitUserMessage` has not consumed |
1117
+ | `ContextSummary` | Attribute | `ContextSummary`, set once the session compacts |
278
1118
  | `AgentEvents` | Stream | `AgentEvent` |
279
1119
 
280
- `AgentState.status` is `waiting_for_user_message`, `running_model`, or
281
- `running_tools`. Wire values are snake_case JSON; TypeScript values are the
1120
+ `AgentState.status` is `waiting_for_user_message`, `running_model`,
1121
+ `running_tools`, or `compacting_context`, and `AgentState.interactionMode` is
1122
+ `chat`, `planning`, or `executing`. A stored record's status can also be
1123
+ `awaiting_answer`, `waiting_for_timer`, or `waiting_for_task`; these non-final
1124
+ statuses appear only in `AgentToolCallRecord`, which snapshots,
1125
+ `client.getAttributes`, and `ToolTaskPoll.toolCall` carry. Runners receive
1126
+ `AgentToolCall`, whose `AgentToolCallStatus` keeps its six values, because a
1127
+ runner never sees a waiting record outside `pollToolTask`. The record gains
1128
+ `wait_until`, `input_continuation`, `input_answers`, `input_question`,
1129
+ `input_choices`, `input_allows_free_text`, `cancellation_reason`, `task_id`,
1130
+ `task_poll_interval_seconds`, and `task_answer_pending`. Wire values are snake_case JSON; TypeScript values are the
282
1131
  same fields in camelCase. Message sequences start at 1 and have no gaps. Turn
283
1132
  IDs are `turn-<n>`, where `n` is `turn_count + 1` when the turn starts.
284
1133
 
285
1134
  | RPC | Input | Output |
286
1135
  | --- | --- | --- |
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 |
1136
+ | `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 |
1137
+ | `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 |
1138
+ | `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 |
1139
+ | `delete_queued_message` | `{ message_id }` | `false` for a message no longer queued |
1140
+ | `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` |
1141
+ | `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 |
1142
+ | `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` |
1143
+ | `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 |
1144
+ | `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
1145
 
292
1146
  | Event type | Payload |
293
1147
  | --- | --- |
294
1148
  | `turn_started` | `{"sequence"}` of the user message |
295
- | `turn_completed` | `{"message_count"}` appended by the turn |
1149
+ | `turn_completed` | `{"message_count"}` appended by the turn after its user message, including incremental messages |
296
1150
  | `turn_failed` | `{"error"}` |
1151
+ | `turn_steered` | `{"steered_message_count","message_count"}` |
1152
+ | `turn_cancelled` | `{"message_count"}` |
297
1153
  | `text_delta`, `reasoning_delta` | `{"text"}` |
298
1154
  | `tool_call_deferred` | `{"tool_call_id","tool_name","effect","requires_approval"}`; the model asked for a call, which runs in its own Step |
299
1155
  | `tool_call_started` | `{"tool_call_id","tool_name"}` |
300
1156
  | `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"}` |
1157
+ | `interrupt_raised` | `{"interrupt_id","kind","tool_call_id","tool_name","deadline","question","choices","allows_free_text"}` |
1158
+ | `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 |
1159
+ | `timer_started` | `{"tool_call_id","wait_until","reason"}` for a `durable_wait` call |
1160
+ | `plan_updated` | `{"revision","status","task_count"}` after `write_todos` replaced the plan, or when an approval marks a draft `active` |
1161
+ | `plan_execution_started` | `{"revision"}`, after the `turn_started` of an `executing` turn |
1162
+ | `tool_task_started` | `{"tool_call_id","tool_name","task_id"}`; the tool started remote work that `PollToolTask` polls |
303
1163
  | `usage_recorded` | `{"recorded","session"}`, each an `AgentUsage` object |
1164
+ | `compaction_started` | `{"through_sequence"}`, when `CompactContext` starts |
1165
+ | `compaction_finished` | `{"generation","through_sequence"}`, when `CompactContext` stored the summary |
304
1166
 
305
1167
  Every event carries `turn_id`, the Step `attempt`, and `step_execution_id`.
306
1168
  Group live text by `(turn_id, step_execution_id, attempt)`: within one
307
1169
  `step_execution_id`, keep only the highest `attempt` seen and drop the partial
308
1170
  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.
1171
+ its separate model call and tool call Steps. After `turn_completed`,
1172
+ `turn_failed`, `turn_steered`, or `turn_cancelled`, replace the turn's live
1173
+ text with the stored messages.
311
1174
 
312
1175
  ## Effects
313
1176
 
@@ -323,6 +1186,594 @@ tool's effect: its `ToolPolicy`, then the framework effect the runner passes,
323
1186
  then `defaultToolEffect`. No tool runs inside `RunModel`, unless the framework
324
1187
  cannot defer it.
325
1188
 
1189
+ ## MCP tools (`@superdurable/dex-agents/mcp`)
1190
+
1191
+ `@superdurable/dex-agents/mcp` turns the tools of MCP servers into tools of an
1192
+ agent session. Every MCP tool call runs in its own tool Step, with an effect
1193
+ class from the tool's annotations, so a crash never repeats a write silently.
1194
+ It needs `@modelcontextprotocol/sdk` 1.x, an optional peer dependency; the main
1195
+ entry point never imports it:
1196
+
1197
+ ```bash
1198
+ npm i @superdurable/dex-agents @superdurable/dex@1.38.0 @modelcontextprotocol/sdk zod
1199
+ ```
1200
+
1201
+ Configure the servers on the Worker, never from Flow input, a user message, or
1202
+ a browser: a stdio server's command runs with the Worker's privileges.
1203
+
1204
+ ```ts
1205
+ import { McpToolSource } from "@superdurable/dex-agents/mcp";
1206
+
1207
+ const tools = new McpToolSource([
1208
+ { transport: "stdio", name: "files", command: "mcp-server-files", args: ["/srv/reports"] },
1209
+ {
1210
+ transport: "streamable_http",
1211
+ name: "tickets",
1212
+ url: "https://tickets.example.com/mcp",
1213
+ headers: { Authorization: `Bearer ${process.env.TICKETS_TOKEN}` },
1214
+ },
1215
+ ]);
1216
+ ```
1217
+
1218
+ A Stepwise agent takes the source in `toolSources`; see
1219
+ [Tool sources](#tool-sources). Another runner offers the tools and defers every
1220
+ call; the tool Step runs it:
1221
+
1222
+ ```ts
1223
+ class MyRunner implements AgentFrameworkRunner {
1224
+ // buildUserMessage omitted
1225
+ public async runModelStep(turn: AgentTurnContext): Promise<AgentTurnOutcome> {
1226
+ const offered = await tools.listTools(); // McpTool: name, description, inputSchema
1227
+ const response = await callMyModel(turn.messages, offered);
1228
+ return {
1229
+ kind: "tool_calls_requested",
1230
+ frameworkRunState: JSON.stringify(response),
1231
+ usage: response.usage,
1232
+ toolCalls: response.toolCalls.map((call) =>
1233
+ tools.deferredToolCall(turn, { toolCallId: call.id, toolName: call.name, arguments: call.arguments }),
1234
+ ),
1235
+ };
1236
+ }
1237
+ public async executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolExecutionResult> {
1238
+ const handlers = { ...this.localHandlers, ...(await tools.deferredToolHandlers()) };
1239
+ return handlers[call.toolCall.toolName]!(call);
1240
+ }
1241
+ }
1242
+ ```
1243
+
1244
+ `listTools()` lists each server on its own and caches its tools per Worker.
1245
+ The first listing of a server is awaited; after `listingTtlMs` (5 minutes) a
1246
+ server is listed again in the background while its earlier tools are
1247
+ returned. A server that cannot be listed is left out with a warning and is not
1248
+ tried again for `listingFailureBackoffMs` (30 seconds), then in the
1249
+ background, so one bad server never hides the others' tools or slows every
1250
+ model call. One listing per server runs at a time. `listTools({ refresh: true
1251
+ })` lists every server again now. `deferredToolCall` stores the
1252
+ server, the tool's MCP name, and its task support on the call's record, so the
1253
+ tool Step calls that server directly, without a listing; `callTool` finds the
1254
+ server through the listing. Tool names are `<server>__<tool>`, with characters
1255
+ other than letters, digits, `_`, and `-` replaced by `_` and names over 64
1256
+ characters shortened with a hash. When two tools get the same name, the first
1257
+ in server order is offered and the other is left out with a warning. Key tool
1258
+ policies by that name.
1259
+
1260
+ ### Resources and prompts
1261
+
1262
+ With `resourceAndPromptToolsEnabled: true`, `listTools` also offers three
1263
+ `read` tools after the servers' tools, when at least one server is configured.
1264
+ Each takes a configured server's name as `server`, an `enum` of the names, and
1265
+ opens a new connection to that server with the source's transport, headers,
1266
+ and `connectionTimeoutMs`:
1267
+
1268
+ | Tool | Arguments | Output |
1269
+ | --- | --- | --- |
1270
+ | `mcp_list_resources_and_prompts` | `server` | `{"prompts": [...], "resource_templates": [...], "resources": [...]}`, every page |
1271
+ | `mcp_read_resource` | `server`, `uri` | `{"contents": [...]}` |
1272
+ | `mcp_get_prompt` | `server`, `name`, optional `arguments` (strings by name) | `{"description": ..., "messages": [...]}` |
1273
+
1274
+ The names, descriptions, argument schemas, and output text match the Python
1275
+ core: outputs are the server's items in JSON with sorted keys and Python's
1276
+ separators, and whole numbers such as a `priority` of 1 are written alike. A
1277
+ server that does not offer resources or prompts lists none. A connection
1278
+ failure is retried `connectionAttempts` times. An MCP error answer, a server
1279
+ that stays unreachable, and an unconfigured server throw `ToolExecutionError`.
1280
+ Enable the tools on one source of an agent only. Every
1281
+ request these tools send is in `@modelcontextprotocol/sdk` 1.32.2, so this core
1282
+ has no gap against the Python core. Neither core follows resource subscriptions
1283
+ or list-change notifications. The names are exported as
1284
+ `mcpListResourcesAndPromptsToolName`, `mcpReadResourceToolName`, and
1285
+ `mcpGetPromptToolName`; key a tool policy by them to require approval.
1286
+
1287
+ ### Effects
1288
+
1289
+ `toolEffectFromMcpToolAnnotations` classifies each tool:
1290
+
1291
+ | MCP annotations | Effect | Tool Step | Approval by default |
1292
+ | --- | --- | --- | --- |
1293
+ | `readOnlyHint: true` | `read` | `CallTool` | No |
1294
+ | `idempotentHint: true`, whatever `destructiveHint` says | `idempotent_write` | `CallTool` | No |
1295
+ | anything else, or none | `defaultToolEffect`, `unclassified` by default | `CallSingleAttemptTool` | Yes |
1296
+
1297
+ The Python core classifies the same way. A policy in `toolPolicies` wins over
1298
+ the annotations. `trustsToolAnnotations` defaults to `true`, so a server with a
1299
+ wrong `readOnlyHint` on a write gets that write run without approval and
1300
+ retried up to three times. Set `trustsToolAnnotations: false` on a server whose
1301
+ hints you do not trust.
1302
+
1303
+ ### What a call does
1304
+
1305
+ The source uses `@modelcontextprotocol/sdk` 1.x, which speaks MCP up to
1306
+ 2025-11-25. Every request opens a new connection, performs the `initialize`
1307
+ handshake within `connectionTimeoutMs` (30 seconds by default), and then ends
1308
+ the protocol session with an HTTP `DELETE` and closes the connection, so no
1309
+ protocol session outlives a request and a server restart between listing and
1310
+ calling is harmless. Every
1311
+ `tools/call` carries the session's idempotency key,
1312
+ `<flowId>/<turnId>/<toolCallId>`, in `_meta` under `mcpIdempotencyKeyMetaKey`
1313
+ (`io.superdurable.dex/idempotencyKey`), and the tool Step's cancellation
1314
+ signal aborts the request.
1315
+
1316
+ | What happens | Recorded | Re-issued? |
1317
+ | --- | --- | --- |
1318
+ | A result | `succeeded`: text blocks joined with newlines, other content as JSON | - |
1319
+ | An `isError` result, or a JSON-RPC error response other than `-32603` | `failed` with the text (`ToolExecutionError`) | No |
1320
+ | The call was never sent: its server is not configured, or 3 connection attempts failed | `failed` | No |
1321
+ | 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 |
1322
+
1323
+ `notifications/progress` become `mcp.tool_progress` events on `AgentEvents`.
1324
+
1325
+ ### Long-running MCP tasks
1326
+
1327
+ A tool whose `execution.taskSupport` is `required` is called as a task, the
1328
+ 2025-11-25 experimental tasks of the SDK. For a Streamable HTTP server, the
1329
+ tool Step saves the task ID as its heartbeat checkpoint before anything else,
1330
+ so a `CallTool` attempt that dies after `tools/call` returned the task resumes
1331
+ the same task on its retry instead of calling the tool again, once the
1332
+ checkpoint reached Dex; a lost checkpoint leaves only the idempotency key to
1333
+ drop a second task. A runner without `pollToolTask` gets every task polled
1334
+ inside the tool Step, as described below for stdio servers.
1335
+
1336
+ A Streamable HTTP server without protocol sessions keeps its tasks in a store
1337
+ that any connection can read, so its task becomes a [tool task](#tool-tasks):
1338
+ the tool Step returns `ToolTaskStarted` and ends, and `PollToolTask` calls
1339
+ `pollToolTask` on a Timer. Each poll sends `tasks/get` once, writes an
1340
+ `mcp.task_status` event when the status changed, and fetches a completed
1341
+ task's result with
1342
+ `tasks/result`. The next interval is the server's `pollInterval`, rounded up to
1343
+ whole seconds, or, when that is shorter, double the previous interval, up to a
1344
+ minute. No Step holds a connection while the task runs, and a Worker restart
1345
+ only delays the next poll. A server that no longer knows the task, or cannot be
1346
+ reached for three poll attempts, records `failed` for a `read` tool and
1347
+ `outcome_unknown` otherwise. `cancel_turn` abandons the task, and
1348
+ `cancelToolTask` sends `tasks/cancel`; forward it from your runner's
1349
+ `cancelToolTask`, as the Stepwise runner does. Any answer from the server ends
1350
+ the cancellation, so a retried one is harmless.
1351
+
1352
+ The source requests such a task's TTL as `taskTtlMs`, 24 hours by default. The
1353
+ TTL starts when the server creates the task, and the poll may span Worker
1354
+ restarts, so it must cover the task's run time plus the longest time no Worker
1355
+ serves the session. A task whose TTL passes before the session reads its result
1356
+ is lost and recorded by effect, as above. The server may grant a shorter TTL.
1357
+
1358
+ A stdio server's task lives in its process, and a server that keeps protocol
1359
+ sessions, as the SDK's task store does for a stateful Streamable HTTP server,
1360
+ binds its tasks to the session that the request ends. Neither can be polled
1361
+ from another Step. The tool Step polls such a task with `tasks/get`, with a
1362
+ heartbeat per poll, for at most `taskWaitTimeoutMs` (10 minutes by default)
1363
+ per attempt, holding a Worker slot, and requests a TTL of four times
1364
+ `taskWaitTimeoutMs`, so the task outlives every attempt's wait. When the wait
1365
+ passes, or the Worker dies, `CallTool` retries a `read` or `idempotent_write`
1366
+ call; a session-bound task is invisible to the retry's new session, so the
1367
+ retry calls the tool again. Other effects record `outcome_unknown`.
1368
+ `callTool`, the framework adapter path, polls every task inside the call.
1369
+
1370
+ ### Input requests
1371
+
1372
+ A task in `input_required` is cancelled and records `failed` with `the MCP
1373
+ server asked for input that this agent session cannot provide yet (...)`. In
1374
+ the 2025-11-25 tasks, the server sends its input requests as requests of its
1375
+ own during `tasks/result` and must get the answer on that live connection, so
1376
+ no durable question can carry them; the Python core, on 2026-07-28, turns a
1377
+ task's input request into a session question. The source declares no
1378
+ elicitation, sampling, or roots capability, so a server cannot send those
1379
+ requests directly.
1380
+
1381
+ ### With a framework adapter
1382
+
1383
+ The runner above is the core's runner-agnostic path. A framework adapter uses
1384
+ the source when it accepts tools defined by a JSON Schema: wrap each `McpTool`
1385
+ as a framework tool that calls `tools.callTool(name, input, { idempotencyKey })`
1386
+ with the key from the adapter's tool context, and pass
1387
+ `{ ...tools.toolPolicies(), ...yourPolicies }` as the adapter's `toolPolicies`.
1388
+ No adapter package changes for this.
1389
+
1390
+ | Adapter | Schema-only tool | Key inside the tool |
1391
+ | --- | --- | --- |
1392
+ | `@superdurable/dex-agents-vercel-ai` | `tool({ inputSchema: jsonSchema(...), execute })` | `getAgentToolExecutionScope()?.idempotencyKey` |
1393
+ | `@superdurable/dex-agents-openai-agents` | `tool({ parameters: <JSON Schema>, strict: false, execute })` | `getDeferredToolExecution()`, the whole call |
1394
+ | `@superdurable/dex-agents-mastra` | `createTool(...)` or an AI SDK tool | `requestContext.get(idempotencyKeyRequestContextKey)` |
1395
+ | `@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 |
1396
+
1397
+ Vercel AI SDK and Mastra also defer their own MCP client tools into tool
1398
+ Steps; use the source there for the shared idempotency key and annotation
1399
+ handling. The OpenAI Agents SDK runs its own MCP servers inline in `RunModel`,
1400
+ so a wrapper is its only path to tool Steps.
1401
+
1402
+ ## UI streams
1403
+
1404
+ `@superdurable/dex-agents/ui-stream` lets a browser render a live session
1405
+ from its `AgentEvents` Stream and recover after a reconnect. It needs no store
1406
+ besides Dex: the resume position rides in a token the browser sends back. It
1407
+ offers three things:
1408
+
1409
+ - an [AI SDK](https://ai-sdk.dev) UI message stream (protocol
1410
+ `x-vercel-ai-ui-message-stream: v1`) for `useChat`;
1411
+ - a plain Server-Sent Events stream whose event `id` is the Dex resume token,
1412
+ so an `EventSource` resumes through `Last-Event-ID`;
1413
+ - snapshots that map `get_agent_snapshot` to AI SDK `UIMessage` objects or to
1414
+ wire JSON without the fields only the session's Steps use.
1415
+
1416
+ It has no runtime dependency on `ai`. Its part and message types are
1417
+ structural subsets of the AI SDK `UIMessageChunk` and `UIMessage` types, and
1418
+ the tests check that they stay assignable. The helpers return Fetch API
1419
+ `Response` objects, so they mount in any server that speaks `Request` and
1420
+ `Response`.
1421
+
1422
+ **Authorize every route.** The session ID, the approval decisions, and the
1423
+ resume token all come from the browser. Each route must check that the caller
1424
+ may read, or write to, the session it names before it calls a helper. The
1425
+ helpers only check that a decision answers an interrupt pending in that
1426
+ session's active turn.
1427
+
1428
+ ### useChat
1429
+
1430
+ Mount three routes, with the chat ID as the session ID:
1431
+
1432
+ ```ts
1433
+ import {
1434
+ agentResumeTokenHeaderName,
1435
+ createAgentUIMessageStreamResponse,
1436
+ readAgentSessionUIMessages,
1437
+ readAgentToolApprovalResponses,
1438
+ resolveAgentToolApprovalsWithUIMessageStream,
1439
+ sendAgentUserMessageWithUIMessageStream,
1440
+ } from "@superdurable/dex-agents/ui-stream";
1441
+ import type { UIMessage } from "ai";
1442
+
1443
+ // GET /api/chat/:id: the stored messages and the active turn's resume token.
1444
+ export async function getChat(request: Request, sessionId: string): Promise<Response> {
1445
+ await authorizeSession(request, sessionId);
1446
+ return Response.json(await readAgentSessionUIMessages({ client, flow, flowId: sessionId }));
1447
+ }
1448
+
1449
+ // POST /api/chat: useChat sends a user message, or the answers to tool approvals.
1450
+ export async function postChat(request: Request): Promise<Response> {
1451
+ const { id, messages } = (await request.json()) as { id: string; messages: UIMessage[] };
1452
+ await authorizeSession(request, id);
1453
+ const lastMessage = messages.at(-1);
1454
+ if (lastMessage?.role === "assistant") {
1455
+ return resolveAgentToolApprovalsWithUIMessageStream({
1456
+ client, flow, flowId: id,
1457
+ approvals: readAgentToolApprovalResponses(lastMessage),
1458
+ signal: request.signal,
1459
+ });
1460
+ }
1461
+ const text = (lastMessage?.parts ?? [])
1462
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
1463
+ .join("");
1464
+ return sendAgentUserMessageWithUIMessageStream({ client, flow, flowId: id, text, signal: request.signal });
1465
+ }
1466
+
1467
+ // GET /api/chat/:id/stream: useChat reconnects.
1468
+ export async function getChatStream(request: Request, sessionId: string): Promise<Response> {
1469
+ await authorizeSession(request, sessionId);
1470
+ return createAgentUIMessageStreamResponse({
1471
+ client, flow, flowId: sessionId,
1472
+ resumeToken: request.headers.get(agentResumeTokenHeaderName) ?? undefined,
1473
+ signal: request.signal,
1474
+ });
1475
+ }
1476
+ ```
1477
+
1478
+ In the browser, keep the latest resume token in memory and send it in a header
1479
+ when the stream reconnects. A token can reach 64 KiB, so keep it out of URLs:
1480
+
1481
+ ```tsx
1482
+ import { useChat } from "@ai-sdk/react";
1483
+ import {
1484
+ DefaultChatTransport,
1485
+ lastAssistantMessageIsCompleteWithApprovalResponses,
1486
+ type UIMessage,
1487
+ } from "ai";
1488
+ import { useRef } from "react";
1489
+
1490
+ export function AgentChat(props: {
1491
+ sessionId: string;
1492
+ initial: { messages: UIMessage[]; resumeToken: string | null };
1493
+ }) {
1494
+ const resumeToken = useRef(props.initial.resumeToken ?? undefined);
1495
+ const { messages, sendMessage, addToolApprovalResponse } = useChat({
1496
+ id: props.sessionId,
1497
+ messages: props.initial.messages,
1498
+ resume: props.initial.resumeToken !== null,
1499
+ onData: (part) => {
1500
+ if (part.type === "data-agent-resume-token") resumeToken.current = part.data as string;
1501
+ },
1502
+ onFinish: ({ finishReason }) => {
1503
+ // "other": the turn ended without its events reaching the Stream; reload the stored messages.
1504
+ },
1505
+ transport: new DefaultChatTransport({
1506
+ api: "/api/chat",
1507
+ prepareReconnectToStreamRequest: ({ id }) => ({
1508
+ api: `/api/chat/${id}/stream`,
1509
+ headers: resumeToken.current === undefined ? {} : { "x-agent-resume-token": resumeToken.current },
1510
+ }),
1511
+ }),
1512
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
1513
+ });
1514
+ // Render messages; answer a part in state "approval-requested" with
1515
+ // addToolApprovalResponse({ id: part.approval.id, approved: true }).
1516
+ }
1517
+ ```
1518
+
1519
+ Events become parts as follows. Each part belongs to the turn's assistant
1520
+ message, whose ID is `<turnId>-assistant`.
1521
+
1522
+ | `AgentEvent` | UI message stream parts |
1523
+ | --- | --- |
1524
+ | `turn_started` | `start` |
1525
+ | `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 |
1526
+ | `tool_call_deferred` | `tool-input-available` with `dynamic: true` and the parsed arguments |
1527
+ | `interrupt_raised` | `tool-approval-request`; its `approvalId` is the `interrupt_id` |
1528
+ | `interrupt_resolved` | `tool-approval-response` |
1529
+ | `tool_call_finished` | `tool-output-available` (`succeeded`), `tool-output-error` (`failed`, `outcome_unknown`), or `tool-output-denied` (`rejected`) |
1530
+ | `turn_completed` | `finish` with reason `stop`, once the turn's end committed |
1531
+ | `turn_failed` | `error`, then `finish` with reason `error`, once the turn's end committed |
1532
+ | `usage_recorded`, `tool_call_started`, text written by a tool Step, adapter-specific events | transient `data-agent-event` with the event's wire JSON |
1533
+ | every part | a transient `data-agent-resume-token` after it |
1534
+
1535
+ - **Resume.** A reconnect with a token replays the token's turn from its
1536
+ first retained event. Its `start` part makes the default
1537
+ `DefaultChatTransport` reset the message and rebuild it, so the result is
1538
+ right whether the connection dropped, closed cleanly, or the page reloaded.
1539
+ A reconnect without a token replays the active turn. To continue exactly
1540
+ after the last part instead, without resending the turn, pass
1541
+ `continueAfterResumeToken: true`, but only while the browser still holds
1542
+ that message: with `useChat`, while `status` is `"error"` after a dropped
1543
+ connection. Each part has its own token, so a cut between two parts of one
1544
+ event repeats neither.
1545
+ - **Rejected positions.** Before it answers 200, the stream reads once to
1546
+ check the token's Dex position. A position Dex rejects, such as a forged one,
1547
+ restarts the turn from its first retained event.
1548
+ - **Turn ends.** `turn_completed` and `turn_failed` reach the Stream before
1549
+ their commit, so the stream waits up to 3 seconds for the commit before it
1550
+ finishes, and keeps reading when a retry follows. When the Stream lost a
1551
+ turn's events, the session's committed state still ends the stream, with
1552
+ reason `other`; that includes a Server whose Stream Store is disabled.
1553
+ - **Approvals.** When the turn waits only for approvals, the stream finishes
1554
+ with reason `tool-calls`, so `sendAutomaticallyWhen` can send the answers.
1555
+ It decides that from the committed records and `PendingInterrupts`, so a
1556
+ replay that reaches an approval request keeps reading when the call was
1557
+ already answered, including after `resolve_interrupt` and before the
1558
+ approval Step commits.
1559
+ `resolveAgentToolApprovalsWithUIMessageStream` reads at most 100 decisions,
1560
+ keeps the first per approval ID, calls `resolve_interrupt` only for
1561
+ interrupts pending in the active turn, and streams the rest of the turn into
1562
+ the same message. `readAgentToolApprovalResponses` binds each decision to
1563
+ the turn of the message it came from, and a decision bound to another turn,
1564
+ such as from a stale browser tab, is not applied. A decision that is no
1565
+ longer pending gets the call's outcome, or an error part, so the browser
1566
+ does not send it again. To keep
1567
+ one stream open instead and resolve approvals from your own endpoint with
1568
+ `resolveAgentToolApproval`, pass `finishWhenAwaitingApproval: false`.
1569
+ - **Retries.** A `reset-step` drops the partial text of an interrupted
1570
+ `RunModel` attempt, such as after a Worker crash.
1571
+ - **Tool details.** Tool arguments and results come from the committed
1572
+ `AgentToolCalls` records, because events carry only IDs and statuses. The
1573
+ stream waits up to 3 seconds for the commit that follows an event, and reads
1574
+ the stored messages once the turn has deleted its records.
1575
+ - **Lower-level APIs.** `streamAgentUIMessageChunks` yields the parts for
1576
+ `createUIMessageStreamResponse` or your own response.
1577
+ `convertAgentSnapshotToUIMessages` maps a snapshot you already have.
1578
+
1579
+ ### Plain Server-Sent Events
1580
+
1581
+ For a UI that applies the event rules itself, serve the snapshot and the
1582
+ events:
1583
+
1584
+ ```ts
1585
+ import {
1586
+ convertAgentEventStreamSnapshotToJson,
1587
+ createAgentEventSseResponse,
1588
+ readAgentEventStreamSnapshot,
1589
+ } from "@superdurable/dex-agents/ui-stream";
1590
+
1591
+ // GET /sessions/:id/snapshot
1592
+ export async function getSnapshot(request: Request, sessionId: string): Promise<Response> {
1593
+ await authorizeSession(request, sessionId);
1594
+ const value = await readAgentEventStreamSnapshot({ client, flow, flowId: sessionId });
1595
+ return Response.json(convertAgentEventStreamSnapshotToJson(value));
1596
+ }
1597
+
1598
+ // GET /sessions/:id/events
1599
+ export async function getEvents(request: Request, sessionId: string): Promise<Response> {
1600
+ await authorizeSession(request, sessionId);
1601
+ const resumeToken =
1602
+ request.headers.get("last-event-id") ?? new URL(request.url).searchParams.get("resumeToken") ?? "";
1603
+ return createAgentEventSseResponse({ client, flow, flowId: sessionId, resumeToken, signal: request.signal });
1604
+ }
1605
+ ```
1606
+
1607
+ The browser loads the snapshot, then opens the event stream from its token:
1608
+
1609
+ ```ts
1610
+ const { resume_token } = await (await fetch(`/sessions/${sessionId}/snapshot`)).json();
1611
+ const events = new EventSource(`/sessions/${sessionId}/events?resumeToken=${encodeURIComponent(resume_token)}`);
1612
+ events.onmessage = (message) => applyAgentEvent(JSON.parse(message.data));
1613
+ ```
1614
+
1615
+ Each event's data is the `AgentEvent` wire JSON with `payload` as JSON text.
1616
+ On a reconnect the browser sends `Last-Event-ID`, which takes precedence over
1617
+ the query parameter, and the stream continues with the next event. A token
1618
+ that Dex rejects, such as one of another session, gets a 400, which stops the
1619
+ `EventSource`; load the snapshot again. While a turn is active, the
1620
+ snapshot's token replays that turn's events. The snapshot and event JSON is
1621
+ the same as the Python core's `dex_agents.ui_stream`.
1622
+
1623
+ The snapshot JSON is the `get_agent_snapshot` wire format without the fields
1624
+ only the session's Steps use. Messages leave out `framework_item`, the
1625
+ framework's replay data, which can hold the agent's instructions. Tool calls
1626
+ leave out `tool_source_reference`, `trace_context`, `input_continuation`,
1627
+ `input_answers`, and `task_answer_pending`. Read them with `getAgentSnapshot` on the server when needed. It
1628
+ includes `session_settings` as stored, so keep anything a browser must not see out of session settings, or
1629
+ remove the key.
1630
+
1631
+ ### UI stream limits
1632
+
1633
+ - **Stop closes the response.** Stopping in the browser only closes the HTTP
1634
+ response; the turn keeps running in Dex. To stop the turn, the application's
1635
+ stop route calls `cancel_turn`. A steered or cancelled turn ends its stream
1636
+ with `finish` reason `other`.
1637
+ - **Streams are best effort.** Stream events live in the memory of the active
1638
+ Dex Server process, so a restart or failover loses them. A server with
1639
+ `streamStore.backend: disabled` drops Step Stream writes. Trimming can also
1640
+ remove old events. Without the events, the UI message stream ends with
1641
+ reason `other` once the turn ends, and `readAgentSessionUIMessages` shows the
1642
+ active turn's tool calls and approvals with a `null` token; poll it until the
1643
+ turn ends.
1644
+ - **Stored results.** A stored tool result is the text the model saw, so after
1645
+ a reload a failed or rejected call shows that text in `output-available`.
1646
+ - **Queued messages.** `sendAgentUserMessageWithUIMessageStream` follows the
1647
+ next turn that starts. When other messages were queued first, that turn
1648
+ answers the oldest of them.
1649
+ - **Exact continuation.** With `continueAfterResumeToken`, a cut that falls
1650
+ between a part and its token, which arrive in one write, still repeats that
1651
+ part.
1652
+
1653
+ ## Tracing
1654
+
1655
+ The session emits OpenTelemetry GenAI spans through `@opentelemetry/api`, an
1656
+ optional peer dependency. The application brings the SDK and an exporter:
1657
+
1658
+ ```bash
1659
+ npm i @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/exporter-trace-otlp-proto
1660
+ ```
1661
+
1662
+ Tracing is on by default and uses the global tracer provider, so an
1663
+ application that registers the SDK gets spans without further setup. Without
1664
+ `@opentelemetry/api`, the session never loads it. With the API but no SDK, the
1665
+ spans are no-ops and the session stores nothing extra. `tracing` in
1666
+ `AgentSessionOptions` changes the defaults:
1667
+
1668
+ ```ts
1669
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
1670
+ import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
1671
+ import { createAgentSessionFlow } from "@superdurable/dex-agents";
1672
+
1673
+ const tracerProvider = new NodeTracerProvider({
1674
+ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
1675
+ });
1676
+ const flow = createAgentSessionFlow(new MyFrameworkRunner(agent), "TriageAgent", {
1677
+ tracing: { tracerProvider, agentName: "Triage" },
1678
+ });
1679
+ ```
1680
+
1681
+ | Option | Default | Meaning |
1682
+ | --- | --- | --- |
1683
+ | `isEnabled` | `true` | `false` emits nothing and never loads `@opentelemetry/api`. |
1684
+ | `tracerProvider` | the global provider | The provider to emit with. Any `TracerProvider` fits `OpenTelemetryTracerProvider`. |
1685
+ | `agentName` | the Flow type | `gen_ai.agent.name` and the `invoke_agent` span name. |
1686
+ | `shouldCaptureMessageContent` | `false` | Put message text, tool arguments, and tool results on spans. |
1687
+ | `semanticConventionsVersion` | `"1.42.0-dev"` | The GenAI semantic conventions the spans follow. |
1688
+
1689
+ The spans, attributes, and content are identical to the Python core's; see its
1690
+ [Tracing section](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md#tracing)
1691
+ for the full attribute table. In short:
1692
+
1693
+ - **One trace per activation.** An activation is a burst of Steps between
1694
+ durable waits. It starts when `StartAgentSession`, `AwaitUserMessage`, or
1695
+ `ApplyTurnControl` consumes a user message, or `AwaitToolApproval` an
1696
+ interrupt answer or an expired deadline. That Step's attempt emits the root, `invoke_agent {agent
1697
+ name}`, with a span link to the previous activation's root. Each `RunModel`
1698
+ and `CompactContext` attempt emits a `chat` span when it ends, and each
1699
+ `CallTool`, `CallSingleAttemptTool`, and `PollToolTask` attempt an
1700
+ `execute_tool {tool name}` span. A turn
1701
+ without approvals is one trace; each approval answer and each new turn
1702
+ starts another.
1703
+ - **A near-zero root.** Every span ends inside its Step attempt, so none stays
1704
+ open across a durable wait. The `invoke_agent` root ends with its own Step,
1705
+ so its duration is near zero, and the activation's later spans start after
1706
+ it ended. That is on purpose: a span must end in the process that started
1707
+ it, and the OpenTelemetry API cannot give a span an ID before it starts, so a
1708
+ root covering the whole activation would stay open across Steps, Workers,
1709
+ and possibly days, and backends drop or hide such traces.
1710
+ - **Current span.** While a tool call runs, its `execute_tool` span is active.
1711
+ While a model call runs, the activation's root is active, because the `chat`
1712
+ span starts when the attempt ends. Framework and HTTP client instrumentation
1713
+ nests under the activation when a context manager is registered.
1714
+ - **Across processes.** The root stores its `traceparent`, about 55 bytes, in
1715
+ the `AgentTraceContext` Attribute in the same commit as its Step. `RunModel`
1716
+ reads it for its `chat` span and copies it onto each tool call record it
1717
+ creates, as `AgentToolCall.traceContext`; an approval replaces the copy with
1718
+ its own root. Tool Steps parent their spans from the record, so overlapping
1719
+ approvals never swap tool spans. The Attribute is written whenever the root
1720
+ has a valid span context, sampled or not, so under a parent-based sampler a
1721
+ sampled-out root's other spans are dropped with it. A root without a valid
1722
+ context (tracing disabled, or no API or SDK) deletes the stored value, so
1723
+ the activation's later spans start traces of their own instead of joining
1724
+ the previous activation's trace.
1725
+ - **Retries.** Every attempt is its own span with `dex.step.attempt`, and the
1726
+ attempts of one Step execution share `dex.step.execution_id`. A failed
1727
+ attempt has status `ERROR` and `error.type` set to the error's `name`; a
1728
+ `ToolExecutionError` sets `ToolExecutionError`. The recovery Steps and
1729
+ `ResumeTurn` emit no span.
1730
+ - **Every span** carries the Flow ID as `gen_ai.conversation.id`, `session.id`,
1731
+ and `dex.flow.id`, plus `dex.run.id`, `dex.step.type`,
1732
+ `dex.step.execution_id`, `dex.step.attempt`, and `dex.agent.turn.id`.
1733
+ - **Model and usage.** The `chat` span is named `chat {requestModel}` when the
1734
+ runner returns `modelCallMetadata.requestModel`, and `chat` otherwise. Each
1735
+ framework adapter in this repository returns `modelCallMetadata` with what
1736
+ its framework reports for the call and leaves the rest unset; its README
1737
+ lists the fields. Usage attributes appear when the runner reports
1738
+ `modelCalls: 1` and a non-zero input or output count. A runner that reports any other
1739
+ `modelCalls`, such as a whole-turn harness, gets an `INTERNAL` `chat` span
1740
+ without usage, so provider instrumentation that traces each call does not
1741
+ count the tokens twice. A single model call can still be counted twice when
1742
+ provider client instrumentation also traces it. Sum usage from one
1743
+ instrumentation scope in the backend, or remove the session's usage
1744
+ attributes, for example with an OpenTelemetry Collector transform processor:
1745
+
1746
+ ```yaml
1747
+ processors:
1748
+ transform/drop_dex_agents_usage:
1749
+ trace_statements:
1750
+ - context: span
1751
+ statements:
1752
+ - delete_matching_keys(attributes, "^gen_ai\\.usage\\.") where instrumentation_scope.name == "@superdurable/dex-agents"
1753
+ ```
1754
+ - **Content by reference.** By default spans carry message sequence keys
1755
+ (`dex.agent.input_messages.last_key`, and `dex.agent.output_messages.keys` for
1756
+ what this call produced) and tool call IDs (`gen_ai.tool.call.id`,
1757
+ `dex.agent.requested_tool_call.ids`), because the content is already durable
1758
+ in Dex. With `shouldCaptureMessageContent: true`, they also carry
1759
+ `gen_ai.input.messages`, `gen_ai.output.messages`,
1760
+ `gen_ai.tool.call.arguments`, and `gen_ai.tool.call.result`, built from the
1761
+ message projections. No `_ref` attributes are set: they take an absolute URI,
1762
+ and Dex has no URI for a stored message.
1763
+ - **Failures.** Tracing never fails or retries a Step. Every OpenTelemetry call
1764
+ (spans, context, content serialization) is guarded; an error, such as one
1765
+ thrown by a span processor, is logged with `console.warn` the first time and
1766
+ `console.debug` after that, and that span is skipped.
1767
+ - **Version switch.** `"1.42.0-dev"` follows
1768
+ [`open-telemetry/semantic-conventions-genai`](https://github.com/open-telemetry/semantic-conventions-genai)
1769
+ at commit [`dc3571562603552dc555c667dfa327a638d94ab2`](https://github.com/open-telemetry/semantic-conventions-genai/tree/dc3571562603552dc555c667dfa327a638d94ab2)
1770
+ (2026-11-01), whose schema URL names `1.42.0-dev`; the repository has no
1771
+ release tag yet. `"1.36.0"` follows OpenTelemetry semantic conventions
1772
+ 1.36.0 for backends that still read the older names: `gen_ai.system` instead
1773
+ of `gen_ai.provider.name`, no cache or reasoning token counts, and no
1774
+ content. `genAISemanticConventionsSchemaUrls` lists the schema URL set on the
1775
+ tracer.
1776
+
326
1777
  ## Testing export
327
1778
 
328
1779
  `@superdurable/dex-agents/testing` runs adapter integration tests against a
@@ -330,13 +1781,14 @@ real Dex Server with no model key:
330
1781
 
331
1782
  | Export | Purpose |
332
1783
  | --- | --- |
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` |
1784
+ | `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
1785
  | `KillableWorkerProcess.start({ entryModulePath, serverAddress, logPath, environment })` | Worker child in its own process group; `kill()` sends SIGKILL to the group, `stop()` drains |
335
1786
  | `runAgentWorkerProcess(flows)` | Body of the Worker child module; 8 concurrent tasks; prints `dex-agents test Worker ready` once registered |
336
1787
  | `AgentTestEnvironment.start({ serverAddress, flows, isWorkerInProcess })` | Registry, BlobCache, Client, and optional in-process Worker with 8 concurrent tasks |
337
1788
  | `AgentSessionTestClient` | Typed RPC calls (`resolveInterrupt` retries lock conflicts) and Worker-free waits on committed Attributes |
338
1789
  | `readStepHistory(serverAddress, flowId)` | Completed Step methods from `dexcli flow history`, to assert a turn's Step shape |
339
1790
  | `FileGate`, `InvocationLog` | Pause Worker code at an exact point; count real tool runs across processes |
1791
+ | `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
1792
  | `pollUntil(probe, options)` | Deadline polling where the Client has no direct wait |
341
1793
  | `findFreeLoopbackPorts(count)` | Distinct free loopback ports, chosen with every listener held open until all are picked |
342
1794
 
@@ -382,8 +1834,17 @@ DEXCLI=/path/to/dexcli ./ci.sh
382
1834
  ```
383
1835
 
384
1836
  `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`,
1837
+ which builds with `tsc` and runs `node --test` over `dist/test/integ`. It skips
1838
+ `test/long`, the 2,000-turn compaction session behind the agent deployment
1839
+ guide's run budget numbers, which runs for minutes; `test/integ` has an
1840
+ 800-turn retention guard instead. The `Agents Long Tests Nightly` workflow runs `test/long` every
1841
+ night. Run it by hand with:
1842
+
1843
+ ```bash
1844
+ DEXCLI=/path/to/dexcli npm run test:long
1845
+ ```
1846
+
1847
+ Strict `tsc` (`noUnusedLocals`, `noUncheckedIndexedAccess`,
387
1848
  `exactOptionalPropertyTypes`, and related flags) is the lint gate. The tests
388
1849
  use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
389
1850
 
@@ -401,7 +1862,50 @@ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
401
1862
  also under an ASYNC step durability FlowConfig;
402
1863
  - SIGKILL during `CallTool`, retried with the same idempotency key;
403
1864
  - an approval that survives a Worker restart, `send_user_message` during a
404
- turn, and snapshots and message paging.
1865
+ turn, and snapshots and message paging;
1866
+ - the UI stream adapters through the AI SDK `Chat` client and
1867
+ `DefaultChatTransport` over HTTP: a streamed turn that matches the stored
1868
+ messages; a dropped connection continued exactly, a clean close replayed into
1869
+ the same message, and a cut between two parts of one event, each with no
1870
+ repeated or missing part; an approval and a rejection round trip; a stale
1871
+ approval that does not resend in a loop; untrusted, duplicate, and excess
1872
+ decisions, decisions bound to an older turn, and an approval sent with the
1873
+ paused stream's token; a reconnect after an approval while the approved call
1874
+ runs; a `turn_completed` or `turn_failed` that never commits; a page reload,
1875
+ also with a forged Dex position; plain Server-Sent Events resumed with
1876
+ `Last-Event-ID` and a rejected token; a turn finished from committed state
1877
+ with the Stream Store disabled; the resume token size limit; and
1878
+ `reset-step` after SIGKILL during `RunModel`;
1879
+ - a tool that asks for input with `ToolInputRequested`, runs again with the
1880
+ answer, and records `outcome_unknown` once when its second run is killed;
1881
+ - two tools of one response that ask at once, both answerable, and a sibling
1882
+ approval resolved while a tool runs that never reappears as pending.
1883
+
1884
+ The Stepwise tests (`test/integ/stepwise-*.integration.test.ts`) run the
1885
+ built-in loop on the AI SDK's `MockLanguageModelV4`, scripted from its prompt
1886
+ (`test/fixtures/stepwise-scripted-language-model.ts`), and cover:
1887
+
1888
+ - a read tool turn whose transcript is stored while the tool runs, history
1889
+ replay, one model call per `RunModel`, and the Step shape;
1890
+ - an approved write in `CallSingleAttemptTool`; a schema mismatch; the model
1891
+ call limit; reserved built-in names;
1892
+ - `ask_user` answered by `answer_question`, with refused, duplicate, declined,
1893
+ and expired answers;
1894
+ - `durable_wait` on a Timer in `AwaitTimer`, and an invalid duration that fails
1895
+ without a Step;
1896
+ - reasoning and tool call parts replayed with their provider metadata
1897
+ unchanged, and replay to another provider without reasoning;
1898
+ - a failed turn that stores its tool results before the turn-failure note;
1899
+ - SIGKILL during `RunModel`, during a single-attempt write, and while waiting
1900
+ on a question or a Timer;
1901
+ - a session policy that wins over a tool's own approval, `null` `ask_user`
1902
+ arguments, a one-day wait ended with **SkipTimer**, a model that asks for
1903
+ tools past the call limit, a built-in name clash before any model call, the
1904
+ argument messages shared with Python, and `ScriptedModelClient`.
1905
+
1906
+ `test/integ/wire-compatibility.integration.test.ts` decodes 0.1.0 wire values in
1907
+ 0.2.0 and checks that 0.2.0 values keep every 0.1.0 field, and
1908
+ `test/compat/agents-0.1.0-source-compatibility.ts` keeps 0.1.0 code compiling.
405
1909
 
406
1910
  ## Design decisions
407
1911
 
@@ -433,11 +1937,15 @@ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
433
1937
  - **`deferral_order`.** `AgentToolCall` carries a zero-based `deferral_order`,
434
1938
  as in the Python core; records and the snapshot list calls in the order the
435
1939
  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.
1940
+ - **Output before a single-attempt tool runs.** A tool Step writes
1941
+ `tool_call_started` and awaits a heartbeat before calling the runner. A
1942
+ crash during the tool fails the attempt instead of re-running the tool. Dex
1943
+ treats a stream that half-closes before any output as one whose task never
1944
+ ran: the attempt returns to pending with the same attempt number, uses no
1945
+ retry, and is handed out again. After output, a half-close is a disconnect
1946
+ that fails the attempt with `NOT_COMPLETED`. The heartbeat therefore keeps a
1947
+ started tool from being returned as not run at the same attempt number and
1948
+ later reported as `NOT_STARTED`.
441
1949
  - **Background heartbeats.** `RunModel` and the tool Steps heartbeat every 20
442
1950
  seconds while the runner works, so a silent model or tool call stays within
443
1951
  the heartbeat timeout. Method timeouts still bound hung calls.
@@ -446,31 +1954,67 @@ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
446
1954
  - **TypeScript extras.** `AgentTurnContext` and `DeferredToolExecution` add
447
1955
  `cancellationSignal` and `deadline`. `ToolPolicy.requiresApproval` is optional
448
1956
  and defaults by effect, as in Python.
1957
+ - **Two record types.** The wire status gains non-final values, but
1958
+ `AgentToolCallStatus` keeps its six, so exhaustive mappings in adapters still
1959
+ compile. Stored records use `AgentToolCallRecord`, whose status also allows
1960
+ `awaiting_answer`, `waiting_for_timer`, and `waiting_for_task`.
1961
+ - **One `streamText` step.** The AI SDK client uses only the public `ai` API.
1962
+ `streamText` with `stopWhen: stepCountIs(1)`, `maxRetries: 0`, and tools
1963
+ without `execute` makes exactly one model call. It accepts every
1964
+ `LanguageModel` form, and the AI SDK's own prompt conversion and middleware
1965
+ apply as in any `streamText` call.
1966
+ - **0.1.0 source compatibility.** Every field added to a 0.1.0 type is
1967
+ optional, and `test/compat/agents-0.1.0-source-compatibility.ts` is 0.1.0
1968
+ code that the typecheck compiles. One break remains: `AgentSnapshot.toolCalls`
1969
+ and records read with `client.getAttributes` are `AgentToolCallRecord`, whose
1970
+ status can be a waiting status, so code that assigns them to `AgentToolCall`
1971
+ must widen the type.
449
1972
 
450
1973
  ## Limits
451
1974
 
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.
1975
+ - `get_agent_snapshot` and `list_agent_messages` load every retained chunk,
1976
+ and so does `RunModel` without compaction. Retention bounds that load only
1977
+ with compaction.
455
1978
  - 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.
1979
+ - A crash in `RunModel` reruns that model call as its next attempt.
1980
+ - The tool Steps use SYNC durability, and Dex never hands one attempt number to
1981
+ a second Worker, so a Worker crash during a single-attempt tool records
1982
+ `outcome_unknown` instead of running the tool again. Cancellation is
1983
+ cooperative, though: a cancelled run may keep running while the failure
1984
+ route starts.
1985
+ - `StepAttemptProgress.NOT_COMPLETED` is conservative, so a call whose tool never
1986
+ ran can still record `outcome_unknown`, such as when Dex Server crashed after
1987
+ it recorded the attempt start but before the task reached the Worker.
467
1988
  - Tools a framework cannot defer run inside `RunModel` and repeat with it; each
468
1989
  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.
1990
+ - Queued user messages are consumed one per turn, and a queued message does
1991
+ not end a `durable_wait`; a steered one does. Steering never stops a model
1992
+ call or a tool call that has started, or a tool task it started.
1993
+ `cancel_turn` stops a model call and abandons a tool task, but never stops a
1994
+ tool call Step that has started.
1995
+ - Each `PollToolTask` poll is a Step in the session's history.
471
1996
  - A failed turn keeps its user message in history without an assistant reply;
472
1997
  only the turn failure note records what its tools did.
473
1998
  - Deadlines use the Worker clock when the Timer is computed.
474
1999
  - Sessions never complete on their own; stop them with `Client.stopFlow`.
475
2000
  - `AgentEvents` is best effort and its byte budget is shared by every session
476
2001
  of the Flow type; rebuild from `get_agent_snapshot`.
2002
+ - `@superdurable/dex-agents/mcp` uses `@modelcontextprotocol/sdk` 1.x, which
2003
+ speaks MCP up to 2025-11-25: no stateless 2026-07-28 requests, no
2004
+ `io.modelcontextprotocol/tasks` extension, no `input_required` results, and no
2005
+ `traceparent` in `_meta`. It calls only `taskSupport: required` tools as
2006
+ tasks. The Python core speaks 2026-07-28.
2007
+ - A stdio server's MCP task, and a task of a server that keeps protocol
2008
+ sessions, is polled inside its tool Step, holding a Worker slot. A task in
2009
+ `input_required` records `failed`, because 2025-11-25 input requests need a
2010
+ live connection.
2011
+ - Every MCP request opens a new connection and runs the `initialize`
2012
+ handshake. An unreachable server is tried again, with its connection
2013
+ attempts, on every `listTools` call until it answers. A server that binds
2014
+ tasks to its protocol session, such as a stateful server on the SDK's task
2015
+ store, cannot resume a task across attempts; the retry calls the tool again.
2016
+ - Tracing covers the session's Steps only. The Dex SDK emits no spans, so Dex
2017
+ Server and Worker internals do not appear in the traces.
2018
+ - The adapters report only the model information their framework exposes, so
2019
+ some `chat` spans carry no provider, response ID, or finish reasons. Each
2020
+ adapter's README lists what it sets.