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