@superdurable/dex-agents 0.4.0 → 0.6.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 (144) hide show
  1. package/README.md +616 -55
  2. package/dist/src/agent-approval-display.d.ts +135 -0
  3. package/dist/src/agent-approval-display.js +536 -0
  4. package/dist/src/agent-approval-display.js.map +1 -0
  5. package/dist/src/agent-context-compaction.d.ts +1 -1
  6. package/dist/src/agent-framework-runner.d.ts +160 -12
  7. package/dist/src/agent-framework-runner.js +4 -0
  8. package/dist/src/agent-framework-runner.js.map +1 -1
  9. package/dist/src/agent-local-directory-media-store.d.ts +111 -0
  10. package/dist/src/agent-local-directory-media-store.js +417 -0
  11. package/dist/src/agent-local-directory-media-store.js.map +1 -0
  12. package/dist/src/agent-media.d.ts +279 -0
  13. package/dist/src/agent-media.js +457 -0
  14. package/dist/src/agent-media.js.map +1 -0
  15. package/dist/src/agent-session-codecs.d.ts +5 -1
  16. package/dist/src/agent-session-codecs.js +76 -4
  17. package/dist/src/agent-session-codecs.js.map +1 -1
  18. package/dist/src/agent-session-failure.d.ts +4 -2
  19. package/dist/src/agent-session-failure.js +6 -2
  20. package/dist/src/agent-session-failure.js.map +1 -1
  21. package/dist/src/agent-session-flow-graph.d.ts +46 -0
  22. package/dist/src/agent-session-flow-graph.js +51 -0
  23. package/dist/src/agent-session-flow-graph.js.map +1 -1
  24. package/dist/src/agent-session-flow.d.ts +28 -3
  25. package/dist/src/agent-session-flow.js +21 -1
  26. package/dist/src/agent-session-flow.js.map +1 -1
  27. package/dist/src/agent-session-persistence.d.ts +33 -0
  28. package/dist/src/agent-session-persistence.js +22 -1
  29. package/dist/src/agent-session-persistence.js.map +1 -1
  30. package/dist/src/agent-session-start.d.ts +1 -1
  31. package/dist/src/agent-session-start.js +2 -2
  32. package/dist/src/agent-session-start.js.map +1 -1
  33. package/dist/src/agent-session-steps.d.ts +2 -0
  34. package/dist/src/agent-session-steps.js +74 -5
  35. package/dist/src/agent-session-steps.js.map +1 -1
  36. package/dist/src/agent-session-summary.d.ts +138 -0
  37. package/dist/src/agent-session-summary.js +151 -0
  38. package/dist/src/agent-session-summary.js.map +1 -0
  39. package/dist/src/agent-session-tracer.js +1 -1
  40. package/dist/src/agent-session-tracer.js.map +1 -1
  41. package/dist/src/agent-session-transitions.d.ts +11 -3
  42. package/dist/src/agent-session-transitions.js +58 -6
  43. package/dist/src/agent-session-transitions.js.map +1 -1
  44. package/dist/src/agent-session-values.d.ts +179 -2
  45. package/dist/src/agent-session-values.js.map +1 -1
  46. package/dist/src/agent-session-wait-steps.js +14 -2
  47. package/dist/src/agent-session-wait-steps.js.map +1 -1
  48. package/dist/src/ai-sdk/ai-sdk-model-client.d.ts +15 -3
  49. package/dist/src/ai-sdk/ai-sdk-model-client.js +36 -7
  50. package/dist/src/ai-sdk/ai-sdk-model-client.js.map +1 -1
  51. package/dist/src/ai-sdk/index.d.ts +1 -1
  52. package/dist/src/ai-sdk/index.js +1 -1
  53. package/dist/src/ai-sdk/index.js.map +1 -1
  54. package/dist/src/{stepwise → builtin-loop}/agent-definition.d.ts +120 -5
  55. package/dist/src/builtin-loop/agent-definition.js.map +1 -0
  56. package/dist/src/builtin-loop/builtin-agent-session-flow.d.ts +77 -0
  57. package/dist/src/{stepwise/stepwise-agent-session-flow.js → builtin-loop/builtin-agent-session-flow.js} +19 -9
  58. package/dist/src/builtin-loop/builtin-agent-session-flow.js.map +1 -0
  59. package/dist/src/{stepwise/stepwise-agent-runner.d.ts → builtin-loop/builtin-loop-agent-runner.d.ts} +72 -14
  60. package/dist/src/{stepwise/stepwise-agent-runner.js → builtin-loop/builtin-loop-agent-runner.js} +284 -41
  61. package/dist/src/builtin-loop/builtin-loop-agent-runner.js.map +1 -0
  62. package/dist/src/{stepwise/stepwise-input-items.d.ts → builtin-loop/builtin-loop-input-items.d.ts} +7 -3
  63. package/dist/src/{stepwise/stepwise-input-items.js → builtin-loop/builtin-loop-input-items.js} +57 -10
  64. package/dist/src/builtin-loop/builtin-loop-input-items.js.map +1 -0
  65. package/dist/src/builtin-loop/function-tool.d.ts +282 -0
  66. package/dist/src/{stepwise → builtin-loop}/function-tool.js +63 -3
  67. package/dist/src/builtin-loop/function-tool.js.map +1 -0
  68. package/dist/src/builtin-loop/index.d.ts +8 -0
  69. package/dist/src/builtin-loop/index.js +13 -0
  70. package/dist/src/builtin-loop/index.js.map +1 -0
  71. package/dist/src/builtin-loop/json-schema-validation.js.map +1 -0
  72. package/dist/src/{stepwise → builtin-loop}/model-client.d.ts +36 -7
  73. package/dist/src/builtin-loop/model-client.js.map +1 -0
  74. package/dist/src/{stepwise → builtin-loop}/scripted-model-client.d.ts +8 -5
  75. package/dist/src/{stepwise → builtin-loop}/scripted-model-client.js +8 -5
  76. package/dist/src/builtin-loop/scripted-model-client.js.map +1 -0
  77. package/dist/src/{stepwise → builtin-loop}/tool-source.d.ts +2 -2
  78. package/dist/src/builtin-loop/tool-source.js.map +1 -0
  79. package/dist/src/connectors/connector-input-schema.js +1 -1
  80. package/dist/src/connectors/connector-input-schema.js.map +1 -1
  81. package/dist/src/connectors/connector-step-context.d.ts +19 -2
  82. package/dist/src/connectors/connector-step-context.js +61 -5
  83. package/dist/src/connectors/connector-step-context.js.map +1 -1
  84. package/dist/src/connectors/connector-tool-source.d.ts +63 -14
  85. package/dist/src/connectors/connector-tool-source.js +124 -25
  86. package/dist/src/connectors/connector-tool-source.js.map +1 -1
  87. package/dist/src/index.d.ts +8 -4
  88. package/dist/src/index.js +5 -1
  89. package/dist/src/index.js.map +1 -1
  90. package/dist/src/mcp/index.d.ts +1 -0
  91. package/dist/src/mcp/index.js +1 -0
  92. package/dist/src/mcp/index.js.map +1 -1
  93. package/dist/src/mcp/mcp-dex-conventions.d.ts +57 -0
  94. package/dist/src/mcp/mcp-dex-conventions.js +90 -0
  95. package/dist/src/mcp/mcp-dex-conventions.js.map +1 -0
  96. package/dist/src/mcp/mcp-image-content.d.ts +20 -0
  97. package/dist/src/mcp/mcp-image-content.js +82 -0
  98. package/dist/src/mcp/mcp-image-content.js.map +1 -0
  99. package/dist/src/mcp/mcp-tool-source.d.ts +205 -18
  100. package/dist/src/mcp/mcp-tool-source.js +289 -47
  101. package/dist/src/mcp/mcp-tool-source.js.map +1 -1
  102. package/dist/src/s3/index.d.ts +1 -0
  103. package/dist/src/s3/index.js +9 -0
  104. package/dist/src/s3/index.js.map +1 -0
  105. package/dist/src/s3/s3-media-store.d.ts +138 -0
  106. package/dist/src/s3/s3-media-store.js +255 -0
  107. package/dist/src/s3/s3-media-store.js.map +1 -0
  108. package/dist/src/stepwise/index.d.ts +41 -8
  109. package/dist/src/stepwise/index.js +35 -5
  110. package/dist/src/stepwise/index.js.map +1 -1
  111. package/dist/src/testing/agent-session-test-client.d.ts +6 -3
  112. package/dist/src/testing/agent-session-test-client.js +10 -3
  113. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  114. package/dist/src/testing/private-dex-engine.d.ts +8 -0
  115. package/dist/src/testing/private-dex-engine.js +17 -1
  116. package/dist/src/testing/private-dex-engine.js.map +1 -1
  117. package/dist/src/testing/scripted-model-client.d.ts +1 -1
  118. package/dist/src/testing/scripted-model-client.js +2 -2
  119. package/dist/src/testing/scripted-model-client.js.map +1 -1
  120. package/dist/src/tool-source-router.d.ts +3 -3
  121. package/dist/src/tool-source-router.js +2 -2
  122. package/dist/src/ui-stream/agent-event-json.d.ts +2 -1
  123. package/dist/src/ui-stream/agent-event-json.js +3 -1
  124. package/dist/src/ui-stream/agent-event-json.js.map +1 -1
  125. package/dist/src/ui-stream/agent-ui-messages.d.ts +11 -3
  126. package/dist/src/ui-stream/agent-ui-messages.js +29 -5
  127. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -1
  128. package/package.json +17 -4
  129. package/dist/src/stepwise/agent-definition.js.map +0 -1
  130. package/dist/src/stepwise/function-tool.d.ts +0 -130
  131. package/dist/src/stepwise/function-tool.js.map +0 -1
  132. package/dist/src/stepwise/json-schema-validation.js.map +0 -1
  133. package/dist/src/stepwise/model-client.js.map +0 -1
  134. package/dist/src/stepwise/scripted-model-client.js.map +0 -1
  135. package/dist/src/stepwise/stepwise-agent-runner.js.map +0 -1
  136. package/dist/src/stepwise/stepwise-agent-session-flow.d.ts +0 -35
  137. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +0 -1
  138. package/dist/src/stepwise/stepwise-input-items.js.map +0 -1
  139. package/dist/src/stepwise/tool-source.js.map +0 -1
  140. /package/dist/src/{stepwise → builtin-loop}/agent-definition.js +0 -0
  141. /package/dist/src/{stepwise → builtin-loop}/json-schema-validation.d.ts +0 -0
  142. /package/dist/src/{stepwise → builtin-loop}/json-schema-validation.js +0 -0
  143. /package/dist/src/{stepwise → builtin-loop}/model-client.js +0 -0
  144. /package/dist/src/{stepwise → builtin-loop}/tool-source.js +0 -0
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # @superdurable/dex-agents
2
2
 
