@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.
- package/LICENSE +105 -0
- package/README.md +475 -2
- package/dist/src/agent-event-writer.d.ts +26 -0
- package/dist/src/agent-event-writer.js +110 -0
- package/dist/src/agent-event-writer.js.map +1 -0
- package/dist/src/agent-framework-runner.d.ts +368 -0
- package/dist/src/agent-framework-runner.js +81 -0
- package/dist/src/agent-framework-runner.js.map +1 -0
- package/dist/src/agent-session-codecs.d.ts +44 -0
- package/dist/src/agent-session-codecs.js +355 -0
- package/dist/src/agent-session-codecs.js.map +1 -0
- package/dist/src/agent-session-flow.d.ts +148 -0
- package/dist/src/agent-session-flow.js +276 -0
- package/dist/src/agent-session-flow.js.map +1 -0
- package/dist/src/agent-session-persistence.d.ts +56 -0
- package/dist/src/agent-session-persistence.js +43 -0
- package/dist/src/agent-session-persistence.js.map +1 -0
- package/dist/src/agent-session-steps.d.ts +86 -0
- package/dist/src/agent-session-steps.js +533 -0
- package/dist/src/agent-session-steps.js.map +1 -0
- package/dist/src/agent-session-transitions.d.ts +61 -0
- package/dist/src/agent-session-transitions.js +202 -0
- package/dist/src/agent-session-transitions.js.map +1 -0
- package/dist/src/agent-session-values.d.ts +337 -0
- package/dist/src/agent-session-values.js +127 -0
- package/dist/src/agent-session-values.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/testing/agent-session-test-client.d.ts +148 -0
- package/dist/src/testing/agent-session-test-client.js +217 -0
- package/dist/src/testing/agent-session-test-client.js.map +1 -0
- package/dist/src/testing/agent-test-environment.d.ts +52 -0
- package/dist/src/testing/agent-test-environment.js +86 -0
- package/dist/src/testing/agent-test-environment.js.map +1 -0
- package/dist/src/testing/agent-worker-process.d.ts +91 -0
- package/dist/src/testing/agent-worker-process.js +150 -0
- package/dist/src/testing/agent-worker-process.js.map +1 -0
- package/dist/src/testing/child-process-group.d.ts +33 -0
- package/dist/src/testing/child-process-group.js +164 -0
- package/dist/src/testing/child-process-group.js.map +1 -0
- package/dist/src/testing/coordination.d.ts +99 -0
- package/dist/src/testing/coordination.js +155 -0
- package/dist/src/testing/coordination.js.map +1 -0
- package/dist/src/testing/index.d.ts +7 -0
- package/dist/src/testing/index.js +15 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/polling.d.ts +31 -0
- package/dist/src/testing/polling.js +51 -0
- package/dist/src/testing/polling.js.map +1 -0
- package/dist/src/testing/private-dex-engine.d.ts +92 -0
- package/dist/src/testing/private-dex-engine.js +215 -0
- package/dist/src/testing/private-dex-engine.js.map +1 -0
- package/dist/src/testing/step-history.d.ts +40 -0
- package/dist/src/testing/step-history.js +59 -0
- package/dist/src/testing/step-history.js.map +1 -0
- 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
|
-
#
|
|
1
|
+
# @superdurable/dex-agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Durable agent sessions for Dex, shared by the TypeScript agent framework
|
|
4
|
+
adapters. One Flow execution is one agent session, and the Flow ID is the
|
|
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;
|