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