3
3
  Durable agent sessions for Dex, shared by the TypeScript agent framework
4
- adapters. One Flow execution is one agent session, and the Flow ID is the
4
+ adapters. One Flow run is one agent session, and the Flow ID is the
5
5
  session ID. An adapter package supplies a framework runner; this package owns
6
6
  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
10
+ The package also ships the built-in agent loop for agents
11
+ defined in Dex terms without a framework (`@superdurable/dex-agents/builtin-loop`),
12
+ and its AI SDK model client (`@superdurable/dex-agents/ai-sdk`). The built-in loop runs
13
13
  on the same session Flow as every adapter, with the same Steps, RPCs, and
14
14
  events. The session also handles two built-in tools under any runner:
15
15
  `ask_user` asks the user a question and waits in `AwaitQuestionAnswer`, and
@@ -27,10 +27,10 @@ Applications usually install an adapter next to this package. To build a
27
27
  runner of your own, install the core with the Dex SDK it supports:
28
28
 
29
29
  ```bash
30
- npm i @superdurable/dex-agents @superdurable/dex@1.38.0
30
+ npm i @superdurable/dex-agents @superdurable/dex@1.45.0
31
31
  ```
32
32
 
33
- `@superdurable/dex` 1.38.0 is a peer dependency, so the application, the core,
33
+ `@superdurable/dex` 1.45.0 is a peer dependency, so the application, the core,
34
34
  and every adapter share one copy. The Dex Registry matches Flow classes and
35
35
  RPC registrations by identity, so a second copy makes it reject the Flow
36
36
  (`steps must be a StepList`). Adapters list this package as a peer dependency
@@ -49,12 +49,13 @@ the published core. It is for tests only; it needs `DEXCLI` and
49
49
  | Subpath | Contents | Extra dependency |
50
50
  | --- | --- | --- |
