@superdurable/dex-agents 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +475 -2
  3. package/dist/src/agent-event-writer.d.ts +26 -0
  4. package/dist/src/agent-event-writer.js +110 -0
  5. package/dist/src/agent-event-writer.js.map +1 -0
  6. package/dist/src/agent-framework-runner.d.ts +368 -0
  7. package/dist/src/agent-framework-runner.js +81 -0
  8. package/dist/src/agent-framework-runner.js.map +1 -0
  9. package/dist/src/agent-session-codecs.d.ts +44 -0
  10. package/dist/src/agent-session-codecs.js +355 -0
  11. package/dist/src/agent-session-codecs.js.map +1 -0
  12. package/dist/src/agent-session-flow.d.ts +148 -0
  13. package/dist/src/agent-session-flow.js +276 -0
  14. package/dist/src/agent-session-flow.js.map +1 -0
  15. package/dist/src/agent-session-persistence.d.ts +56 -0
  16. package/dist/src/agent-session-persistence.js +43 -0
  17. package/dist/src/agent-session-persistence.js.map +1 -0
  18. package/dist/src/agent-session-steps.d.ts +86 -0
  19. package/dist/src/agent-session-steps.js +533 -0
  20. package/dist/src/agent-session-steps.js.map +1 -0
  21. package/dist/src/agent-session-transitions.d.ts +61 -0
  22. package/dist/src/agent-session-transitions.js +202 -0
  23. package/dist/src/agent-session-transitions.js.map +1 -0
  24. package/dist/src/agent-session-values.d.ts +337 -0
  25. package/dist/src/agent-session-values.js +127 -0
  26. package/dist/src/agent-session-values.js.map +1 -0
  27. package/dist/src/index.d.ts +4 -0
  28. package/dist/src/index.js +12 -0
  29. package/dist/src/index.js.map +1 -0
  30. package/dist/src/testing/agent-session-test-client.d.ts +148 -0
  31. package/dist/src/testing/agent-session-test-client.js +217 -0
  32. package/dist/src/testing/agent-session-test-client.js.map +1 -0
  33. package/dist/src/testing/agent-test-environment.d.ts +52 -0
  34. package/dist/src/testing/agent-test-environment.js +86 -0
  35. package/dist/src/testing/agent-test-environment.js.map +1 -0
  36. package/dist/src/testing/agent-worker-process.d.ts +91 -0
  37. package/dist/src/testing/agent-worker-process.js +150 -0
  38. package/dist/src/testing/agent-worker-process.js.map +1 -0
  39. package/dist/src/testing/child-process-group.d.ts +33 -0
  40. package/dist/src/testing/child-process-group.js +164 -0
  41. package/dist/src/testing/child-process-group.js.map +1 -0
  42. package/dist/src/testing/coordination.d.ts +99 -0
  43. package/dist/src/testing/coordination.js +155 -0
  44. package/dist/src/testing/coordination.js.map +1 -0
  45. package/dist/src/testing/index.d.ts +7 -0
  46. package/dist/src/testing/index.js +15 -0
  47. package/dist/src/testing/index.js.map +1 -0
  48. package/dist/src/testing/polling.d.ts +31 -0
  49. package/dist/src/testing/polling.js +51 -0
  50. package/dist/src/testing/polling.js.map +1 -0
  51. package/dist/src/testing/private-dex-engine.d.ts +92 -0
  52. package/dist/src/testing/private-dex-engine.js +215 -0
  53. package/dist/src/testing/private-dex-engine.js.map +1 -0
  54. package/dist/src/testing/step-history.d.ts +40 -0
  55. package/dist/src/testing/step-history.js +59 -0
  56. package/dist/src/testing/step-history.js.map +1 -0
  57. package/package.json +50 -4