51
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 |
52
+ | `@superdurable/dex-agents/builtin-loop` | The built-in agent loop: `createBuiltinAgentSessionFlow`, `functionTool`, `ToolSource`, `ModelClient`, `ScriptedModelClient` | none |
53
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
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
55
  | `@superdurable/dex-agents/connectors` | Reviewed Connector operations as agent tools; see [Connector tools](#connector-tools-superdurabledex-agentsconnectors) | optional peer `@superdurable/connectors-sdk` |
56
56
  | `@superdurable/dex-agents/ui-stream` | Browser streams of live sessions; see [UI streams](#ui-streams) | none |
57
- | `@superdurable/dex-agents/testing` | Test engine, Worker processes, waits, and `ScriptedModelClient` (the same class as the stepwise entry point's) | none |
57
+ | `@superdurable/dex-agents/s3` | `S3MediaStore`, image bytes in an S3-compatible bucket; see [Images](#images) | optional peer `@aws-sdk/client-s3@^3.400.0` |
58
+ | `@superdurable/dex-agents/testing` | Test engine, Worker processes, waits, and `ScriptedModelClient` (the same class as the builtin-loop entry point's) | none |
58
59
 
59
60
  Install `ai` and a provider package only for `./ai-sdk`; importing that
60
61
  subpath without `ai` fails with `Cannot find package 'ai'`.
@@ -128,17 +129,17 @@ Enable built-in tools and call `answer_question` only after every Worker of the
128
129
  Flow type runs 0.2.0, because a 0.1.0 Worker cannot decode the new non-final
129
130
  statuses.
130
131
 
131
- ## Stepwise mode
132
+ ## Built-in agent loop
132
133
 
133
- Stepwise is the built-in agent loop. Declare the agent, its function tools,
134
- and its model client; `createStepwiseAgentSessionFlow` builds the session Flow
135
- with a `StepwiseAgentRunner` and derives the session options from the
134
+ The built-in agent loop runs an agent without a framework. Declare the agent, its function tools,
135
+ and its model client; `createBuiltinAgentSessionFlow` builds the session Flow
136
+ with a `BuiltinLoopAgentRunner` and derives the session options from the
136
137
  definition.
137
138
 
138
139
  ```ts
139
140
  import { openai } from "@ai-sdk/openai";
140
141
  import { createAiSdkModelClient } from "@superdurable/dex-agents/ai-sdk";
141
- import { createStepwiseAgentSessionFlow, functionTool } from "@superdurable/dex-agents/stepwise";
142
+ import { createBuiltinAgentSessionFlow, functionTool } from "@superdurable/dex-agents/builtin-loop";
142
143
 
143
144
  const lookupOrder = functionTool<{ order_id: string }>({
144
145
  name: "lookup_order",
@@ -155,7 +156,7 @@ const refundOrder = functionTool<{ order_id: string }>({
155
156
  execute: async ({ order_id }, context) => payments.refund(order_id, { idempotencyKey: context.idempotencyKey }),
156
157
  });
157
158
 
158
- export const supportAgentFlow = createStepwiseAgentSessionFlow(
159
+ export const supportAgentFlow = createBuiltinAgentSessionFlow(
159
160
  {
160
161
  name: "support",
161
162
  instructions: "You help customers with their orders.",
@@ -181,6 +182,7 @@ export const supportAgentFlow = createStepwiseAgentSessionFlow(
181
182
  | `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.` |
182
183
  | `planModeEnabled` | `false` | Accept `planMode` user messages; requires `write_todos` in `builtInTools`. See [Plans and plan mode](#plans-and-plan-mode) |
183
184
  | `interruptDeadlineMs`, `eventStreamCapacityBytes` | as for the session | Passed to the session options |
185
+ | `decideTurnContinuation` | none | A function that ends or continues the turn at the end of each loop iteration; see [Ending or continuing a turn](#ending-or-continuing-a-turn). Since 0.5.0 |
184
186
 
185
187
  Each `RunModel` makes at most one model call, built from committed state only:
186
188
  the instructions, the stored messages, the plan note from `buildPlanNote`
@@ -188,7 +190,8 @@ before the turn's messages when a plan exists or the turn plans, and the
188
190
  results of the previous model response from the turn's final records. The runner stores the turn's
189
191
  transcript as it goes: `ToolCallsRequested.newMessages` carries the previous
190
192
  round's tool results and the new model output, so `FrameworkRunState` holds
191
- only `{"model_calls": <n>}`. The input builder pairs every stored tool call
193
+ only `{"model_calls": <n>}`, plus `"consecutive_continuations": <n>` after a
194
+ continued answer. The input builder pairs every stored tool call
192
195
  with a result, and a missing one gets the outcome-unknown text, so an orphaned
193
196
  call never reaches a provider.
194
197
 
@@ -197,9 +200,19 @@ A function tool declares its effect, which picks `CallTool` or
197
200
  model's arguments against the tool's JSON Schema before `execute` runs, and a
198
201
  mismatch or an unknown tool reaches the model as a tool failure. `execute`
199
202
  receives the arguments and a `ToolCallContext` with the idempotency key,
200
- attempt, events, heartbeat, and the answers to the tool's own questions. A
201
- `string` result is the output, and a `ToolInputRequested` result asks the user
202
- first. Any other result is JSON with the separators of Python's `json.dumps`,
203
+ attempt, events, heartbeat, the answers to the tool's own questions, and
204
+ `effect`: the call's effect after the tool policies, which chose its tool Step.
205
+ A wrapper that turns errors into `ToolExecutionError` lets them propagate for a
206
+ `non_idempotent_write` or `unclassified` effect, and for an absent one, which
207
+ only a hand-built context has. `prepareArguments`, a synchronous and deterministic function, converts the
208
+ decoded arguments before the schema check, such as an argument shape an earlier
209
+ version of the tool took; a throw fails the call like a schema violation, a
210
+ cancellation passes through, and a Promise result is refused. The
211
+ stored tool call and the approval interrupt keep the model's original
212
+ arguments. A
213
+ `string` result is the output, a `functionToolResult` adds images (see
214
+ [Images from function tools](#images-from-function-tools)), and a
215
+ `ToolInputRequested` result asks the user first. Any other result is JSON with the separators of Python's `json.dumps`,
203
216
  so the model sees the same text as from the Python core; the one exception is a
204
217
  whole-number float such as `1.0`, which JavaScript cannot tell from `1`. The
205
218
  argument check and its messages match the Python core, including Python's `==`
@@ -207,7 +220,7 @@ for `enum`, so `true` matches `1`.
207
220
 
208
221
  The session's tool policies, `turn.toolPolicies`, win over a function tool's own
209
222
  effect and `requiresApproval`, whether they come from the agent definition or
210
- from options passed to `createAgentSessionFlow` with a `StepwiseAgentRunner`. A
223
+ from options passed to `createAgentSessionFlow` with a `BuiltinLoopAgentRunner`. A
211
224
  built-in tool enabled only in those options must not share a function tool's
212
225
  name; the first `RunModel` fails before it calls the model.
213
226
 
@@ -222,6 +235,74 @@ SDK returned them. It is replayed unchanged to a model of the same provider;
222
235
  any other provider gets its text and tool calls only, so a session can change
223
236
  model between turns.
224
237
 
238
+ ### Ending or continuing a turn
239
+
240
+ `decideTurnContinuation` lets the application decide, at the end of each loop
241
+ iteration, whether the turn ends or continues. An iteration is one model call
242
+ and the tool calls it requested. The function receives a
243
+ `TurnContinuationContext`: `flowId`, `turnId`, `sessionSettings`, the
244
+ iteration's `modelOutput` items, and its `toolResults` (empty when the model
245
+ answered without tools). It returns `"end"`, `"default"`, or a
246
+ `ContinueTurn` (`{ action: "continue", message }`), directly or as a Promise.
247
+
248
+ | The iteration | `"end"` | `{ action: "continue", message }` | `"default"` or no hook |
249
+ | --- | --- | --- | --- |
250
+ | The model called tools | The turn completes without another model call | The message follows the tool results, and the model is called with both | The model is called with the tool results |
251
+ | The model answered without tools | The turn completes | The answer and the message are stored, and the model is called again in the same turn | The turn completes |
252
+
253
+ ```ts
254
+ const approvalsAgentFlow = createBuiltinAgentSessionFlow(
255
+ {
256
+ name: "approvals",
257
+ instructions: "Review the request, then call submit_decision.",
258
+ model,
259
+ tools: [submitDecision],
260
+ decideTurnContinuation: ({ toolResults, modelOutput }) => {
261
+ if (toolResults.some((result) => result.toolName === "submit_decision")) {
262
+ return "end";
263
+ }
264
+ const answer = modelOutput.map((item) => item.text ?? "").join("");
265
+ if (toolResults.length === 0 && !answer.includes("DECISION:")) {
266
+ return { action: "continue", message: "Finish with a line that starts with DECISION:." };
267
+ }
268
+ return "default";
269
+ },
270
+ },
271
+ "ApprovalsAgent",
272
+ );
273
+ ```
274
+
275
+ - After tools, the hook runs in the next `RunModel` Step, before its model
276
+ call. `"end"` completes the turn there with the tool results as its last
277
+ messages and zero model calls in that Step's usage. The snapshot, the
278
+ `turn_completed` event, and the UI stream show a finished turn whose last
279
+ part is the tool result. That `RunModel` still runs the session's
280
+ compaction and spend-limit checks first, and still emits a `chat` span. The
281
+ UI stream starts a step for every `RunModel` Step, so it sends one more
282
+ `start-step` and `finish-step` with no visible part; the AI SDK message has
283
+ no part for them and matches `convertAgentSnapshotToUIMessages`.
284
+ - After an answer, the hook runs in the same `RunModel` Step, after the model
285
+ call. A continuation returns the `TurnContinued` outcome: the session stores
286
+ the answer and the message as user text, then moves to `RunModel` again.
287
+ The next model request therefore ends with a user message, which every
288
+ provider accepts. Each model call stays its own `RunModel` Step, so a crash
289
+ or retry repeats only that call. Each continued call counts toward
290
+ `maxModelCallsPerTurn`.
291
+ - The message is part of the turn's transcript: the model sees it as user
292
+ text, and snapshots hold it as a `user` message of the same turn.
293
+ `convertAgentSnapshotToUIMessages` keeps a user message between two model
294
+ calls of one turn out of the chat and starts the next model call's step, as
295
+ the UI stream does.
296
+ - At most `maxConsecutiveTurnContinuations` (8) continuations in a row may
297
+ follow answers without tools; a tool call resets the count. The next one
298
+ ends the turn with its answer stored, `lastTurnError` set to `the turn
299
+ reached its limit of 8 consecutive continuations without a tool call`, and a
300
+ `turn_failed` event.
301
+ - A thrown error, or any other value, including a continuation with a blank
302
+ message, fails the `RunModel` attempt, and its retry policy runs it again.
303
+ After an answer, the retry repeats the model call too. Keep the function
304
+ free of side effects.
305
+
225
306
  ### Tool sources
226
307
 
227
308
  `toolSources` takes tools that are listed at run time, such as an MCP server's
@@ -236,7 +317,7 @@ const files = new McpToolSource(
236
317
  [{ transport: "stdio", name: "files", command: "mcp-server-files", args: ["/srv/reports"] }],
237
318
  { resourceAndPromptToolsEnabled: true },
238
319
  );
239
- export const reportsAgentFlow = createStepwiseAgentSessionFlow(
320
+ export const reportsAgentFlow = createBuiltinAgentSessionFlow(
240
321
  {
241
322
  name: "reports",
242
323
  instructions: "You manage reports.",
@@ -286,7 +367,7 @@ runner's last listing of that source, as the Python core does.
286
367
 
287
368
  ### Demo model without a key
288
369
 
289
- `ScriptedModelClient` from `@superdurable/dex-agents/stepwise` answers each
370
+ `ScriptedModelClient` from `@superdurable/dex-agents/builtin-loop` answers each
290
371
  call from a function of the `ModelRequest`, so an agent runs without a model
291
372
  key: a local demo mode, a tutorial, or a test. The function returns text,
292
373
  reasoning, and tool calls, sync or async; `ScriptedModelClient.userTexts` and
@@ -296,7 +377,7 @@ core's. Keep the script deterministic, because a crashed `RunModel` reruns
296
377
  it. `@superdurable/dex-agents/testing` exports the same class.
297
378
 
298
379
  ```ts
299
- import { ScriptedModelClient, type ModelRequest, type ScriptedModelReply } from "@superdurable/dex-agents/stepwise";
380
+ import { ScriptedModelClient, type ModelRequest, type ScriptedModelReply } from "@superdurable/dex-agents/builtin-loop";
300
381
 
301
382
  function replyAsDemoModel(request: ModelRequest): ScriptedModelReply {
302
383
  const results = ScriptedModelClient.toolResults(request);
@@ -309,7 +390,7 @@ function replyAsDemoModel(request: ModelRequest): ScriptedModelReply {
309
390
  const model = isDemo ? new ScriptedModelClient(replyAsDemoModel) : createAiSdkModelClient(openai("gpt-5-mini"));
310
391
  ```
311
392
 
312
- The stored `framework_item` of a Stepwise message is one of
393
+ The stored `framework_item` of a built-in loop message is one of
313
394
  `{"type":"user_text","text"}`,
314
395
  `{"type":"model_output","provider","api","item","text","tool_calls"}`, or
315
396
  `{"type":"tool_result","tool_call_id","tool_name","output"}`, the same as the
@@ -362,7 +443,7 @@ response`; invalid arguments fail with `invalid todos: <reason>`.
362
443
 
363
444
  Plan mode reviews a plan before it runs. Enable it with
364
445
  `planModeEnabled: true`, which needs `write_todos` in `builtInTools` and a
365
- runner whose `supportsPlanMode` is `true`, such as the Stepwise runner.
446
+ runner whose `supportsPlanMode` is `true`, such as the built-in loop runner.
366
447
  `AgentDefinition.planningToolChoice` is `"auto"` by default: a planning turn
367
448
  offers only `write_todos`, the plan note tells the model to write the plan with
368
449
  it, and a turn that answers in text, such as with a clarifying question, ends
@@ -406,9 +487,10 @@ if (snapshot.plan?.status === "draft") {
406
487
  never runs. It marks the plan `active`, writes `plan_execution_started`, and
407
488
  starts an `executing` turn with every tool, whose user message is `[Dex] The
408
489
  user approved plan revision <n>. Execute it and keep the task statuses
409
- current with write_todos.`.
490
+ current with write_todos.`. The stored message has `origin`
491
+ `plan_execution`; see [User messages the session writes](#user-messages-the-session-writes).
410
492
  - `buildPlanNote(plan, interactionMode)` returns the note that shows the model
411
- the plan. Stepwise adds it as user text before the turn's messages in every
493
+ the plan. The built-in loop adds it as user text before the turn's messages in every
412
494
  model call, so the model always sees the latest plan.
413
495
 
414
496
  ## Steering and turn cancellation
@@ -493,7 +575,7 @@ at once.
493
575
 
494
576
  Compaction keeps the model input under the context window. Pass
495
577
  `compaction: { contextWindowTokens, triggerRatio?, retainedRatio? }` to
496
- `createAgentSessionFlow`, or as `AgentDefinition.compaction` for Stepwise. The
578
+ `createAgentSessionFlow`, or as `AgentDefinition.compaction` for the built-in loop. The
497
579
  ratios default to 0.85 and 0.3, as in the Python core.
498
580
 
499
581
  ```ts
@@ -517,7 +599,7 @@ in `turn.messages` and the summary in `turn.contextSummary`.
517
599
  Summarized messages stay stored until retention deletes them; see
518
600
  [Message storage and retention](#message-storage-and-retention). A compaction policy needs a runner with all
519
601
  three optional methods, and `createAgentSessionFlow` throws a `TypeError`
520
- otherwise. `StepwiseAgentRunner` implements them: its input starts with
602
+ otherwise. `BuiltinLoopAgentRunner` implements them: its input starts with
521
603
  `contextSummaryNoteHeader` (`[Dex] Summary of the earlier conversation:`) and
522
604
  the summary, it cuts only between model responses whose tool calls all have
523
605
  stored results, so a turn with many tool calls compacts mid-turn without
@@ -560,7 +642,7 @@ const models: Record<string, ModelClient> = {
560
642
  "gpt-5-mini": createAiSdkModelClient(openai("gpt-5-mini")),
561
643
  "gpt-5": createAiSdkModelClient(openai("gpt-5")),
562
644
  };
563
- const flow = createStepwiseAgentSessionFlow(
645
+ const flow = createBuiltinAgentSessionFlow(
564
646
  {
565
647
  name: "support",
566
648
  instructions: "You help customers with their orders.",
@@ -588,7 +670,7 @@ await client.invokeRPC(flow.updateSessionSettings, sessionId, { sessionSettings:
588
670
  turn left reads as no settings.
589
671
  - A runner reads `AgentTurnContext.sessionSettings`, `{}` when there are
590
672
  none, such as in a session started on 0.1.0. `ContextSummaryRequest` carries
591
- the same value. Stepwise's `AgentDefinition.resolveModel` maps it to the
673
+ the same value. The built-in loop's `AgentDefinition.resolveModel` maps it to the
592
674
  `ModelClient` of each model call, including summary calls and token
593
675
  estimates, and `InstructionContext.sessionSettings` passes it to an
594
676
  instructions function.
@@ -616,6 +698,182 @@ await client.invokeRPC(flow.updateSessionSettings, sessionId, { sessionSettings:
616
698
  - Start sessions with settings, and call `update_session_settings`, only after
617
699
  every Worker of the Flow type runs 0.2.0. A 0.1.0 Worker drops the start
618
700
  settings and starts turns without them.
701
+ - `AgentSessionStartInput.startEnvironment` (`startAgentSession(..., { startEnvironment })`) is different: string
702
+ keys and values that say where the starting program ran. It stays in the Flow's start input and the session never
703
+ reads it, so it is not a setting and cannot change.
704
+
705
+ ## Images
706
+
707
+ A user message and a tool result can carry images. Session state holds only an `AgentImageReference` for each image;
708
+ the bytes live in a media store, keyed by their SHA-256. The wire form is the same as the Python core's:
709
+
710
+ ```json
711
+ {"type": "image", "uri": "s3://agent-media/sessions/sha256/9f2c...", "mime_type": "image/png", "sha256": "9f2c...", "size_bytes": 48213}
712
+ ```
713
+
714
+ Upload first, then send the reference:
715
+
716
+ ```ts
717
+ import { sendAgentUserMessage } from "@superdurable/dex-agents";
718
+ import { S3MediaStore } from "@superdurable/dex-agents/s3";
719
+
720
+ const store = new S3MediaStore({ bucket: "agent-media", prefix: "sessions" }); // credentials from the AWS chain
721
+ const reference = await store.putImage(await readFile("chart.png"));
722
+ await sendAgentUserMessage(client, flow, "session-42", { text: "What does this chart show?", images: [reference] });
723
+ ```
724
+
725
+ A tool source returns images the same way, uploading inside its call. This is a tool source's
726
+ `executeDeferredToolCall` result:
727
+
728
+ ```ts
729
+ const reference = await store.putImage(screenshotPng);
730
+ return { output: "Took a screenshot.", images: [reference] };
731
+ ```
732
+
733
+ A function tool returns `functionToolResult({ output, images })` instead, because the runner serializes its plain
734
+ objects as JSON; see [Images from function tools](#images-from-function-tools).
735
+
736
+ - **One object per image.** The object key is `<prefix>/sha256/<sha256>`, and the reference's `uri` ends in
737
+ `/sha256/<sha256>`. `S3MediaStore.putImage` uploads with `If-None-Match: *` and treats "already exists" (412) as
738
+ success, so a tool Step that a crash retries, a client that sends again, or two Workers at once write one object.
739
+ A 409 conflict retries the upload twice; when every attempt conflicts, a `HEAD` must find the object with the
740
+ image's size, or `putImage` throws `MediaStoreError`. On a
741
+ service that refuses conditional writes it checks with `HEAD` first; two concurrent uploads may then both write the
742
+ same content, so deduplication holds for the key and its final content. A replay always reads the bytes the
743
+ reference was made from: `getImage` reads only references under its own bucket and prefix, refuses an object whose
744
+ `Content-Length` differs, reads at most one byte past `sizeBytes`, and `verifyImageBytes` checks the SHA-256, the
745
+ size, and that the bytes are a well-formed image of `mimeType`.
746
+ - **Types and sizes.** PNG, JPEG, GIF, and WebP, detected from the bytes with a cheap structural check (PNG signature
747
+ and `IHDR`; JPEG SOI, a marker, and EOI at the end; GIF header and screen descriptor; WebP RIFF size and a `VP8`,
748
+ `VP8L`, or `VP8X` chunk), not a full decode, up to `maxImageBytes` (3,750,000 bytes) each. Its base64 text is 5,000,000 bytes, which fits the per-image limits of Amazon Bedrock (5 MB of base64) and the
749
+ Claude API (10 MB of base64). One message or tool
750
+ result carries at most `maxImagesPerMessage` (20). `putImage` throws `ImageValidationError` for other bytes. A `uri`
751
+ is at most `maxImageUriLength` (1024) bytes as UTF-8 in every core, and a store refuses a bucket, prefix, or name
752
+ that would make longer ones. `validateImageReferences` checks references, and `sendAgentUserMessage` checks them
753
+ before it sends.
754
+ - **Where they are stored.** `AgentUserMessage.images`, the session copy on the stored `AgentMessage.images` for
755
+ display, `ToolExecutionResult.images`, and `AgentToolCall.images`. A runner also keeps the references, never the
756
+ bytes, in its `frameworkItem`, the replay truth. Decoded values omit `images` when there are none. The browser
757
+ snapshot JSON includes the `images` of messages and tool calls.
758
+ - **The runner.** `send_user_message` returns `false` for images unless the runner implements
759
+ `buildUserMessageWithImages`, which the session calls instead of `buildUserMessage` for a message with images.
760
+ When it builds model input, the runner reads the bytes with `loadImages(store, references)`, choosing the
761
+ references from its replayed items or from `collectTurnImages(turn)`. The session sets the stored user message's
762
+ `images` itself; for a tool result message the runner sets `FrameworkMessage.images` from the record. A tool result
763
+ with an invalid reference fails the tool Step attempt. The session checks the `images` of every `FrameworkMessage` a
764
+ runner returns before it stores any: an invalid one fails the model call like any runner error, and one from
765
+ `buildUnfinishedTurnMessages` leaves only the turn's note.
766
+ - **Stores.** `new S3MediaStore({ bucket, prefix, endpoint, region, forcePathStyle, credentials })` or
767
+ `S3MediaStore.fromStoreUri("s3://bucket/prefix")` for AWS S3 or any S3-compatible service; call `close()` when done.
768
+ Credentials come from the standard AWS chain by default; pass `credentials` (`accessKeyId`, `secretAccessKey`, and
769
+ optionally `sessionToken`) only for a bucket whose keys are not secret, such as a local development bucket with
770
+ fixed loopback keys. The store never logs them. `InMemoryMediaStore` is for tests in one process, and returns a copy
771
+ on each read. A store of your own implements `MediaStore`: check its location with `requireShortImageUris`, build
772
+ each reference with `buildImageReference(data, storeUri)`, and check reads with `requireStoreImageReference` and
773
+ `verifyImageBytes`.
774
+ - **Local directory.** `new LocalDirectoryMediaStore(root)`, from the main export, keeps images on one machine, for a
775
+ client and Workers that share a file system, such as a command-line tool in local mode; use `S3MediaStore` across
776
+ machines. `root` must be an absolute path. An image's file is `<root>/sha256/<sha256>`, and its `uri` is
777
+ `file://<root>/sha256/<sha256>`: the root is made absolute as text, without resolving symbolic links or normalizing
778
+ Unicode, and every byte other than ASCII letters, digits, `-._~`, and `/` is percent-encoded, the same in every core.
779
+ `putImage` copies the bytes into a temporary file in the same directory and renames it into place; an existing file
780
+ with the same bytes is success. Directories it creates get mode `0700` and files `0600`. A write syncs the image file
781
+ before the rename. Before every `putImage` returns, even when an identical file already existed and nothing was
782
+ written, it syncs the `sha256` directory, the root, and the root's parent; it also syncs the parent of each directory
783
+ it creates. Directories above the root's parent must already be durable; a concurrent creator of a deeper missing
784
+ ancestor is out of scope. It refuses a symbolic link, a special file, or an image file
785
+ with more than one hard link under the root with `MediaStoreError`. Node.js has no `openat`, so it opens files by path
786
+ and checks before and after each operation: `lstat` on the `sha256` directory and `O_NOFOLLOW` on the file before,
787
+ then the directory's real path and the file's device and inode after. A read that fails the check returns nothing;
788
+ a write that fails it removes the file it wrote when it can and throws. A process of the same user that can write the
789
+ `0700` root can still race a directory swap between these checks, so this store does not guarantee that it never
790
+ follows a symbolic link. The Python core opens every path relative to an open directory and has no such window.
791
+ Reads check the file's size first, read at most one byte past `sizeBytes`, and run `verifyImageBytes`. It needs a POSIX file system. Deleting a
792
+ session deletes no file.
793
+ - **Built-in runners.** The built-in loop and the framework adapters show images to their model when built with a
794
+ `mediaStore`: they implement `buildUserMessageWithImages`, keep references in their stored items, and load the bytes
795
+ for each model call. Without a store they do not, and their sessions refuse images. For the built-in loop, pass it
796
+ to `createBuiltinAgentSessionFlow(definition, flowType, { mediaStore })` or `new BuiltinLoopAgentRunner(definition,
797
+ mediaStore)`; see [Images on the built-in loop](#images-on-the-built-in-loop).
798
+ - **MCP tools.** `new McpToolSource(servers, { mediaStore })` uploads the `image` content of a successful result
799
+ inside the tool Step and returns the references in `ToolExecutionResult.images`. Give it the store the runner
800
+ reads.
801
+ - **Retention.** Deleting a session deletes no object. A runner reads the images again whenever a model call replays
802
+ them, so a bucket lifecycle rule must keep each object at least as long as any session that references it can
803
+ still run a turn or replay its history. When an object is gone, the turn that needs it fails with `MediaStoreError`
804
+ in `lastTurnError`, after its user message, with nothing else stored; later turns that replay the image fail the
805
+ same way.
806
+ - Send images only after every Worker of the Flow type runs 0.5.0: an earlier Worker drops them.
807
+
808
+ ### Images on the built-in loop
809
+
810
+ ```ts
811
+ import { S3MediaStore } from "@superdurable/dex-agents/s3";
812
+
813
+ const mediaStore = S3MediaStore.fromStoreUri("s3://agent-media/sessions");
814
+ const flow = createBuiltinAgentSessionFlow(supportAgent, "SupportAgent", { mediaStore });
815
+ ```
816
+
817
+ - **Stored items.** The `user_text` item of a message with images, and the `tool_result` item of a call whose record
818
+ has images, add an `images` list of snake_case references after their other fields, the same JSON as the Python
819
+ core. An item without images has no `images` key, so its bytes are unchanged. The tool result message also shows
820
+ the references in `images`. Session state never holds image bytes.
821
+ - **Model calls.** `ModelUserTextItem.images` and `ModelToolResultItem.images` carry the references into the
822
+ `ModelRequest`. Right before each model call, the runner reads the bytes of every image in the request with
823
+ `loadImages` and passes them in `ModelRequest.imageBytes`, keyed by SHA-256. A retried `RunModel` attempt, or one on
824
+ a new Worker after a crash, builds the same request from the same references and bytes. A store read that fails
825
+ fails that attempt, which retries; after the last attempt the turn records the error in `lastTurnError`.
826
+ - **Summaries and estimates.** The model call that writes a context summary sees the text of the summarized items,
827
+ without their images. `estimateInputTokens` gets requests without image bytes; the AI SDK client counts
828
+ `estimatedImageInputTokens` (1600) for each image.
829
+ - **Without a store.** A tool source result's references are still stored for display, but the model sees only the
830
+ text. A function tool's images fail the call instead, without a retry.
831
+ - **AI SDK.** `createAiSdkModelClient` sends each image after the item's text as an AI SDK `file` part with its bytes:
832
+ `{ type: "file", mediaType, data: { type: "data", data } }`. A user message's content is a text part and one file
833
+ part per image. A tool result with images has `content` output, a text part and one file part per image; the
834
+ provider package converts it, and a provider that takes no images in a tool result handles them as its package
835
+ does.
836
+ - **Your own model client.** Convert each reference in `images` to the provider's image part with its bytes from
837
+ `ModelRequest.imageBytes`, and throw when they are missing. A client that ignores `images` shows the model the text
838
+ only.
839
+ - **Which tools return images.** A tool source's `ToolExecutionResult.images`, such as the image content of a
840
+ `new McpToolSource(servers, { mediaStore })` result. Give the source the store the runner reads. A function tool
841
+ returns `functionToolResult`; see [Images from function tools](#images-from-function-tools).
842
+
843
+ ### Images from function tools
844
+
845
+ A function tool gets the runner's media store as `ToolCallContext.mediaStore`, or `undefined` when the runner has
846
+ none. It uploads the bytes inside its call and returns the references with its text through `functionToolResult`:
847
+
848
+ ```ts
849
+ import { functionTool, functionToolResult } from "@superdurable/dex-agents/builtin-loop";
850
+
851
+ const takeScreenshot = functionTool<Record<string, never>>({
852
+ name: "take_screenshot",
853
+ description: "Take a screenshot of the screen.",
854
+ parameters: { type: "object", properties: {} },
855
+ effect: "read",
856
+ execute: async (_args, context) => {
857
+ if (context.mediaStore === undefined) {
858
+ return "Screenshots need a media store.";
859
+ }
860
+ const reference = await context.mediaStore.putImage(await screen.capturePng());
861
+ return functionToolResult({ output: "Took a screenshot.", images: [reference] });
862
+ },
863
+ });
864
+ ```
865
+
866
+ - The runner returns the `FunctionToolResult` that `functionToolResult` created as a `ToolExecutionResult` with the
867
+ same output and images, so the images are stored, checked, and shown to the model exactly like a tool source's.
868
+ Only the object `functionToolResult` returned counts: a plain object, or a copy, with the same fields is still
869
+ serialized as JSON.
870
+ - More than `maxImagesPerMessage` images, or a reference that `validateImageReference` refuses, fails the attempt
871
+ with `ImageValidationError`. A failed attempt of a `read` or `idempotent_write` tool retries; a single-attempt call
872
+ records `outcome_unknown`.
873
+ - Images from a runner without a media store throw `ToolExecutionError`, because no retry could show them. The
874
+ session records `failed` once, without a retry, and the model sees the error text.
875
+ - A retried attempt uploads the same bytes to the same object, so uploading again is safe. A replay reads the stored
876
+ references, so the tool does not run again.
619
877
 
620
878
  ## Message storage and retention
621
879
 
@@ -738,10 +996,22 @@ interface AgentFrameworkRunner<ToolOutcome = ToolExecutionResult> {
738
996
  runModelStep(turn: AgentTurnContext): Promise<AgentTurnOutcome>;
739
997
  executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolOutcome>;
740
998
  buildUnfinishedTurnMessages?(turn: AgentTurnContext): FrameworkMessage[]; // optional
999
+ buildUserMessageWithImages?(text: string, images: AgentImageReference[]): FrameworkMessage; // optional; see Images
741
1000
  }
742
1001
 
743
- type AgentTurnOutcome = TurnCompleted | ToolCallsRequested;
744
- interface TurnCompleted { kind: "completed"; newMessages: FrameworkMessage[]; usage: AgentUsage }
1002
+ type AgentTurnOutcome = TurnCompleted | ToolCallsRequested | TurnContinued;
1003
+ interface TurnCompleted {
1004
+ kind: "completed";
1005
+ newMessages: FrameworkMessage[];
1006
+ usage: AgentUsage;
1007
+ turnError?: string; // optional since 0.5.0: end the turn as failed, without a retry
1008
+ }
1009
+ interface TurnContinued { // since 0.5.0
1010
+ kind: "turn_continued";
1011
+ frameworkRunState: string; // non-empty, opaque to the session
1012
+ newMessages: FrameworkMessage[]; // stored now; requires buildUnfinishedTurnMessages
1013
+ usage: AgentUsage;
1014
+ }
745
1015
  interface ToolCallsRequested {
746
1016
  kind: "tool_calls_requested";
747
1017
  frameworkRunState: string; // non-empty, opaque to the session
@@ -756,6 +1026,7 @@ interface FrameworkMessage {
756
1026
  text: string | null; toolCallId: string | null; toolName: string | null;
757
1027
  toolArguments: string | null; toolOutput: string | null;
758
1028
  frameworkItem: string; // framework-native JSON, replayed unchanged
1029
+ images?: AgentImageReference[]; // optional display projection; see Images
759
1030
  }
760
1031
  ```
761
1032
 
@@ -776,7 +1047,7 @@ calls it for the turn failure note.
776
1047
  | --- | --- |
777
1048
  | `flowId`, `turnId`, `attempt` | Session ID, `turn-<n>`, one-based `RunModel` attempt |
778
1049
  | `messages` | Every stored message in sequence order, including this turn's user message |
779
- | `frameworkRunState`, `isResuming` | Run state from this turn's last `ToolCallsRequested`, or `null` for the turn's first model call |
1050
+ | `frameworkRunState`, `isResuming` | Run state from this turn's last `ToolCallsRequested` or `TurnContinued`, or `null` for the turn's first model call |
780
1051
  | `toolCalls`, `recordedToolCall(id)` | This turn's tool call records, in deferral order; all final when `runModelStep` runs |
781
1052
  | `resolveToolPolicy(name, frameworkEffect?)` | Effective `ResolvedToolPolicy { effect, requiresApproval }` |
782
1053
  | `events` | `AgentEventWriter` bound to this turn and attempt |
@@ -812,13 +1083,20 @@ The runner must:
812
1083
  every message of the turn the session has not stored yet, in order: model
813
1084
  output, tool requests, tool results, and the answer. Without incremental
814
1085
  messages, that is every message the turn produced after its user message.
815
- 6. Report only this model call's usage. Use `emptyAgentUsage()` and
1086
+ 6. To make another model call within the same turn, return `TurnContinued`
1087
+ with the run state and the messages to store now, ending with a user
1088
+ message when the provider needs the request to end with one. The session
1089
+ stores them and runs `RunModel` again. A runner that never needs this
1090
+ ignores it. To end the turn as failed without a retry, such as at a
1091
+ limit the runner enforces, return `TurnCompleted` with `turnError`; the
1092
+ session stores `newMessages` and sets `lastTurnError` to that text.
1093
+ 7. Report only this model call's usage. Use `emptyAgentUsage()` and
816
1094
  `addAgentUsage()` when a framework reports several usage parts. Optionally
817
1095
  return `modelCallMetadata: { requestModel, responseModel, providerName,
818
1096
  responseId, finishReasons }` in either outcome. The session never stores it;
819
1097
  it names the `chat` span (see [Tracing](#tracing)). Omit a field the
820
1098
  framework does not expose.
821
- 7. Throw for an unexpected failure. The model call reruns from the same
1099
+ 8. Throw for an unexpected failure. The model call reruns from the same
822
1100
  committed state, up to 3 attempts within 30 minutes; then the turn fails.
823
1101
 
824
1102
  **Incremental turn messages (optional).** A runner may return the turn's
@@ -1007,7 +1285,133 @@ const waiting = await client.searchFlows('ActiveStepTypes = "AwaitToolApproval"'
1007
1285
  `AwaitQuestionAnswer` finds questions the same way. A session leaves the search when it moves on. `Client.startFlow`
1008
1286
  does not turn this on; pass `configOverride: { activeStepSearchMode: agentSessionActiveStepSearchMode }` there, or use
1009
1287
  `Client.updateFlowConfig` for a running session. See
1010
- [Find sessions that need a person](https://docs.superdurable.io/agent-frameworks/deploy-agents#find-sessions).
1288
+ [Find sessions that need a person](https://docs.superdurable.io/reference/agents/approvals#find-sessions).
1289
+
1290
+ ## Showing what an approval acts on
1291
+
1292
+ A tool approval can say what the call acts on and what it does. Its tool source returns an
1293
+ `AgentInterruptDisplay` `{ target, summary, preview? }` in `DeferredToolCall.approvalDisplay`, and the session
1294
+ stores it in `AgentInterrupt.display`, on the call's record as `approvalDisplay`, and in the `interrupt_raised`
1295
+ event. A queued ordered write raises its approval later with the display its source built when the model asked.
1296
+
1297
+ ```ts
1298
+ const snapshot = await client.invokeRPC(flow.getAgentSnapshot, sessionId);
1299
+ for (const interrupt of snapshot.pendingInterrupts) {
1300
+ console.log(interrupt.display?.summary ?? interrupt.arguments);
1301
+ }
1302
+ ```
1303
+
1304
+ - `ConnectorToolSource` and the CLI's connector tools build it with `buildConnectorToolApprovalDisplay`: the
1305
+ target is `<connector>/<connection>`, and the summary names the operation, the connection, and up to five short
1306
+ arguments, such as `Calls slack postChannelMessage with connection support-slack; channel: "#eng".`
1307
+ - The CLI's local file writes name the workspace path and carry a unified diff against the file as it is when the
1308
+ model asks; its header lines say so. Its shell commands name the working directory and the command with its
1309
+ inline credentials redacted and its control characters escaped, cut at 2 KiB; the full command is in the call's
1310
+ arguments.
1311
+ - A display never says how often the call runs or what it achieves, and never uses a tool's description as its
1312
+ effect.
1313
+ - Redaction in a display is best-effort hygiene, not a security boundary. The display's text comes from the call's
1314
+ arguments, which the session stores in full as before, and an agent can read a workspace file with its own tools.
1315
+ A credential-like argument (`isCredentialLikeName`) shows `<redacted>`, and `redactInlineCredentials` replaces
1316
+ common credential shapes: the value after a credential-like name, read as a shell word, a triple-quoted string, or
1317
+ a YAML block; `Authorization` and `Cookie` header values; URL passwords; `sshpass -p`; and common token shapes.
1318
+ It reads `\uXXXX`, `\xXX`, and `%XX` escapes to find credentials but changes no other text: escapes stay as
1319
+ written, and only the characters of each credential become `<redacted>`. It is conservative, so a generic
1320
+ `key=value` is redacted too, and the CLI leaves out a file preview whose text it changes. It finds common forms
1321
+ only, and Unicode lookalike letters are out of scope; `agents/conformance/approval_display.json` lists the cases
1322
+ both cores share.
1323
+ - The session cuts a target over `maxApprovalTargetBytes` (1 KiB) and a summary over `maxApprovalSummaryBytes`
1324
+ (2 KiB), ending them with `…`, and drops a preview over `maxApprovalPreviewBytes` (16 KiB). A tool without a
1325
+ display, and every approval a Worker before 0.6.0 raised, has `display` `null`: show the concrete arguments, not a
1326
+ guessed effect.
1327
+
1328
+ ## User messages the session writes
1329
+
1330
+ Since 0.6.0, a stored `user` message that the session wrote itself, not a person, carries
1331
+ `AgentMessage.origin`. Show such a message as a session note, or hide it, never as text the user typed.
1332
+
1333
+ | `origin` | Message |
1334
+ | --- | --- |
1335
+ | `plan_execution` | The message with which `execute_plan` starts the turn, `buildPlanExecutionMessage(revision)` |
1336
+ | `turn_failure_note` | The note `RecordTurnFailure` stores for the model after a failed turn whose tools ran |
1337
+ | `turn_steered_note` | The note that lists the tool calls that ran before `steer_user_message` ended the turn |
1338
+ | `turn_cancelled_note` | The note that lists the tool calls that ran before `cancel_turn` ended the turn |
1339
+
1340
+ ```ts
1341
+ const page = await client.invokeRPC(flow.listAgentMessages, sessionId, { beforeSequence: null, limit: 50 });
1342
+ for (const message of page.messages) {
1343
+ if (message.origin === "plan_execution") {
1344
+ console.log("Running the approved plan");
1345
+ } else if (message.origin === undefined && message.role === "user") {
1346
+ console.log(`> ${message.text ?? ""}`);
1347
+ }
1348
+ }
1349
+ ```
1350
+
1351
+ - Only the session sets `origin`. `send_user_message`, the start input's initial message, and a runner's messages,
1352
+ such as a `decideTurnContinuation` message, have none.
1353
+ - The wire field is `origin`, `null` when absent; a decoded message omits it. Messages stored before 0.6.0 have
1354
+ none. A Worker before 0.6.0 ignores the field, and drops it when it moves the message into an archive chunk.
1355
+ - A later release may add values. The decoder keeps a value it does not know, so treat any `origin` as written by
1356
+ the session.
1357
+
1358
+ ## Listing sessions
1359
+
1360
+ Since 0.6.0, a session can keep a summary that a session list needs: its title, the first 80 Unicode code points of
1361
+ the first user message; the time its latest turn started or ended; and its turn count. Turn it on with
1362
+ `summaryAttributesIndexed: true`; the adapter factories, such as `createBuiltinAgentSessionFlow`, take the same option.
1363
+ It is off by default, and `flow.options.summaryAttributesIndexed` tells whether a Flow keeps it. `listAgentSessions` reads one page of sessions in one `Client.searchFlows` call, without a snapshot
1364
+ per session and without a running Worker:
1365
+
1366
+ ```ts
1367
+ const flow = createAgentSessionFlow(runner, "SupportAgent", { summaryAttributesIndexed: true });
1368
+ const page = await listAgentSessions(client, flow.getFlowType(), { pageSize: 50, titleSearch: "refund" });
1369
+ for (const session of sortAgentSessionsByLastActivity(page.sessions)) {
1370
+ console.log(session.sessionId, session.title ?? "—", session.lastActivityAt?.toISOString() ?? "—", session.turnCount);
1371
+ }
1372
+ ```
1373
+
1374
+ - Dex returns running sessions first, then the most recently closed and started ones. It cannot sort by last
1375
+ activity, so `sortAgentSessionsByLastActivity` sorts the sessions you loaded.
1376
+ - `titleSearch` matches whole words of the title, without case: `flaky` finds "Fix the flaky test", `flak` does not.
1377
+ Pass the same `titleSearch` with each `nextPageToken`.
1378
+ - A session without the option, or one that an earlier release started, lists without the summary, and a title search
1379
+ never finds it. An earlier session gains the last activity time and the turn count at its next turn on a Worker with
1380
+ the option. Show each missing value as a dash. Until a Worker with the option has started on the server, a title
1381
+ search throws `SessionTitleSearchUnavailableError`.
1382
+ - A session that fails because its state would exceed the server's limit can keep the values of its last turn start.
1383
+ - Privacy: the title is the user's own text, and anyone who can search the namespace's Flows reads it. Turn the option
1384
+ on only when the first message may be stored in the search index.
1385
+ - The three Attributes are indexed under `text1` (FULL_TEXT), `datetime1` (DATETIME), and `int1` (INT), the generic
1386
+ keys that every Flow type of a namespace shares. A Worker with the option registers them when it starts. With
1387
+ PostgreSQL search, a namespace has three keys of each of these types; the Worker fails to start with
1388
+ `FAILED_PRECONDITION` when the namespace registered one of these keys with another type, or when all three keys of
1389
+ the type are taken by other names. Search queries of other Flow types must filter on `FlowType`.
1390
+
1391
+ ## Showing sessions in Dex Web
1392
+
1393
+ Dex Web draws a session and offers its Actions from the session Flow
1394
+ Definition Graph. Let the Worker upload it: pass
1395
+ `sessionFlowDefinitionDocument({ flowType, actionPermission })` in
1396
+ `WorkerOptions.flowDefinitions`.
1397
+
1398
+ ```ts
1399
+ import { Registry, Worker } from "@superdurable/dex";
1400
+ import { sessionFlowDefinitionDocument } from "@superdurable/dex-agents";
1401
+
1402
+ const worker = new Worker(new Registry([flow]), blobCache, {
1403
+ flowDefinitions: [
1404
+ sessionFlowDefinitionDocument({ flowType: "SupportAgent", actionPermission: "support-agent.operate" }),
1405
+ ],
1406
+ });
1407
+ ```
1408
+
1409
+ The upload needs server-zd 0.1.26 or later. The document's `generatedAt` is
1410
+ the time the graph template last changed, the same in both cores, so a restart
1411
+ writes nothing and an upgrade replaces the graph. For an older server, render
1412
+ a file with `renderSessionFlowGraph` or the `dex-agents-session-graph` command
1413
+ and pass its directory to Dex Web with `--flow-rendering-dir`. See
1414
+ [Show sessions in Dex Web](https://docs.superdurable.io/reference/agents/dex-web).
1011
1415
 
1012
1416
  ## Spend limits
1013
1417
 
@@ -1015,7 +1419,7 @@ does not turn this on; pass `configOverride: { activeStepSearchMode: agentSessio
1015
1419
  across many sessions. The session asks it before each model call and reports what
1016
1420
  the call used. Counters and limits belong to the application. Without one, the
1017
1421
  session makes no checks. See the interface's documentation and
1018
- [Spend limits](https://docs.superdurable.io/agent-frameworks/deploy-agents#spend-limits).
1422
+ [Spend limits](https://docs.superdurable.io/reference/agents/spend-limits).
1019
1423
 
1020
1424
  ## Flow shape
1021
1425
 
@@ -1116,6 +1520,9 @@ Do not repeat these tool calls unless the user asks again.
1116
1520
  | `PlanExecutions` | ChannelMap, key = `planExecutionKey(revision)` (decimal) | `PlanExecutionRequest { revision }` |
1117
1521
  | `PendingPlanExecution` | Attribute | `PlanExecutionRequest` that `execute_plan` accepted and `AwaitUserMessage` has not consumed |
1118
1522
  | `ContextSummary` | Attribute | `ContextSummary`, set once the session compacts |
1523
+ | `AgentSessionTitle` | Attribute, indexed FULL_TEXT `text1`; only with `summaryAttributesIndexed` | string, the first user message's title; set by the first turn only |
1524
+ | `AgentSessionLastActivityAt` | Attribute, indexed DATETIME `datetime1`; only with `summaryAttributesIndexed` | string, RFC 3339 time the latest turn started or ended |
1525
+ | `AgentSessionTurnCount` | Attribute, indexed INT `int1`; only with `summaryAttributesIndexed` | int64, `AgentState.turnCount` as of the latest turn start or end |
1119
1526
  | `AgentEvents` | Stream | `AgentEvent` |
1120
1527
 
1121
1528
  `AgentState.status` is `waiting_for_user_message`, `running_model`,
@@ -1155,7 +1562,7 @@ IDs are `turn-<n>`, where `n` is `turn_count + 1` when the turn starts.
1155
1562
  | `tool_call_deferred` | `{"tool_call_id","tool_name","effect","requires_approval"}`; the model asked for a call, which runs in its own Step |
1156
1563
  | `tool_call_started` | `{"tool_call_id","tool_name"}` |
1157
1564
  | `tool_call_finished` | `{"tool_call_id","tool_name","status"}`; the record became final, including a rejection |
1158
- | `interrupt_raised` | `{"interrupt_id","kind","tool_call_id","tool_name","deadline","question","choices","allows_free_text"}` |
1565
+ | `interrupt_raised` | `{"interrupt_id","kind","tool_call_id","tool_name","deadline","question","choices","allows_free_text","display"}`; `display` is `{"target","summary","preview"}` or `null` |
1159
1566
  | `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 |
1160
1567
  | `timer_started` | `{"tool_call_id","wait_until","reason"}` for a `durable_wait` call |
1161
1568
  | `plan_updated` | `{"revision","status","task_count"}` after `write_todos` replaced the plan, or when an approval marks a draft `active` |
@@ -1191,12 +1598,13 @@ cannot defer it.
1191
1598
 
1192
1599
  `@superdurable/dex-agents/mcp` turns the tools of MCP servers into tools of an
1193
1600
  agent session. Every MCP tool call runs in its own tool Step, with an effect
1194
- class from the tool's annotations, so a crash never repeats a write silently.
1601
+ class from the tool's annotations or from the [Dex MCP
1602
+ conventions](#dex-mcp-conventions), so a crash never repeats a write silently.
1195
1603
  It needs `@modelcontextprotocol/sdk` 1.x, an optional peer dependency; the main
1196
1604
  entry point never imports it:
1197
1605
 
1198
1606
  ```bash
1199
- npm i @superdurable/dex-agents @superdurable/dex@1.38.0 @modelcontextprotocol/sdk zod
1607
+ npm i @superdurable/dex-agents @superdurable/dex@1.45.0 @modelcontextprotocol/sdk zod
1200
1608
  ```
1201
1609
 
1202
1610
  Configure the servers on the Worker, never from Flow input, a user message, or
@@ -1216,7 +1624,7 @@ const tools = new McpToolSource([
1216
1624
  ]);
1217
1625
  ```
1218
1626
 
1219
- A Stepwise agent takes the source in `toolSources`; see
1627
+ A built-in loop agent takes the source in `toolSources`; see
1220
1628
  [Tool sources](#tool-sources). Another runner offers the tools and defers every
1221
1629
  call; the tool Step runs it:
1222
1630
 
@@ -1248,7 +1656,9 @@ server is listed again in the background while its earlier tools are
1248
1656
  returned. A server that cannot be listed is left out with a warning and is not
1249
1657
  tried again for `listingFailureBackoffMs` (30 seconds), then in the
1250
1658
  background, so one bad server never hides the others' tools or slows every
1251
- model call. One listing per server runs at a time. `listTools({ refresh: true
1659
+ model call. The warnings go to `console.warn` unless `logWarning` sends them
1660
+ elsewhere, such as a log file; a `logWarning` that throws falls back to
1661
+ `console.warn`. One listing per server runs at a time. `listTools({ refresh: true
1252
1662
  })` lists every server again now. `deferredToolCall` stores the
1253
1663
  server, the tool's MCP name, and its task support on the call's record, so the
1254
1664
  tool Step calls that server directly, without a listing; `callTool` finds the
@@ -1258,6 +1668,16 @@ characters shortened with a hash. When two tools get the same name, the first
1258
1668
  in server order is offered and the other is left out with a warning. Key tool
1259
1669
  policies by that name.
1260
1670
 
1671
+ `executeDeferredToolCall(call, { requestMeta })` adds entries to the
1672
+ `tools/call` request's `_meta`, such as the identity the caller expects the
1673
+ tool to have; `callTool` takes the same `requestMeta` option. The idempotency
1674
+ key entry wins on a name clash. `willResumeToolTaskWithoutSending(call)` is
1675
+ `true` only when `executeDeferredToolCall` will return a session-polled task
1676
+ that an earlier attempt started and can never send `tools/call` for the call;
1677
+ a task polled inside the tool Step, such as one bound to a protocol session,
1678
+ may be called again when the server no longer knows it. A check that only
1679
+ guards a new send can be skipped when it is `true`. Since 0.5.0.
1680
+
1261
1681
  ### Resources and prompts
1262
1682
 
1263
1683
  With `resourceAndPromptToolsEnabled: true`, `listTools` also offers three
@@ -1287,10 +1707,11 @@ or list-change notifications. The names are exported as
1287
1707
 
1288
1708
  ### Effects
1289
1709
 
1290
- `toolEffectFromMcpToolAnnotations` classifies each tool:
1710
+ A tool with the Dex `effect` key keeps the declared effect; `toolEffectFromMcpToolAnnotations` classifies the others:
1291
1711
 
1292
1712
  | MCP annotations | Effect | Tool Step | Approval by default |
1293
1713
  | --- | --- | --- | --- |
1714
+ | any, with the Dex `effect` key in `_meta` | the declared effect; see [Dex MCP conventions](#dex-mcp-conventions) | by effect | Only with `approval: recommended` |
1294
1715
  | `readOnlyHint: true` | `read` | `CallTool` | No |
1295
1716
  | `idempotentHint: true`, whatever `destructiveHint` says | `idempotent_write` | `CallTool` | No |
1296
1717
  | anything else, or none | `defaultToolEffect`, `unclassified` by default | `CallSingleAttemptTool` | Yes |
@@ -1317,7 +1738,10 @@ signal aborts the request.
1317
1738
  | What happens | Recorded | Re-issued? |
1318
1739
  | --- | --- | --- |
1319
1740
  | A result | `succeeded`: text blocks joined with newlines, other content as JSON | - |
1741
+ | A result with `image` content, on a source built with `mediaStore` | `succeeded`: each image uploaded and recorded in `images`; the text shows `[image <n>]` in its place, counting from 1. More than 20 images, base64 that is invalid or not canonical, or bytes that are not a supported image: `failed` (`ToolExecutionError`) | - |
1742
+ | An `isError`, `rejected`, or `unknown` result with `image` content | As above, with `[image <n> omitted]` for each image in the text, with or without a store, so the recorded error holds no image bytes | As above |
1320
1743
  | An `isError` result, or a JSON-RPC error response other than `-32603` | `failed` with the text (`ToolExecutionError`) | No |
1744
+ | A result or error with the Dex `outcome` key | `outcome_unknown` or `failed`; see [Dex MCP conventions](#dex-mcp-conventions) | No |
1321
1745
  | The call was never sent: its server is not configured, or 3 connection attempts failed | `failed` | No |
1322
1746
  | 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 |
1323
1747
 
@@ -1347,7 +1771,7 @@ only delays the next poll. A server that no longer knows the task, or cannot be
1347
1771
  reached for three poll attempts, records `failed` for a `read` tool and
1348
1772
  `outcome_unknown` otherwise. `cancel_turn` abandons the task, and
1349
1773
  `cancelToolTask` sends `tasks/cancel`; forward it from your runner's
1350
- `cancelToolTask`, as the Stepwise runner does. Any answer from the server ends
1774
+ `cancelToolTask`, as the built-in loop runner does. Any answer from the server ends
1351
1775
  the cancellation, so a retried one is harmless.
1352
1776
 
1353
1777
  The source requests such a task's TTL as `taskTtlMs`, 24 hours by default. The
@@ -1379,13 +1803,117 @@ task's input request into a session question. The source declares no
1379
1803
  elicitation, sampling, or roots capability, so a server cannot send those
1380
1804
  requests directly.
1381
1805
 
1806
+ ### Dex MCP conventions
1807
+
1808
+ An MCP server built for Dex, such as one that serves Dex connectors, carries a
1809
+ connector's guarantees across MCP with `_meta` keys under the
1810
+ `io.superdurable.dex/` prefix. A server that sets none of them behaves exactly
1811
+ as described above. Both cores follow the same rules, and
1812
+ [`agents/conformance/mcp_dex_conventions.json`](../../conformance/mcp_dex_conventions.json)
1813
+ fixes them case by case. Since 0.5.0.
1814
+
1815
+ | Key | Where | Values |
1816
+ | --- | --- | --- |
1817
+ | `io.superdurable.dex/effect` (`mcpToolEffectMetaKey`) | tool definition `_meta` in `tools/list` | `read`, `idempotent_write`, `non_idempotent_write` |
1818
+ | `io.superdurable.dex/approval` (`mcpToolApprovalMetaKey`) | tool definition `_meta` in `tools/list` | `recommended` |
1819
+ | `io.superdurable.dex/outcome` (`mcpToolOutcomeMetaKey`) | `tools/call` result `_meta`, or JSON-RPC `error.data._meta` | `unknown`, `rejected` |
1820
+ | `io.superdurable.dex/idempotencyKey` (`mcpIdempotencyKeyMetaKey`) | `tools/call` request `_meta`, sent by the source | `<flowId>/<turnId>/<toolCallId>` |
1821
+
1822
+ **Tool definitions.** A tool with the `effect` key is declared, as a connector
1823
+ manifest declares an operation for `ConnectorToolSource`:
1824
+
1825
+ - Its effect class is the declared value, whatever its annotations say:
1826
+ `read` and `idempotent_write` run in `CallTool`, `non_idempotent_write` in
1827
+ `CallSingleAttemptTool`. A declared write runs in the model's order after the
1828
+ turn's earlier writes, and a write after one that did not succeed is skipped.
1829
+ - Without a session policy, its calls wait for approval only with `approval:
1830
+ recommended`, like `agent.approval: recommended` in a manifest. Any other
1831
+ `approval` value, `null` included, also asks, so an unknown value fails
1832
+ closed.
1833
+ - A session policy in `toolPolicies` for the tool may keep or raise its
1834
+ safety, never lower it. The call gets the more conservative of the declared
1835
+ effect and the policy's effect, ranked `read`, `idempotent_write`,
1836
+ `non_idempotent_write`, then `unclassified`. So a policy
1837
+ `{ effect: "non_idempotent_write" }` on a tool declared `read` runs it once in
1838
+ `CallSingleAttemptTool`, in the model's order, after approval. A declared
1839
+ `non_idempotent_write` stays one with a `read` policy.
1840
+ - The policy's `requiresApproval`, when set, wins. When it is left out, a call
1841
+ waits for approval when the tool recommends it or the policy's effect asks
1842
+ for approval by default, as `non_idempotent_write` does.
1843
+ - A framework adapter gets the same rule from
1844
+ `tools.mergeToolPolicies(yourPolicies)`. Do not spread your policies after
1845
+ `tools.toolPolicies()`: yours would replace the declaration and could lower
1846
+ its effect.
1847
+ - `approval` without `effect` is ignored. An `effect` value outside the three
1848
+ above gives the tool the session's `defaultToolEffect` and ignores its
1849
+ annotations, with a warning log.
1850
+ - `trustsToolAnnotations: false` also ignores both keys.
1851
+
1852
+ **Results.** The `outcome` key reports what a call did:
1853
+
1854
+ | `outcome` | Recorded | The model sees | Sent again? |
1855
+ | --- | --- | --- | --- |
1856
+ | `unknown` | `outcome_unknown` (`ToolOutcomeUnknownError`), with the result's text as `error` | `outcomeUnknownToolResult` | Never, for any effect class |
1857
+ | `rejected` | `failed` (`ToolExecutionError`) | `The tool call failed: <text>` | Never |
1858
+ | absent | as in "What a call does" | | |
1859
+
1860
+ - The key wins over `isError` and over the JSON-RPC error code. Set
1861
+ `isError: true` with either value, so a client that does not know these
1862
+ conventions never reads the result as a success.
1863
+ - The text is the result's text: text blocks joined with newlines, other
1864
+ content as JSON. A result without text gets a fixed text. Write a message
1865
+ that is safe to show the model and the user.
1866
+ - In the JSON-RPC error form, put the key in `error.data._meta`. The text is
1867
+ `MCP error <code>: <message>`. An internal error (`-32603`) with the key is
1868
+ not re-issued.
1869
+ - A task's completed result follows the same rule, whether the tool Step or
1870
+ `PollToolTask` reads it.
1871
+ - Other values are reserved; the source ignores them with a warning log.
1872
+
1873
+ **Idempotency key.** Every `tools/call` from a tool Step carries the session's
1874
+ idempotency key. It is the same on every attempt of one tool call, including a
1875
+ `CallTool` retry and an attempt after a Worker restart, and it is unique per
1876
+ tool call. Treat it as opaque. A server that follows these conventions derives
1877
+ its own call ID from it, such as a hash, and keeps a fence per call ID: a
1878
+ retried attempt gets the first attempt's result and never applies the write a
1879
+ second time. When the server cannot tell whether an earlier attempt took
1880
+ effect, it answers `outcome: unknown`. It should refuse a declared write that
1881
+ arrives without the key, because it cannot recognize a retry of that call.
1882
+ Task requests (`tasks/get`, `tasks/update`, `tasks/cancel`) carry no key; the
1883
+ task ID names the call there.
1884
+
1885
+ A declared tool, as `tools/list` returns it:
1886
+
1887
+ ```json
1888
+ {
1889
+ "name": "send_payment",
1890
+ "description": "Send a payment.",
1891
+ "inputSchema": {"type": "object", "properties": {"amount": {"type": "integer"}}},
1892
+ "annotations": {"idempotentHint": true},
1893
+ "_meta": {
1894
+ "io.superdurable.dex/effect": "idempotent_write",
1895
+ "io.superdurable.dex/approval": "recommended"
1896
+ }
1897
+ }
1898
+ ```
1899
+
1900
+ A result whose outcome is unknown:
1901
+
1902
+ ```json
1903
+ {
1904
+ "content": [{"type": "text", "text": "the payment provider timed out"}],
1905
+ "isError": true,
1906
+ "_meta": {"io.superdurable.dex/outcome": "unknown"}
1907
+ }
1908
+ ```
1909
+
1382
1910
  ### With a framework adapter
1383
1911
 
1384
1912
  The runner above is the core's runner-agnostic path. A framework adapter uses
1385
1913
  the source when it accepts tools defined by a JSON Schema: wrap each `McpTool`
1386
1914
  as a framework tool that calls `tools.callTool(name, input, { idempotencyKey })`
1387
1915
  with the key from the adapter's tool context, and pass
1388
- `{ ...tools.toolPolicies(), ...yourPolicies }` as the adapter's `toolPolicies`.
1916
+ `tools.mergeToolPolicies(yourPolicies)` as the adapter's `toolPolicies`.
1389
1917
  No adapter package changes for this.
1390
1918
 
1391
1919
  | Adapter | Schema-only tool | Key inside the tool |
@@ -1403,7 +1931,7 @@ so a wrapper is its only path to tool Steps.
1403
1931
  ## Connector tools (`@superdurable/dex-agents/connectors`)
1404
1932
 
1405
1933
  `@superdurable/dex-agents/connectors` offers reviewed Connector operations to a
1406
- Stepwise agent as tools. `ConnectorToolSource` implements `ToolSource`, like
1934
+ built-in loop agent as tools. `ConnectorToolSource` implements `ToolSource`, like
1407
1935
  `McpToolSource`. Each call runs one operation in its own tool Step through the
1408
1936
  Connector SDK's `runQuery` or `runMutation`, so the connector's Call ID,
1409
1937
  idempotency key, credential handling, and single-dispatch fence apply. Install
@@ -1438,7 +1966,10 @@ an operation whose entry lacks `agent.exposed: true`, an `agent` value it does
1438
1966
  not know, an `agent.branches` key that is not an optional branch (or an
1439
1967
  `unknown` one on a Query), or an `inputSchema` keyword it cannot check. The
1440
1968
  tool name defaults to `<connectorId>_<operation in snake case>`; the
1441
- description and argument schema come from the manifest. A Mutation is any
1969
+ description and argument schema come from the manifest. The snake case is
1970
+ connectorctl's, which also names the Python Step factories: an acronym run
1971
+ stays one word, so `listStoryIDs` becomes `list_story_ids`.
1972
+ `agents/conformance/connector_tools.json` pins it. A Mutation is any
1442
1973
  operation with an `idempotencyKey` method.
1443
1974
 
1444
1975
  | Operation | Effect | Tool Step | Retries |
@@ -1467,13 +1998,42 @@ back. A `RetryError` goes through `throwDexRetry`, except for a single-attempt w
1467
1998
  whose fence saved `notSent` or `canResend` in this attempt: that records `failed` with
1468
1999
  `connectorNotSentRetryLater` instead of `outcome_unknown`.
1469
2000
 
2001
+ The first matching row wins. The rows before the listing can only make an
2002
+ outcome unknown:
2003
+
1470
2004
  | Branch | Record | The model reads |
1471
2005
  | --- | --- | --- |
1472
- | A required (success) branch | `succeeded` | The value as compact JSON, keys sorted |
2006
+ | `uncertain`, whatever the listing or the required branches say | `outcome_unknown` (`ToolOutcomeUnknownError`) | `outcomeUnknownToolResult` |
2007
+ | A Mutation's `defect`, whatever the listing or the required branches say | `outcome_unknown` | The same text |
2008
+ | A required (success) branch other than `defect` | `succeeded` | The value as compact JSON, keys sorted |
2009
+ | Any Mutation branch when its fence cannot prove that nothing was sent | `outcome_unknown` | The same text |
1473
2010
  | Listed `rejected` under `agent.branches` | `failed` (`ToolExecutionError`) | `<connector> <operation> returned <branch>: <kind>: <message>` |
1474
- | `defect` | `failed` | The same text |
1475
- | A Query branch not listed | `failed` | The same text |
1476
- | `uncertain`, listed `unknown`, or a Mutation branch not listed | `outcome_unknown` (`ToolOutcomeUnknownError`) | `outcomeUnknownToolResult` |
2011
+ | Listed `unknown`, or a Mutation branch not listed | `outcome_unknown` | `outcomeUnknownToolResult` |
2012
+ | A Query branch not listed, including `defect` | `failed` | `<connector> <operation> returned <branch>: <kind>: <message>` |
2013
+
2014
+ A Mutation's `defect` is not always local: the Connector SDK selects it for
2015
+ some attempts that already called the provider, such as an invalid attempt or
2016
+ an undeclared branch, and the source cannot tell those apart. A Query's
2017
+ `defect` follows its listing.
2018
+
2019
+ A Mutation that uses the single-dispatch fence gets one more check, the same
2020
+ one the Go Connector SDK makes for Go agent hosts. The source observes every
2021
+ fence marker the attempt saves through its Step context, bound to the call's
2022
+ Call ID and attempt. Once the fence claims a send, or records a provider ID,
2023
+ the attempt may have sent. Only a later `canResend` marker of the same call and
2024
+ attempt, after a claim without a provider ID, lowers that, as the fence's own
2025
+ `recordCanResend` does. The operation's own later checkpoints never lower it.
2026
+ On a retry that reads the earlier checkpoint, a missing checkpoint, one that is
2027
+ not a fence marker, a malformed one, another call's, or one that does not allow
2028
+ this attempt a send also means the request may have been sent. While the
2029
+ request may have been sent, every branch but the success branch records outcome
2030
+ unknown, whatever the listing says, and a Retry is never proof that nothing was
2031
+ sent. The core cannot see inside the fence, so it reads the markers instead of
2032
+ the fence's own events: a forged `canResend` marker of the same call would
2033
+ count. A Mutation that never reads its checkpoint and never saves a fence
2034
+ marker gets no such check and relies on its listing.
2035
+ `agents/conformance/connector_tools.json` pins these cases in
2036
+ `fenceObservationCases`.
1477
2037
 
1478
2038
  A result over `maxResultBytes` (default `defaultConnectorResultMaxBytes`,
1479
2039
  64 KiB of UTF-8 text, set per source or per tool) is cut, followed by
@@ -1495,7 +2055,7 @@ README. An adapter serves them through `ToolSourceRouter`, exported from the
1495
2055
  package root: it lists the sources in each `RunModel`, refuses name
1496
2056
  collisions, lets the source build each deferred call, so the effect, the
1497
2057
  approval, and the write order come from the source, and routes the tool Step,
1498
- its Step options, and polls back to that source. The Stepwise runner uses it
2058
+ its Step options, and polls back to that source. The built-in loop runner uses it
1499
2059
  too.
1500
2060
 
1501
2061
  ## UI streams
@@ -1723,7 +2283,7 @@ The snapshot JSON is the `get_agent_snapshot` wire format without the fields
1723
2283
  only the session's Steps use. Messages leave out `framework_item`, the
1724
2284
  framework's replay data, which can hold the agent's instructions. Tool calls
1725
2285
  leave out `tool_source_reference`, `trace_context`, `input_continuation`,
1726
- `input_answers`, and `task_answer_pending`. Read them with `getAgentSnapshot` on the server when needed. It
2286
+ `input_answers`, `task_answer_pending`, and `runner_tool_state`. Read them with `getAgentSnapshot` on the server when needed. It
1727
2287
  includes `session_settings` as stored, so keep anything a browser must not see out of session settings, or
1728
2288
  remove the key.
1729
2289
 
@@ -1887,7 +2447,7 @@ real Dex Server with no model key:
1887
2447
  | `AgentSessionTestClient` | Typed RPC calls (`resolveInterrupt` retries lock conflicts) and Worker-free waits on committed Attributes |
1888
2448
  | `readStepHistory(serverAddress, flowId)` | Completed Step methods from `dexcli flow history`, to assert a turn's Step shape |
1889
2449
  | `FileGate`, `InvocationLog` | Pause Worker code at an exact point; count real tool runs across processes |
1890
- | `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) |
2450
+ | `ScriptedModelClient` | Built-in loop `ModelClient` that answers from a script, like the Python core's; the same class as `@superdurable/dex-agents/builtin-loop` exports. See [Demo model without a key](#demo-model-without-a-key) |
1891
2451
  | `pollUntil(probe, options)` | Deadline polling where the Client has no direct wait |
1892
2452
  | `findFreeLoopbackPorts(count)` | Distinct free loopback ports, chosen with every listener held open until all are picked |
1893
2453
 
@@ -1980,7 +2540,7 @@ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
1980
2540
  - two tools of one response that ask at once, both answerable, and a sibling
1981
2541
  approval resolved while a tool runs that never reappears as pending.
1982
2542
 
1983
- The Stepwise tests (`test/integ/stepwise-*.integration.test.ts`) run the
2543
+ The built-in loop tests (`test/integ/stepwise-*.integration.test.ts`) run the
1984
2544
  built-in loop on the AI SDK's `MockLanguageModelV4`, scripted from its prompt
1985
2545
  (`test/fixtures/stepwise-scripted-language-model.ts`), and cover:
1986
2546
 
@@ -2112,6 +2672,7 @@ built-in loop on the AI SDK's `MockLanguageModelV4`, scripted from its prompt
2112
2672
  attempts, on every `listTools` call until it answers. A server that binds
2113
2673
  tasks to its protocol session, such as a stateful server on the SDK's task
2114
2674
  store, cannot resume a task across attempts; the retry calls the tool again.
2675
+ - Images: the media stores keep no local read cache, so each `loadImages` reads the store.
2115
2676
  - Tracing covers the session's Steps only. The Dex SDK emits no spans, so Dex
2116
2677
  Server and Worker internals do not appear in the traces.
2117
2678
  - The adapters report only the model information their framework exposes, so