package/LICENSE ADDED
@@ -0,0 +1,105 @@
1
+ # License
2
+
3
+ Copyright (c) 2026 Super Durable, Inc.
4
+
5
+ Portions of this software are licensed as follows:
6
+
7
+ - Legacy Materials remain under their original licenses as described in
8
+ `LEGACY_NOTICES.md`.
9
+ - Third-Party Materials remain under the licenses provided by their owners.
10
+ - The `docs/` and `examples/` directories are not licensed under the
11
+ Sustainable Use License and retain their existing licensing status.
12
+ - All other material owned or licensable by Super Durable, Inc. is available
13
+ under the Sustainable Use License below.
14
+
15
+ See `LEGACY_NOTICES.md` for the applicable legacy and third-party notices.
16
+
17
+ ## Sustainable Use License
18
+
19
+ Version 1.0
20
+
21
+ ### Acceptance
22
+
23
+ By using the software, you agree to all of the terms and conditions below.
24
+
25
+ ### Copyright License
26
+
27
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
28
+ non-sublicensable, non-transferable license to use, copy, distribute, make
29
+ available, and prepare derivative works of the software, in each case subject
30
+ to the limitations below.
31
+
32
+ ### Limitations
33
+
34
+ You may use or modify the software only for your own internal business purposes
35
+ or for non-commercial or personal use. You may distribute the software or
36
+ provide it to others only if you do so free of charge for non-commercial
37
+ purposes. You may not alter, remove, or obscure any licensing, copyright, or
38
+ other notices of the licensor in the software. Any use of the licensor's
39
+ trademarks is subject to applicable law.
40
+
41
+ ### Patents
42
+
43
+ The licensor grants you a license, under any patent claims the licensor can
44
+ license, or becomes able to license, to make, have made, use, sell, offer for
45
+ sale, import and have imported the software, in each case subject to the
46
+ limitations and conditions in this license. This license does not cover any
47
+ patent claims that you cause to be infringed by modifications or additions to
48
+ the software.
49
+
50
+ If you or your company make any written claim that the software infringes or
51
+ contributes to infringement of any patent, your patent license for the software
52
+ granted under these terms ends immediately. If your company makes such a claim,
53
+ your patent license ends immediately for work on behalf of your company.
54
+
55
+ ### Notices
56
+
57
+ You must ensure that anyone who gets a copy of any part of the software from
58
+ you also gets a copy of these terms. If you modify the software, you must
59
+ include in any modified copies of the software a prominent notice stating that
60
+ you have modified the software.
61
+
62
+ ### No Other Rights
63
+
64
+ These terms do not imply any licenses other than those expressly granted in
65
+ these terms.
66
+
67
+ ### Termination
68
+
69
+ If you use the software in violation of these terms, such use is not licensed,
70
+ and your license will automatically terminate. If the licensor provides you
71
+ with a notice of your violation, and you cease all violation of this license no
72
+ later than 30 days after you receive that notice, your license will be
73
+ reinstated retroactively. However, if you violate these terms after such
74
+ reinstatement, any additional violation of these terms will cause your license
75
+ to terminate automatically and permanently.
76
+
77
+ ### No Liability
78
+
79
+ As far as the law allows, the software comes as is, without any warranty or
80
+ condition, and the licensor will not be liable to you for any damages arising
81
+ out of these terms or the use or nature of the software, under any kind of
82
+ legal claim.
83
+
84
+ ### Definitions
85
+
86
+ The "licensor" is the entity offering these terms.
87
+
88
+ The "software" is the software the licensor makes available under these terms,
89
+ including any portion of it.
90
+
91
+ "You" refers to the individual or entity agreeing to these terms.
92
+
93
+ "Your company" is any legal entity, sole proprietorship, or other kind of
94
+ organization that you work for, plus all organizations that have control over,
95
+ are under the control of, or are under common control with that organization.
96
+ Control means ownership of substantially all the assets of an entity, or the
97
+ power to direct its management and policies by vote, contract, or otherwise.
98
+ Control can be direct or indirect.
99
+
100
+ "Your license" is the license granted to you for the software under these
101
+ terms.
102
+
103
+ "Use" means anything you do with the software requiring your license.
104
+
105
+ "Trademark" means trademarks, service marks, and similar rights.
package/README.md CHANGED
@@ -1,3 +1,476 @@
1
- # Temporary Holding Version
1
+ # @superdurable/dex-agents
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Durable agent sessions for Dex, shared by the TypeScript agent framework
4
+ adapters. One Flow execution is one agent session, and the Flow ID is the
5
+ session ID. An adapter package supplies a framework runner; this package owns
6
+ the Steps, persistence, RPCs, live events, tool approval, and crash recovery.
7
+ It runs in Step mode: each model call and each tool call is its own Dex Step,
8
+ and the tool calls of one model response run in parallel.
9
+
10
+ The session Flow implements the contract in
11
+ [`docs/design/plan/agent-framework-adapters.md`](https://github.com/superdurable/dex/blob/main/docs/design/plan/agent-framework-adapters.md).
12
+ Its Step types, persistence names, RPC names, wire values, and event payloads
13
+ match the Python core (`agents/python/core`), so one UI reads sessions from
14
+ either language.
15
+
16
+ ## Installation
17
+
18
+ Applications usually install an adapter next to this package. To build a
19
+ runner of your own, install the core with the Dex SDK it supports:
20
+
21
+ ```bash
22
+ npm i @superdurable/dex-agents @superdurable/dex@1.30.0
23
+ ```
24
+
25
+ `@superdurable/dex` 1.30.0 is a peer dependency, so the application, the core,
26
+ and every adapter share one copy. The Dex Registry matches Flow classes and
27
+ RPC registrations by identity, so a second copy makes it reject the Flow
28
+ (`steps must be a StepList`). Adapters list this package as a peer dependency
29
+ for the same reason. Requirements: Node `^22.0.0 || ^24.0.0`.
30
+
31
+ The package also exports `@superdurable/dex-agents/testing`. It starts a
32
+ private `dexcli dev --engine zd` engine and Worker processes for integration
33
+ tests, and the adapters' own tests import it from the published core. It is
34
+ for tests only; it needs `DEXCLI` and `dex-server-zd`.
35
+
36
+ Develop the package inside this repository with `./ci.sh`. The adapters in
37
+ this repository depend on it as a `file:../core` devDependency with
38
+ `install-links=true` in their `.npmrc`, so npm copies the built core instead
39
+ of symlinking it; a symlinked core would load a second `@superdurable/dex`.
40
+ Consumers of the published packages need neither:
41
+
42
+ ```bash
43
+ cd agents/typescript/core
44
+ DEXCLI=/path/to/dexcli ./ci.sh
45
+ ```
46
+
47
+ An adapter peer-depends on `@superdurable/dex-agents@^<same version>`. All
48
+ agents packages share one version line; see the
49
+ [compatibility table](https://github.com/superdurable/dex/blob/main/agents/README.md#versions-and-compatibility).
50
+
51
+ ## Usage
52
+
53
+ ```ts
54
+ import { Client, Registry, Worker, openBlobCache } from "@superdurable/dex";
55
+ import { createAgentSessionFlow } from "@superdurable/dex-agents";
56
+
57
+ const flow = createAgentSessionFlow(new MyFrameworkRunner(agent), "SupportAgent", {
58
+ toolPolicies: { search_orders: { effect: "read" } },
59
+ interruptDeadlineMs: 24 * 60 * 60 * 1000,
60
+ });
61
+ const registry = new Registry([flow]);
62
+ const blobCache = openBlobCache({ directory: ".dex-cache", maxBytes: 64 * 1024 * 1024 });
63
+ const worker = new Worker(registry, blobCache, { serverAddress: "127.0.0.1:8801" });
64
+ await worker.start();
65
+ await worker.waitPullRegistered({ timeoutMs: 10_000 });
66
+ const client = new Client(registry, blobCache, { serverAddress: "127.0.0.1:8801" });
67
+
68
+ await client.startFlow(flow, "session-42", { initialMessage: "Where is order 42?" });
69
+ const snapshot = await client.invokeRPC(flow.getAgentSnapshot, "session-42");
70
+ for (const interrupt of snapshot.pendingInterrupts) {
71
+ await client.invokeRPC(flow.resolveInterrupt, "session-42", {
72
+ interruptId: interrupt.interruptId,
73
+ approved: true,
74
+ rejectionMessage: null,
75
+ });
76
+ }
77
+ await client.invokeRPC(flow.sendUserMessage, "session-42", { text: "Thanks" });
78
+ ```
79
+
80
+ `createAgentSessionFlow(runner, flowType, options?)` returns a new Flow each
81
+ call, so one Registry can hold several agents under different Flow types. It is
82
+ a function rather than a class because the Dex Client identifies an RPC by its
83
+ method function; two Flows of one class would share RPC methods. The Flow's
84
+ persistence definitions are properties of the Flow (`flow.agentState`,
85
+ `flow.agentMessages`, `flow.agentEvents`, and so on); pass them to
86
+ `client.getAttributes` or the Stream reads.
87
+
88
+ | Option | Default | Meaning |
89
+ | --- | --- | --- |
90
+ | `toolPolicies` | `{}` | `ToolPolicy` by tool name; overrides framework metadata and the default effect |
91
+ | `defaultToolEffect` | `"unclassified"` | Effect of a tool without a policy or framework metadata |
92
+ | `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"` |
94
+
95
+ ## The runner contract
96
+
97
+ The session runs in **Step mode**: every model call and every tool call is its
98
+ own Dex Step, with its own retry policy, timeout, failure route, and history
99
+ entry. Tool calls of one model response run in parallel Steps. An adapter
100
+ implements `AgentFrameworkRunner` and wraps `createAgentSessionFlow` in its own
101
+ factory.
102
+
103
+ ```ts
104
+ interface AgentFrameworkRunner {
105
+ buildUserMessage(text: string): FrameworkMessage;
106
+ runModelStep(turn: AgentTurnContext): Promise<AgentTurnOutcome>;
107
+ executeDeferredToolCall(call: DeferredToolExecution): Promise<ToolExecutionResult>;
108
+ }
109
+
110
+ type AgentTurnOutcome = TurnCompleted | ToolCallsRequested;
111
+ interface TurnCompleted { kind: "completed"; newMessages: FrameworkMessage[]; usage: AgentUsage }
112
+ interface ToolCallsRequested {
113
+ kind: "tool_calls_requested";
114
+ frameworkRunState: string; // non-empty, opaque to the session
115
+ toolCalls: DeferredToolCall[]; // at least one, in the order the model made them
116
+ usage: AgentUsage;
117
+ }
118
+ interface DeferredToolCall { toolCallId: string; toolName: string; arguments: string; policy: ResolvedToolPolicy }
119
+ interface ResolvedToolPolicy { effect: ToolEffect; requiresApproval: boolean }
120
+ interface FrameworkMessage {
121
+ role: "user" | "assistant" | "tool";
122
+ text: string | null; toolCallId: string | null; toolName: string | null;
123
+ toolArguments: string | null; toolOutput: string | null;
124
+ frameworkItem: string; // framework-native JSON, replayed unchanged
125
+ }
126
+ ```
127
+
128
+ All interfaces are `readonly`; see the TSDoc in `src/agent-framework-runner.ts`.
129
+
130
+ ### `buildUserMessage(text)`
131
+
132
+ Return the framework's user message as a `FrameworkMessage` with role `"user"`.
133
+ It must be synchronous, deterministic, and free of side effects. The session
134
+ stores it before the turn's first `runModelStep`. `RecordTurnFailure` also
135
+ calls it for the turn failure note.
136
+
137
+ ### `runModelStep(turn)`
138
+
139
+ `AgentTurnContext` gives the runner:
140
+
141
+ | Member | Meaning |
142
+ | --- | --- |
143
+ | `flowId`, `turnId`, `attempt` | Session ID, `turn-<n>`, one-based `RunModel` attempt |
144
+ | `messages` | Every stored message in sequence order, including this turn's user message |
145
+ | `frameworkRunState`, `isResuming` | Run state from this turn's last `ToolCallsRequested`, or `null` for the turn's first model call |
146
+ | `toolCalls`, `recordedToolCall(id)` | This turn's tool call records, in deferral order; all final when `runModelStep` runs |
147
+ | `resolveToolPolicy(name, frameworkEffect?)` | Effective `ResolvedToolPolicy { effect, requiresApproval }` |
148
+ | `events` | `AgentEventWriter` bound to this turn and attempt |
149
+ | `cancellationSignal`, `deadline` | Abort signal and attempt deadline from Dex |
150
+ | `heartbeat()` | Extra heartbeat; the session already sends one every 20 seconds |
151
+
152
+ The runner must:
153
+
154
+ 1. Rebuild framework input from `messages[*].frameworkItem` only. The other
155
+ message fields are display projections.
156
+ 2. When `isResuming`, restore the framework from `frameworkRunState` and give
157
+ every tool call of the previous response the result from its final record.
158
+ Use `describeToolCallOutcome(record)` for result text, or pass
159
+ `rejectionMessage` to a native rejection mechanism.
160
+ 3. Let the framework make **at most one model call**, then stop it before it
161
+ runs any tool.
162
+ 4. If the response asks for tools, return `ToolCallsRequested` with one
163
+ `DeferredToolCall` per call, each with
164
+ `policy: turn.resolveToolPolicy(toolName, frameworkEffect)`. Pass
165
+ `toolEffectFromMcpToolAnnotations(annotations)` as `frameworkEffect` for MCP
166
+ tools from trusted servers. `frameworkRunState` must let the next
167
+ `runModelStep` continue the turn, including the messages the turn produced
168
+ so far; the session stores no partial messages.
169
+ 5. If the response is the final answer, return `TurnCompleted.newMessages` with
170
+ every message the turn produced after its user message, across all model
171
+ calls, in order: model output, tool requests, tool results, and the answer.
172
+ 6. Report only this model call's usage. Use `emptyAgentUsage()` and
173
+ `addAgentUsage()` when a framework reports several usage parts.
174
+ 7. Throw for an unexpected failure. The model call reruns from the same
175
+ committed state, up to 3 attempts within 30 minutes; then the turn fails.
176
+
177
+ Never run a tool inside `runModelStep`, unless the framework cannot defer it.
178
+ Such a tool repeats whenever the model call reruns; the adapter documents it as
179
+ at-least-once.
180
+
181
+ Tool call IDs must be non-empty, contain no `/`, and be unique within one turn;
182
+ they may repeat across turns, because records and idempotency keys are scoped
183
+ to the turn. Do not request a call that already has a record. The session
184
+ treats both as runner defects and fails the attempt. Keep no state between
185
+ calls: a later call may run in another Worker process. Do not rely on `attempt`
186
+ to detect a rerun; see Limits.
187
+
188
+ ### `executeDeferredToolCall(call)`
189
+
190
+ It runs in `CallTool` or `CallSingleAttemptTool`, possibly concurrently with
191
+ other calls of the same response. `DeferredToolExecution` has `flowId`,
192
+ `toolCall` (the call's `AgentToolCall` record), `idempotencyKey`
193
+ (`<flowId>/<turnId>/<toolCallId>`, the same on every attempt), `attempt`,
194
+ `events`, `cancellationSignal`, `deadline`, and `heartbeat()`.
195
+
196
+ - Run the real tool and return `{ output }`; the text is recorded and shown to
197
+ the model.
198
+ - Throw `ToolExecutionError(message)` for a failure the model should see. The
199
+ call is recorded as `failed` and not retried.
200
+ - Throw anything else for an unexpected failure. `CallTool` retries it up to 3
201
+ attempts; `CallSingleAttemptTool` does not retry. When the attempts run out,
202
+ or on a crash or timeout, the call is recorded as `failed` for a `read` tool
203
+ and `outcome_unknown` for a write. The model then sees
204
+ `outcomeUnknownToolResult`, because the write may have taken effect.
205
+
206
+ ### Events
207
+
208
+ `AgentEventWriter` has `turnId`, `attempt`, `writeTextDelta(text)`,
209
+ `writeReasoningDelta(text)`, `writeToolCallStarted(id, name)`,
210
+ `writeToolCallFinished(id, name, status)`, and `writeEvent(type, payload)`.
211
+ Write `reasoning_delta` only for provider-authored reasoning summaries. Give
212
+ adapter-specific events a prefix, such as `"vercel_ai.step_finished"`. The
213
+ writer coalesces consecutive deltas of one type for up to 100 ms or 1,024
214
+ characters, and drops writes after the handler returns.
215
+
216
+ ## Flow shape
217
+
218
+ ```text
219
+ StartAgentSession -> RunModel (initial message) | AwaitUserMessage
220
+ AwaitUserMessage -> RunModel
221
+ RunModel -> AwaitUserMessage (final answer)
222
+ | one Step per tool call + ResumeTurn(n) (tool calls, all at once)
223
+ | RecordTurnFailure -> AwaitUserMessage (retries exhausted)
224
+ AwaitToolApproval(id) -> CallTool(id) | CallSingleAttemptTool(id) (approved)
225
+ | dead end, after publishing to ToolCallResults (rejected or deadline)
226
+ CallTool(id), CallSingleAttemptTool(id), RecordToolFailure(id), RecordToolOutcomeUnknown(id)
227
+ -> dead end, after publishing to ToolCallResults
228
+ ResumeTurn(n) -> RunModel
229
+ ```
230
+
231
+ A turn with one read tool and a final answer therefore runs `RunModel`,
232
+ `CallTool`, `ResumeTurn`, `RunModel`.
233
+
234
+ | Step type | Input | Behavior | Options |
235
+ | --- | --- | --- | --- |
236
+ | `StartAgentSession` | `AgentSessionStartInput` | Initializes `AgentState`, `AgentUsage`, `PendingInterrupts`; starts a turn when `initial_message` is set | |
237
+ | `AwaitUserMessage` | none | Waits for one queued message, appends it, starts `turn-<n>` | |
238
+ | `RunModel` | none | Calls `runModelStep`. On `TurnCompleted`, appends the messages and clears the turn. On `ToolCallsRequested`, stores the run state, records, and interrupts, then moves to one Step per call plus `ResumeTurn` | 15 min method timeout, 2 min heartbeat timeout, 3 attempts within 30 min, failure route `RecordTurnFailure` |
239
+ | `AwaitToolApproval` | tool call ID | Waits for the call's `InterruptResolutions` instance or its deadline Timer. Approved: moves to the call's tool Step. Rejected or expired: records `rejected` | Locks `PendingInterrupts` in Execute |
240
+ | `CallTool` | tool call ID | Runs one `read` or `idempotent_write` call | 3 attempts, failure route `RecordToolFailure` |
241
+ | `CallSingleAttemptTool` | tool call ID | Runs one `non_idempotent_write` or `unclassified` call | 1 attempt, failure route `RecordToolOutcomeUnknown` |
242
+ | `RecordToolFailure` | tool call ID | Records `failed` for a `read` call and `outcome_unknown` for a write, with the recovery error text | Retry backoff capped at 5 s |
243
+ | `RecordToolOutcomeUnknown` | tool call ID | Records `outcome_unknown` with the recovery error text | Retry backoff capped at 5 s |
244
+ | `ResumeTurn` | expected result count | Waits for that many `ToolCallResults` messages; if a record is still open, waits for the rest; then moves to `RunModel` | |
245
+ | `RecordTurnFailure` | none | Records `last_turn_error` and clears the turn; first appends the turn failure note when the turn has final tool call records | Retry backoff capped at 5 s |
246
+
247
+ Every tool Step receives the tool call ID as its input, and a failure route
248
+ receives the failed Step's input, so each Step knows its own call without any
249
+ shared state. A tool Step whose record is already final ends without running.
250
+ Every Step method sets SYNC durability explicitly, so a Flow started with an
251
+ ASYNC `stepDurability` FlowConfig still commits each result before the session
252
+ advances and never replays a committed single-attempt write.
253
+
254
+ When a turn fails after tool calls finished, the messages its model calls
255
+ produced are lost with the framework run state. `RecordTurnFailure` therefore
256
+ appends one note, built with `runner.buildUserMessage`, as the failed turn's
257
+ last message, so the next turn's model does not repeat a write. The text is
258
+ `buildTurnFailureNote(records)`, identical to the Python core:
259
+
260
+ ```text
261
+ [Dex] The previous turn failed after these tool calls ran:
262
+ - send_email {"to":"ops"} -> succeeded: email sent
263
+ - charge_card {"amount":5} -> outcome_unknown: The tool call's outcome is unknown: ...
264
+ Do not repeat these tool calls unless the user asks again.
265
+ ```
266
+
267
+ | Persistence name | Kind | Value |
268
+ | --- | --- | --- |
269
+ | `AgentState` | Attribute | `AgentState` |
270
+ | `AgentMessages` | AttributeMap, key = `messageSequenceKey(sequence)` (12-digit zero-padded) | `AgentMessage` |
271
+ | `FrameworkRunState` | Attribute | string; set only between a turn's model calls |
272
+ | `AgentToolCalls` | AttributeMap, key = tool call ID | `AgentToolCall`; only the active turn's records |
273
+ | `PendingInterrupts` | Attribute | `AgentInterrupt[]` |
274
+ | `AgentUsage` | Attribute | cumulative `AgentUsage` |
275
+ | `QueuedUserMessages` | Channel | `AgentUserMessage` |
276
+ | `InterruptResolutions` | ChannelMap, key = interrupt ID | `AgentInterruptResolution` |
277
+ | `ToolCallResults` | Channel, internal | `ToolCallCompleted { tool_call_id }` |
278
+ | `AgentEvents` | Stream | `AgentEvent` |
279
+
280
+ `AgentState.status` is `waiting_for_user_message`, `running_model`, or
281
+ `running_tools`. Wire values are snake_case JSON; TypeScript values are the
282
+ same fields in camelCase. Message sequences start at 1 and have no gaps. Turn
283
+ IDs are `turn-<n>`, where `n` is `turn_count + 1` when the turn starts.
284
+
285
+ | RPC | Input | Output |
286
+ | --- | --- | --- |
287
+ | `send_user_message` | `{ text }` | `false` for blank text; a message sent during a turn starts the next turn |
288
+ | `resolve_interrupt` | `{ interrupt_id, approved, rejection_message }` | `false` when not pending or past its deadline; may throw `RpcLockConflictError`, which is safe to retry |
289
+ | `get_agent_snapshot` | none | `{ state, messages, pending_interrupts, tool_calls, usage, queued_message_count }`; `state` is `null` before the session starts |
290
+ | `list_agent_messages` | `{ before_sequence, limit }` | `{ messages, next_before_sequence }`, oldest first; `limit` clamped to 1..200, default 50 |
291
+
292
+ | Event type | Payload |
293
+ | --- | --- |
294
+ | `turn_started` | `{"sequence"}` of the user message |
295
+ | `turn_completed` | `{"message_count"}` appended by the turn |
296
+ | `turn_failed` | `{"error"}` |
297
+ | `text_delta`, `reasoning_delta` | `{"text"}` |
298
+ | `tool_call_deferred` | `{"tool_call_id","tool_name","effect","requires_approval"}`; the model asked for a call, which runs in its own Step |
299
+ | `tool_call_started` | `{"tool_call_id","tool_name"}` |
300
+ | `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"}` |
303
+ | `usage_recorded` | `{"recorded","session"}`, each an `AgentUsage` object |
304
+
305
+ Every event carries `turn_id`, the Step `attempt`, and `step_execution_id`.
306
+ Group live text by `(turn_id, step_execution_id, attempt)`: within one
307
+ `step_execution_id`, keep only the highest `attempt` seen and drop the partial
308
+ text of lower attempts; different `step_execution_id` values of one turn are
309
+ its separate model call and tool call Steps. After `turn_completed` or
310
+ `turn_failed`, replace the turn's live text with the stored messages.
311
+
312
+ ## Effects
313
+
314
+ | Effect | Step | Retry | Crash mid-call | Default approval |
315
+ | --- | --- | --- | --- | --- |
316
+ | `read` | `CallTool` | Up to 3 attempts | Runs again; `failed` after the last attempt | No |
317
+ | `idempotent_write` | `CallTool` | Up to 3 attempts, key `<flowId>/<turnId>/<toolCallId>` | Runs again with the same key; `outcome_unknown` after the last attempt | No |
318
+ | `non_idempotent_write` | `CallSingleAttemptTool` | One attempt | `outcome_unknown` | Yes |
319
+ | `unclassified` | `CallSingleAttemptTool` | One attempt | `outcome_unknown` | Yes |
320
+
321
+ A call that requires approval runs `AwaitToolApproval` first. Precedence for a
322
+ tool's effect: its `ToolPolicy`, then the framework effect the runner passes,
323
+ then `defaultToolEffect`. No tool runs inside `RunModel`, unless the framework
324
+ cannot defer it.
325
+
326
+ ## Testing export
327
+
328
+ `@superdurable/dex-agents/testing` runs adapter integration tests against a
329
+ real Dex Server with no model key:
330
+
331
+ | Export | Purpose |
332
+ | --- | --- |
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` |
334
+ | `KillableWorkerProcess.start({ entryModulePath, serverAddress, logPath, environment })` | Worker child in its own process group; `kill()` sends SIGKILL to the group, `stop()` drains |
335
+ | `runAgentWorkerProcess(flows)` | Body of the Worker child module; 8 concurrent tasks; prints `dex-agents test Worker ready` once registered |
336
+ | `AgentTestEnvironment.start({ serverAddress, flows, isWorkerInProcess })` | Registry, BlobCache, Client, and optional in-process Worker with 8 concurrent tasks |
337
+ | `AgentSessionTestClient` | Typed RPC calls (`resolveInterrupt` retries lock conflicts) and Worker-free waits on committed Attributes |
338
+ | `readStepHistory(serverAddress, flowId)` | Completed Step methods from `dexcli flow history`, to assert a turn's Step shape |
339
+ | `FileGate`, `InvocationLog` | Pause Worker code at an exact point; count real tool runs across processes |
340
+ | `pollUntil(probe, options)` | Deadline polling where the Client has no direct wait |
341
+ | `findFreeLoopbackPorts(count)` | Distinct free loopback ports, chosen with every listener held open until all are picked |
342
+
343
+ Engines and Workers still running when the test process exits receive SIGKILL.
344
+
345
+ ```ts
346
+ // test/worker-main.ts: the Worker child
347
+ import { runAgentWorkerProcess } from "@superdurable/dex-agents/testing";
348
+ await runAgentWorkerProcess([createMyAgentFlow(process.env.TEST_STATE_DIR!)]);
349
+
350
+ // test/kill.integration.test.ts
351
+ const engine = await PrivateDexEngine.start();
352
+ const flow = createMyAgentFlow(stateDirectory);
353
+ const environment = await AgentTestEnvironment.start({
354
+ serverAddress: engine.serverAddress, flows: [flow], isWorkerInProcess: false });
355
+ const sessions = new AgentSessionTestClient(environment.client, flow);
356
+ const startWorker = () => KillableWorkerProcess.start({
357
+ entryModulePath: fileURLToPath(new URL("./worker-main.js", import.meta.url)),
358
+ serverAddress: engine.serverAddress,
359
+ logPath: join(stateDirectory, "worker.log"),
360
+ environment: { TEST_STATE_DIR: stateDirectory },
361
+ });
362
+
363
+ const worker = await startWorker();
364
+ await sessions.startSession(sessionId, "charge the card");
365
+ const interrupt = await sessions.waitForPendingInterrupt(sessionId, "charge_card");
366
+ await sessions.resolveInterrupt(sessionId, interrupt.interruptId, true);
367
+ const gate = new FileGate(stateDirectory, "charge_card"); // the tool calls markReached()
368
+ await gate.waitUntilReached();
369
+ await worker.kill();
370
+ gate.release();
371
+ await startWorker();
372
+ await sessions.waitForCompletedTurns(sessionId, 1);
373
+ const steps = (await readStepHistory(engine.serverAddress, sessionId))
374
+ .filter((entry) => entry.eventType === executeCompletedEvent)
375
+ .map((entry) => entry.stepType);
376
+ ```
377
+
378
+ ## Development
379
+
380
+ ```bash
381
+ DEXCLI=/path/to/dexcli ./ci.sh
382
+ ```
383
+
384
+ `ci.sh` runs `npm ci`, `npm run typecheck`, and `npm run test:integration`,
385
+ which builds with `tsc` and runs `node --test` over `dist/test/integ`. Strict
386
+ `tsc` (`noUnusedLocals`, `noUncheckedIndexedAccess`,
387
+ `exactOptionalPropertyTypes`, and related flags) is the lint gate. The tests
388
+ use a scripted runner (`test/fixtures/scripted-agent-runner.ts`) and cover:
389
+
390
+ - `RunModel`, `CallTool`, `ResumeTurn`, `RunModel` for a read tool, checked in
391
+ Step history, and history replay on the next turn;
392
+ - parallel tool Steps, and a read tool that runs while a sibling write waits
393
+ for approval;
394
+ - rejection, duplicate `resolve_interrupt`, approval deadlines, and two
395
+ interrupts where the deadline rejects the unresolved one;
396
+ - `ToolExecutionError`, exhausted `CallTool` retries for a read and a write,
397
+ and the turn failure note after a failed model call;
398
+ - SIGKILL during `RunModel`, which reruns only that model call, before and
399
+ after output;
400
+ - SIGKILL during `CallSingleAttemptTool`, which records `outcome_unknown`,
401
+ also under an ASYNC step durability FlowConfig;
402
+ - SIGKILL during `CallTool`, retried with the same idempotency key;
403
+ - an approval that survives a Worker restart, `send_user_message` during a
404
+ turn, and snapshots and message paging.
405
+
406
+ ## Design decisions
407
+
408
+ - **Step mode.** Following the review of
409
+ [#1132](https://github.com/superdurable/dex/pull/1132), Dex adds no new
410
+ primitive for durable operations inside a Step. Each model call and each tool
411
+ call is an ordinary Step, so a crash reruns only the call that was running,
412
+ and every call has its own retry policy, timeout, failure route, and history
413
+ entry.
414
+ - **Factory, not class.** See Usage. Each Flow also owns its persistence
415
+ definition objects; the persisted names are the same for every Flow type.
416
+ - **Tool call ID as Step input.** Tool Steps run in parallel, so no shared
417
+ "executing tool call" Attribute exists. The ID travels in each Step's input,
418
+ and failure routes receive it as the failed Step's input. Each tool Step and
419
+ its failure route load only that call's `AgentToolCalls` instance, through
420
+ per-movement Step options. `ResumeTurn` takes its count as an int64 Value,
421
+ as the Python core's `Step[int]` does.
422
+ - **Whole-second deadline Timers.** Dex Timers take whole seconds, so
423
+ `AwaitToolApproval` rounds the remaining time up. A Timer can then fire up
424
+ to a second after the deadline; `resolve_interrupt` already returns `false`
425
+ from the deadline on, so a late answer never wins.
426
+ - **`ToolCallResults` and `ResumeTurn`.** Each tool call publishes exactly one
427
+ `ToolCallCompleted` when its record becomes final. `ResumeTurn` waits for one
428
+ per call, then re-counts open records and waits again if any remain, so a
429
+ stray message can never resume a turn early.
430
+ - **Approval races.** `AwaitToolApproval` locks `PendingInterrupts`. When its
431
+ deadline Timer fires but `resolve_interrupt` already removed the interrupt,
432
+ or the Timer fired early, it waits again and consumes the resolution.
433
+ - **`deferral_order`.** `AgentToolCall` carries a zero-based `deferral_order`,
434
+ as in the Python core; records and the snapshot list calls in the order the
435
+ model made them.
436
+ - **Output before a single-attempt tool runs.** Dex re-delivers an attempt,
437
+ with the same attempt number, when its Worker disconnects before sending any
438
+ output within the dispatch wait. A tool Step therefore writes
439
+ `tool_call_started` and awaits a heartbeat before calling the runner, so a
440
+ crash during the tool fails the attempt instead of re-running the tool.
441
+ - **Background heartbeats.** `RunModel` and the tool Steps heartbeat every 20
442
+ seconds while the runner works, so a silent model or tool call stays within
443
+ the heartbeat timeout. Method timeouts still bound hung calls.
444
+ - **Delta coalescing.** The events writer merges consecutive text or reasoning
445
+ deltas; the Python core writes one Stream message per call.
446
+ - **TypeScript extras.** `AgentTurnContext` and `DeferredToolExecution` add
447
+ `cancellationSignal` and `deadline`. `ToolPolicy.requiresApproval` is optional
448
+ and defaults by effect, as in Python.
449
+
450
+ ## Limits
451
+
452
+ - `RunModel`, `get_agent_snapshot`, and `list_agent_messages` load the whole
453
+ `AgentMessages` map, and every model call replays the full history. There is
454
+ no compaction yet.
455
+ - Each model call and each tool call adds a Step round trip.
456
+ - A crash in `RunModel` reruns that model call. When the crashed attempt sent
457
+ no output, Dex re-delivers it with the same `attempt`, so `attempt` does not
458
+ reliably count reruns.
459
+ - The single-attempt guarantee depends on the tool Step's output frame reaching
460
+ Dex before the tool runs. The Step awaits a heartbeat first, but that Promise
461
+ resolves when grpc-js takes the frame locally: it does not prove that the
462
+ frame left the Worker, let alone that Dex received it. If the Worker dies
463
+ before Dex receives the frame, whether the frame was still in the Worker's
464
+ buffers or on the network, and within the 10 second dispatch wait, Dex can
465
+ re-deliver the attempt and the tool can run a second time. The window is
466
+ normally well under a second.
467
+ - Tools a framework cannot defer run inside `RunModel` and repeat with it; each
468
+ adapter documents them as at-least-once.
469
+ - Interrupts are only tool approvals. Queued user messages are consumed one per
470
+ turn; there is no steering of a running turn.
471
+ - A failed turn keeps its user message in history without an assistant reply;
472
+ only the turn failure note records what its tools did.
473
+ - Deadlines use the Worker clock when the Timer is computed.
474
+ - Sessions never complete on their own; stop them with `Client.stopFlow`.
475
+ - `AgentEvents` is best effort and its byte budget is shared by every session
476
+ of the Flow type; rebuild from `get_agent_snapshot`.
@@ -0,0 +1,26 @@
1
+ import type { Context, Stream } from "@superdurable/dex";
2
+ import type { AgentEventWriter } from "./agent-framework-runner.js";
3
+ import type { AgentEvent, AgentToolCallStatus } from "./agent-session-values.js";
4
+ export declare class StreamAgentEventWriter implements AgentEventWriter {
5
+ private readonly context;
6
+ private readonly stream;
7
+ readonly turnId: string | null;
8
+ private pendingDeltaType;
9
+ private pendingDeltaText;
10
+ private deltaFlushTimer;
11
+ private isClosed;
12
+ constructor(context: Context, stream: Stream<AgentEvent>, turnId: string | null);
13
+ get attempt(): number;
14
+ writeEvent(type: string, payload: Readonly<Record<string, unknown>>): void;
15
+ writeTextDelta(text: string): void;
16
+ writeReasoningDelta(text: string): void;
17
+ writeToolCallStarted(toolCallId: string, toolName: string): void;
18
+ writeToolCallFinished(toolCallId: string, toolName: string, status: AgentToolCallStatus): void;
19
+ /** Flushes buffered deltas and drops later writes; call before the handler returns. */
20
+ close(): void;
21
+ private writeDelta;
22
+ private flushPendingDelta;
23
+ private writeStreamMessage;
24
+ }
25
+ /** Serializes with sorted object keys, as the Python core does, so payload text is deterministic. */
26
+ export declare function stringifyWithSortedKeys(value: unknown): string;