@juno-ai/bind 8.0.0 → 10.0.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 +405 -58
- package/completion/index.d.ts +1 -0
- package/completion/index.js +1 -0
- package/completion/text-stream.d.ts +311 -0
- package/completion/text-stream.js +273 -0
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +77 -2
- package/contracts/turn.js +35 -2
- package/loop/index.d.ts +2 -1
- package/loop/index.js +1 -1
- package/loop/tool-loop.d.ts +131 -12
- package/loop/tool-loop.js +285 -49
- package/package.json +5 -1
- package/plugins/dispatch.d.ts +130 -0
- package/plugins/dispatch.js +241 -0
- package/plugins/index.d.ts +2 -0
- package/plugins/index.js +2 -0
- package/plugins/tool-message.d.ts +23 -0
- package/plugins/tool-message.js +31 -0
- package/plugins/tool.d.ts +1 -1
- package/run/index.d.ts +1 -1
- package/run/index.js +1 -1
- package/run/tool-batch.d.ts +51 -1
- package/run/tool-batch.js +59 -1
- package/testing/index.d.ts +153 -0
- package/testing/index.js +188 -0
- package/tools/control-chars.d.ts +23 -0
- package/tools/control-chars.js +35 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming a turn's assistant text to a live surface, safely across retries.
|
|
3
|
+
*
|
|
4
|
+
* Forwarding deltas is one line in a host's transport. What is not one line —
|
|
5
|
+
* and what every host that streams has to get right independently — is what
|
|
6
|
+
* happens when the attempt that produced those deltas *fails*. The routing
|
|
7
|
+
* executor's answer to a failure is to try again: same endpoint, then the next
|
|
8
|
+
* provider, then the fallback model. Each of those re-renders a turn the user is
|
|
9
|
+
* already reading.
|
|
10
|
+
*
|
|
11
|
+
* Until now the package's only answer was to refuse: a transport that had
|
|
12
|
+
* streamed set `producedOutput` on its failure outcome and the executor clamped
|
|
13
|
+
* to propagate-only (`propagateOnly`). That trades a recoverable failure for a
|
|
14
|
+
* visible one — a 500 on the first provider becomes an error the user sees,
|
|
15
|
+
* purely because the first token had left.
|
|
16
|
+
*
|
|
17
|
+
* There is a better answer whenever the surface can be *retracted*: tell it to
|
|
18
|
+
* discard what it has, then stream the retry cleanly. That is this module. It
|
|
19
|
+
* turns `producedOutput` from "did we emit" into what its own contract already
|
|
20
|
+
* said — **"did we emit somewhere the caller cannot take it back"** — and lets a
|
|
21
|
+
* retractable surface keep the whole fallback chain.
|
|
22
|
+
*
|
|
23
|
+
* ## Lifetime: one stream per turn, and you must call `finish()`
|
|
24
|
+
*
|
|
25
|
+
* Construct it in the function that owns the turn — outside the executor and
|
|
26
|
+
* outside any structured-output retry loop — and call {@link
|
|
27
|
+
* TurnTextStream.finish} when the turn is over, whichever way it ended.
|
|
28
|
+
*
|
|
29
|
+
* Both halves are load-bearing and neither is enforceable from in here. A stream
|
|
30
|
+
* built *inside* the `AttemptFn` never sees a second attempt, so it never resets
|
|
31
|
+
* and `producedOutput` is never true; the single-attempt path — almost all
|
|
32
|
+
* traffic — looks identical to a correct one, and the bug appears only under
|
|
33
|
+
* fallback, as two partial answers glued together. Skipping `finish()` leaves an
|
|
34
|
+
* armed reset undelivered whenever the attempt that *succeeds* emits no text,
|
|
35
|
+
* which is the ordinary shape of a tool-calling turn (`content: null` plus
|
|
36
|
+
* `tool_calls`): the discarded attempt's narration then stays on screen as if it
|
|
37
|
+
* belonged to the turn that replaced it.
|
|
38
|
+
*
|
|
39
|
+
* ## What the host still owns
|
|
40
|
+
*
|
|
41
|
+
* The sink, and the judgement of what goes into it. In particular **reasoning
|
|
42
|
+
* deltas are not decided here**: whether a model's thinking counts as output the
|
|
43
|
+
* user has seen is a product call, and the host expresses it by choosing what it
|
|
44
|
+
* passes to {@link TurnTextStream.observe}.
|
|
45
|
+
*
|
|
46
|
+
* **Tool-call deltas are deliberately not output.** It is tempting to latch on
|
|
47
|
+
* the first byte of any kind, and a host streaming raw chunks may already do
|
|
48
|
+
* that. But a tool call is not a side effect until it is *dispatched*, which
|
|
49
|
+
* happens after the turn completes — so a turn that dies mid-stream having
|
|
50
|
+
* emitted only tool-call bytes has changed nothing the user or a third party can
|
|
51
|
+
* see, and refusing to retry it forfeits a fallback for free. A host with a
|
|
52
|
+
* genuine mid-attempt effect of its own says so with
|
|
53
|
+
* {@link TurnTextStream.markProducedOutput} rather than by widening what counts
|
|
54
|
+
* as text.
|
|
55
|
+
*
|
|
56
|
+
* **`onAssistantMessage` on the turn kernel is a different surface.** It fires
|
|
57
|
+
* once per completed assistant message; these events stream one attempt of one
|
|
58
|
+
* message. Wiring both to the same UI element delivers the same text twice. The
|
|
59
|
+
* division that works: deltas drive the live view, `onAssistantMessage` drives
|
|
60
|
+
* the permanent record (the transcript row, the notification, the third-party
|
|
61
|
+
* post). If they must share one element, treat the persisted message as
|
|
62
|
+
* authoritative and let it supersede the streamed epochs for its `turnId`.
|
|
63
|
+
*
|
|
64
|
+
* ## The race this exists to make survivable
|
|
65
|
+
*
|
|
66
|
+
* On any real transport — a WebSocket through a Durable Object, an SSE relay, a
|
|
67
|
+
* fan-out to several tabs — an in-flight delta from attempt 1 can be *delivered
|
|
68
|
+
* after* attempt 2's reset. A client that renders every text event it receives
|
|
69
|
+
* will then show the wiped attempt's tail glued onto the retry. Every event
|
|
70
|
+
* therefore carries a `turnId`, an `epoch` (which attempt produced it) and a
|
|
71
|
+
* `seq` (monotonic within the turn), and a correct client uses all three:
|
|
72
|
+
*
|
|
73
|
+
* - scope everything to `turnId` — `epoch` and `seq` both restart each turn, so
|
|
74
|
+
* a client that carried "newest epoch" across turns drops every event after
|
|
75
|
+
* the first turn that retried, and a later turn's reset tells it to wipe an
|
|
76
|
+
* earlier, committed message;
|
|
77
|
+
* - **apply events in `seq` order**: drop anything at or below the last `seq`
|
|
78
|
+
* applied, and hold anything that arrives ahead of it until the gap fills;
|
|
79
|
+
* - then drop any event whose `epoch` is older than the newest seen, and clear
|
|
80
|
+
* what is rendered on a `reset`.
|
|
81
|
+
*
|
|
82
|
+
* The `seq` step is not optional decoration on the `epoch` step — it is what
|
|
83
|
+
* makes the epoch step *sound*. Ordering by epoch alone loses a same-epoch
|
|
84
|
+
* reorder: a retry's text arriving before its own reset is accepted and then
|
|
85
|
+
* cleared by the reset that follows, leaving the surface blank. A transport that
|
|
86
|
+
* already guarantees ordered exactly-once delivery collapses the `seq` step to a
|
|
87
|
+
* no-op, but that is a property to assert deliberately rather than assume.
|
|
88
|
+
*
|
|
89
|
+
* ## Why the reset is lazy
|
|
90
|
+
*
|
|
91
|
+
* It fires on the retry's **first byte**, not when the previous attempt failed.
|
|
92
|
+
* A retry that dies before producing anything — no viable endpoint, an immediate
|
|
93
|
+
* 401 — would otherwise have wiped the screen to show nothing. Partial text plus
|
|
94
|
+
* an error is strictly more useful to a reader than a blank space plus an error.
|
|
95
|
+
* A turn where no attempt ever emits therefore emits no events at all.
|
|
96
|
+
*
|
|
97
|
+
* The one thing that must not be lazy is a reset still armed when the turn ends
|
|
98
|
+
* — hence `finish()`, which flushes it. By then the text it retracts is known to
|
|
99
|
+
* have come from an attempt that was thrown away.
|
|
100
|
+
*/
|
|
101
|
+
/** Why the surface is being told to discard what it has rendered. */
|
|
102
|
+
export type TurnResetReason =
|
|
103
|
+
/** The previous attempt failed and routing moved on (retry, provider, model). */
|
|
104
|
+
"attempt_failed"
|
|
105
|
+
/** The previous attempt succeeded but its output was rejected and re-asked. */
|
|
106
|
+
| "structured_output_retry";
|
|
107
|
+
/**
|
|
108
|
+
* One event bound for the user's surface.
|
|
109
|
+
*
|
|
110
|
+
* `turnId` is the host's identifier for the turn and scopes everything else:
|
|
111
|
+
* `seq` is monotonic within it and never restarts, so it doubles as an ordering
|
|
112
|
+
* and de-duplication key on a transport that can do neither, and `epoch`
|
|
113
|
+
* identifies the attempt and only ever increases. Both restart on the next turn,
|
|
114
|
+
* which is why the `turnId` is on the wire.
|
|
115
|
+
*/
|
|
116
|
+
export type TurnStreamEvent = Readonly<{
|
|
117
|
+
kind: "text";
|
|
118
|
+
turnId: string;
|
|
119
|
+
epoch: number;
|
|
120
|
+
seq: number;
|
|
121
|
+
text: string;
|
|
122
|
+
}> | Readonly<{
|
|
123
|
+
kind: "reset";
|
|
124
|
+
turnId: string;
|
|
125
|
+
epoch: number;
|
|
126
|
+
seq: number;
|
|
127
|
+
reason: TurnResetReason;
|
|
128
|
+
}>;
|
|
129
|
+
/**
|
|
130
|
+
* Where a turn's text goes.
|
|
131
|
+
*
|
|
132
|
+
* `retractable` is the whole decision. It is a property of the *surface*, not of
|
|
133
|
+
* the transport that writes to it: a view that re-renders from the events it is
|
|
134
|
+
* sent is retractable; a chat message already posted through a third-party API,
|
|
135
|
+
* an email, a webhook delivery, and an append-only transcript row are not.
|
|
136
|
+
*
|
|
137
|
+
* **Wire only retractable surfaces here.** A host that must also deliver
|
|
138
|
+
* somewhere permanent should do that from the *completed* turn rather than from
|
|
139
|
+
* the deltas — that composes correctly, whereas declaring a permanent surface
|
|
140
|
+
* retractable silently re-enables the duplication this module exists to prevent.
|
|
141
|
+
* If one sink genuinely fans out to a mix, declare it `false`; the conservative
|
|
142
|
+
* answer costs a fallback, the optimistic one costs the user's trust.
|
|
143
|
+
*
|
|
144
|
+
* **`retractable` is read once, when the stream is created**, and a `readonly`
|
|
145
|
+
* field is no barrier to a getter. Re-reading it would make `producedOutput`
|
|
146
|
+
* non-monotonic: a sink that flipped after the first byte could un-clamp routing
|
|
147
|
+
* *after* the executor had already been told the turn was replayable, and the
|
|
148
|
+
* permanent surface would then get the turn twice.
|
|
149
|
+
*
|
|
150
|
+
* A sink that **buffers** — coalescing deltas over a byte or time window before
|
|
151
|
+
* releasing them — is free to drop a `reset` whose epoch never left that buffer,
|
|
152
|
+
* along with the text it would have wiped. Nothing was rendered, so nothing
|
|
153
|
+
* needs retracting, and the client is spared a no-op flicker. This module cannot
|
|
154
|
+
* do that for the sink because only the sink knows what it has released.
|
|
155
|
+
*/
|
|
156
|
+
export interface TurnStreamSink {
|
|
157
|
+
readonly retractable: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Deliver one event. May throw — a closed socket is ordinary, not
|
|
160
|
+
* exceptional. See {@link TurnTextStream.sinkErrors} for what a throw means.
|
|
161
|
+
*/
|
|
162
|
+
emit(event: TurnStreamEvent): void;
|
|
163
|
+
}
|
|
164
|
+
export interface TurnTextStreamOptions {
|
|
165
|
+
readonly sink: TurnStreamSink;
|
|
166
|
+
/**
|
|
167
|
+
* Identifies the turn on the wire. Any value the client can compare for
|
|
168
|
+
* equality and that is unique among the turns it may see concurrently — a
|
|
169
|
+
* message id, a run id plus a turn ordinal. Required rather than defaulted
|
|
170
|
+
* because a client cannot scope `epoch` and `seq` without it, and every
|
|
171
|
+
* plausible default would be wrong for someone.
|
|
172
|
+
*
|
|
173
|
+
* **It is a correlation label, never an authorization boundary.** The client
|
|
174
|
+
* rule says "scope everything to `turnId`", which is about *ordering*, not
|
|
175
|
+
* about deciding whether an event is yours to render. The sink must already be
|
|
176
|
+
* scoped to the intended recipient before anything is emitted — a client that
|
|
177
|
+
* treats a matching `turnId` as evidence an event belongs to it will render
|
|
178
|
+
* whatever arrives on a shared topic, including a `reset` that wipes a
|
|
179
|
+
* committed message.
|
|
180
|
+
*/
|
|
181
|
+
readonly turnId: string;
|
|
182
|
+
/**
|
|
183
|
+
* Cap on how many times the surface may be wiped in one turn. Reaching it
|
|
184
|
+
* does not throw and does not stop emission: it makes
|
|
185
|
+
* {@link TurnTextStream.producedOutput} true, so the *next* failure clamps
|
|
186
|
+
* routing and the traversal stops. Emission continues so that an attempt
|
|
187
|
+
* already in flight still reaches the user.
|
|
188
|
+
*
|
|
189
|
+
* There is **no default cap** — a plan with three stages, three candidates and
|
|
190
|
+
* two defect retries can legally wipe the screen more than twenty times, and
|
|
191
|
+
* that is worth bounding, but the tolerable number is a product judgement
|
|
192
|
+
* about flicker that this module cannot make for a host. Guessing one would
|
|
193
|
+
* break a host for whom a rare double-wipe is entirely fine.
|
|
194
|
+
*
|
|
195
|
+
* Because the value gates routing, it also shapes how many endpoints record a
|
|
196
|
+
* failure against the circuit breaker for one user turn. That is a real
|
|
197
|
+
* coupling between a UI judgement and shared telemetry; it is the price of
|
|
198
|
+
* letting the UI decide.
|
|
199
|
+
*/
|
|
200
|
+
readonly maxResets?: number | undefined;
|
|
201
|
+
}
|
|
202
|
+
export interface TurnTextStream {
|
|
203
|
+
/**
|
|
204
|
+
* Open a new attempt. Call this at the **top of every attempt**, including the
|
|
205
|
+
* first — that is once per `AttemptFn` invocation, which covers same-endpoint
|
|
206
|
+
* defect retries, provider traversal and model fallback in one place, plus
|
|
207
|
+
* once per structured-output retry, which happens outside the executor and is
|
|
208
|
+
* the caller's loop to instrument.
|
|
209
|
+
*
|
|
210
|
+
* On any attempt following one that produced text, this arms a reset; the
|
|
211
|
+
* reset itself is emitted lazily, when that attempt first produces text (or by
|
|
212
|
+
* {@link finish}, if it never does).
|
|
213
|
+
*
|
|
214
|
+
* An explicitly passed `reason` **sticks** until the reset is delivered, and a
|
|
215
|
+
* later call that omits one will not overwrite it. Without that, the
|
|
216
|
+
* structured-output arm of {@link TurnResetReason} would be unreachable in the
|
|
217
|
+
* composition this module prescribes: the caller's retry loop opens the
|
|
218
|
+
* attempt with a reason, then re-enters the executor, whose `AttemptFn` opens
|
|
219
|
+
* the same pending reset again with the default.
|
|
220
|
+
*/
|
|
221
|
+
beginAttempt(reason?: TurnResetReason): void;
|
|
222
|
+
/**
|
|
223
|
+
* Forward one content delta. `null`, `undefined` and `""` are no-ops and do
|
|
224
|
+
* not count as output — a provider sending an empty content field has shown
|
|
225
|
+
* the user nothing.
|
|
226
|
+
*/
|
|
227
|
+
observe(text: string | null | undefined): void;
|
|
228
|
+
/**
|
|
229
|
+
* End the turn. **Call it however the turn ended**, including on the success
|
|
230
|
+
* path and on a throw — and tell it which, because the two do opposite things
|
|
231
|
+
* with a reset that is still armed.
|
|
232
|
+
*
|
|
233
|
+
* - `"succeeded"` **flushes** it. An attempt streamed text and failed, the
|
|
234
|
+
* retry succeeded with tool calls and no text at all, so nothing triggered
|
|
235
|
+
* the lazy reset. Without the flush the failed attempt's narration stays on
|
|
236
|
+
* the surface attributed to a turn that never said it, and vanishes only on
|
|
237
|
+
* reload. `content: null` plus `tool_calls` is the ordinary shape of an
|
|
238
|
+
* agent turn, so this is the common case rather than an edge.
|
|
239
|
+
* - `"failed"` **drops** it. Every attempt failed, and the last one died
|
|
240
|
+
* before producing a byte. Flushing here would wipe the screen to show
|
|
241
|
+
* nothing — the reader would get a blank space plus an error where they
|
|
242
|
+
* could have had partial text plus an error, which is the same trade the
|
|
243
|
+
* lazy reset exists to make and must not be undone at the finish line.
|
|
244
|
+
*
|
|
245
|
+
* Idempotent. Afterwards every method is a no-op.
|
|
246
|
+
*/
|
|
247
|
+
finish(outcome: "succeeded" | "failed"): void;
|
|
248
|
+
/**
|
|
249
|
+
* Latch {@link producedOutput} for a reason this module cannot see — a
|
|
250
|
+
* provider-side effect, a write a replay would repeat, a second surface the
|
|
251
|
+
* host wrote to itself. Irreversible, and a no-op after {@link finish}: the
|
|
252
|
+
* flag exists for the executor to read on a failure outcome, and once the turn
|
|
253
|
+
* is over there is no outcome left to clamp.
|
|
254
|
+
*
|
|
255
|
+
* It does **not** stop emission. If the attempt that reported the effect goes
|
|
256
|
+
* on to succeed, its text is still the user's answer and must reach them; if
|
|
257
|
+
* it fails, the clamp has already told the executor to stop, so there is no
|
|
258
|
+
* later attempt to suppress.
|
|
259
|
+
*
|
|
260
|
+
* This is the seam for a host migrating off a hand-rolled "any byte arrived"
|
|
261
|
+
* flag. Keep the flag for the effects it genuinely tracks and call this;
|
|
262
|
+
* do not widen {@link observe}.
|
|
263
|
+
*/
|
|
264
|
+
markProducedOutput(): void;
|
|
265
|
+
/**
|
|
266
|
+
* Whether output has reached a surface the caller **cannot take back**, which
|
|
267
|
+
* is exactly the flag the routing executor clamps on. Put it straight onto the
|
|
268
|
+
* failure outcome:
|
|
269
|
+
*
|
|
270
|
+
* ```ts
|
|
271
|
+
* return { kind: "failure", error, producedOutput: stream.producedOutput };
|
|
272
|
+
* ```
|
|
273
|
+
*
|
|
274
|
+
* For a retractable sink this stays `false` while a wipe is still available,
|
|
275
|
+
* so routing keeps its whole fallback chain. It latches `true` when the reset
|
|
276
|
+
* budget is spent, when a reset failed to reach the sink, or when the host
|
|
277
|
+
* reports an out-of-band effect. Monotonic: never true then false.
|
|
278
|
+
*
|
|
279
|
+
* **It answers "may routing replay this turn", not "is the reader looking at
|
|
280
|
+
* output".** The executor only reads it on a failure outcome, so the two
|
|
281
|
+
* questions never diverge where it is consumed — but they do diverge off-label:
|
|
282
|
+
* a turn that spent its flicker budget and then *succeeded* reads `true` even
|
|
283
|
+
* though the text it emitted was retracted. Do not source a "did the user see
|
|
284
|
+
* anything" metric from this.
|
|
285
|
+
*/
|
|
286
|
+
readonly producedOutput: boolean;
|
|
287
|
+
/** The current attempt's epoch. `-1` before the first {@link beginAttempt}. */
|
|
288
|
+
readonly epoch: number;
|
|
289
|
+
/**
|
|
290
|
+
* Resets *handed to the sink* so far. A reset whose `emit` threw is counted —
|
|
291
|
+
* it consumed the flicker budget, and whether the client saw it is exactly
|
|
292
|
+
* what this module cannot know. See {@link sinkErrors}.
|
|
293
|
+
*/
|
|
294
|
+
readonly resetCount: number;
|
|
295
|
+
/**
|
|
296
|
+
* Events whose `emit` threw. Emission continues after a throw rather than
|
|
297
|
+
* tearing down — a momentarily closed socket should not end a model turn that
|
|
298
|
+
* is otherwise fine.
|
|
299
|
+
*
|
|
300
|
+
* Two consequences. On a **non-retractable** surface a throw still counts as
|
|
301
|
+
* output: the sink got far enough to fail, and whether the bytes left first is
|
|
302
|
+
* not knowable from here, so the conservative reading is that they did. On a
|
|
303
|
+
* **retractable** one a failed *text* event leaves the turn replayable, but a
|
|
304
|
+
* failed *reset* does not — the wipe instruction is the one event whose loss
|
|
305
|
+
* cannot be repaired by sending more, so it latches
|
|
306
|
+
* {@link producedOutput} rather than letting a second epoch stack onto a
|
|
307
|
+
* surface that never cleared the first.
|
|
308
|
+
*/
|
|
309
|
+
readonly sinkErrors: number;
|
|
310
|
+
}
|
|
311
|
+
export declare function createTurnTextStream(options: TurnTextStreamOptions): TurnTextStream;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming a turn's assistant text to a live surface, safely across retries.
|
|
3
|
+
*
|
|
4
|
+
* Forwarding deltas is one line in a host's transport. What is not one line —
|
|
5
|
+
* and what every host that streams has to get right independently — is what
|
|
6
|
+
* happens when the attempt that produced those deltas *fails*. The routing
|
|
7
|
+
* executor's answer to a failure is to try again: same endpoint, then the next
|
|
8
|
+
* provider, then the fallback model. Each of those re-renders a turn the user is
|
|
9
|
+
* already reading.
|
|
10
|
+
*
|
|
11
|
+
* Until now the package's only answer was to refuse: a transport that had
|
|
12
|
+
* streamed set `producedOutput` on its failure outcome and the executor clamped
|
|
13
|
+
* to propagate-only (`propagateOnly`). That trades a recoverable failure for a
|
|
14
|
+
* visible one — a 500 on the first provider becomes an error the user sees,
|
|
15
|
+
* purely because the first token had left.
|
|
16
|
+
*
|
|
17
|
+
* There is a better answer whenever the surface can be *retracted*: tell it to
|
|
18
|
+
* discard what it has, then stream the retry cleanly. That is this module. It
|
|
19
|
+
* turns `producedOutput` from "did we emit" into what its own contract already
|
|
20
|
+
* said — **"did we emit somewhere the caller cannot take it back"** — and lets a
|
|
21
|
+
* retractable surface keep the whole fallback chain.
|
|
22
|
+
*
|
|
23
|
+
* ## Lifetime: one stream per turn, and you must call `finish()`
|
|
24
|
+
*
|
|
25
|
+
* Construct it in the function that owns the turn — outside the executor and
|
|
26
|
+
* outside any structured-output retry loop — and call {@link
|
|
27
|
+
* TurnTextStream.finish} when the turn is over, whichever way it ended.
|
|
28
|
+
*
|
|
29
|
+
* Both halves are load-bearing and neither is enforceable from in here. A stream
|
|
30
|
+
* built *inside* the `AttemptFn` never sees a second attempt, so it never resets
|
|
31
|
+
* and `producedOutput` is never true; the single-attempt path — almost all
|
|
32
|
+
* traffic — looks identical to a correct one, and the bug appears only under
|
|
33
|
+
* fallback, as two partial answers glued together. Skipping `finish()` leaves an
|
|
34
|
+
* armed reset undelivered whenever the attempt that *succeeds* emits no text,
|
|
35
|
+
* which is the ordinary shape of a tool-calling turn (`content: null` plus
|
|
36
|
+
* `tool_calls`): the discarded attempt's narration then stays on screen as if it
|
|
37
|
+
* belonged to the turn that replaced it.
|
|
38
|
+
*
|
|
39
|
+
* ## What the host still owns
|
|
40
|
+
*
|
|
41
|
+
* The sink, and the judgement of what goes into it. In particular **reasoning
|
|
42
|
+
* deltas are not decided here**: whether a model's thinking counts as output the
|
|
43
|
+
* user has seen is a product call, and the host expresses it by choosing what it
|
|
44
|
+
* passes to {@link TurnTextStream.observe}.
|
|
45
|
+
*
|
|
46
|
+
* **Tool-call deltas are deliberately not output.** It is tempting to latch on
|
|
47
|
+
* the first byte of any kind, and a host streaming raw chunks may already do
|
|
48
|
+
* that. But a tool call is not a side effect until it is *dispatched*, which
|
|
49
|
+
* happens after the turn completes — so a turn that dies mid-stream having
|
|
50
|
+
* emitted only tool-call bytes has changed nothing the user or a third party can
|
|
51
|
+
* see, and refusing to retry it forfeits a fallback for free. A host with a
|
|
52
|
+
* genuine mid-attempt effect of its own says so with
|
|
53
|
+
* {@link TurnTextStream.markProducedOutput} rather than by widening what counts
|
|
54
|
+
* as text.
|
|
55
|
+
*
|
|
56
|
+
* **`onAssistantMessage` on the turn kernel is a different surface.** It fires
|
|
57
|
+
* once per completed assistant message; these events stream one attempt of one
|
|
58
|
+
* message. Wiring both to the same UI element delivers the same text twice. The
|
|
59
|
+
* division that works: deltas drive the live view, `onAssistantMessage` drives
|
|
60
|
+
* the permanent record (the transcript row, the notification, the third-party
|
|
61
|
+
* post). If they must share one element, treat the persisted message as
|
|
62
|
+
* authoritative and let it supersede the streamed epochs for its `turnId`.
|
|
63
|
+
*
|
|
64
|
+
* ## The race this exists to make survivable
|
|
65
|
+
*
|
|
66
|
+
* On any real transport — a WebSocket through a Durable Object, an SSE relay, a
|
|
67
|
+
* fan-out to several tabs — an in-flight delta from attempt 1 can be *delivered
|
|
68
|
+
* after* attempt 2's reset. A client that renders every text event it receives
|
|
69
|
+
* will then show the wiped attempt's tail glued onto the retry. Every event
|
|
70
|
+
* therefore carries a `turnId`, an `epoch` (which attempt produced it) and a
|
|
71
|
+
* `seq` (monotonic within the turn), and a correct client uses all three:
|
|
72
|
+
*
|
|
73
|
+
* - scope everything to `turnId` — `epoch` and `seq` both restart each turn, so
|
|
74
|
+
* a client that carried "newest epoch" across turns drops every event after
|
|
75
|
+
* the first turn that retried, and a later turn's reset tells it to wipe an
|
|
76
|
+
* earlier, committed message;
|
|
77
|
+
* - **apply events in `seq` order**: drop anything at or below the last `seq`
|
|
78
|
+
* applied, and hold anything that arrives ahead of it until the gap fills;
|
|
79
|
+
* - then drop any event whose `epoch` is older than the newest seen, and clear
|
|
80
|
+
* what is rendered on a `reset`.
|
|
81
|
+
*
|
|
82
|
+
* The `seq` step is not optional decoration on the `epoch` step — it is what
|
|
83
|
+
* makes the epoch step *sound*. Ordering by epoch alone loses a same-epoch
|
|
84
|
+
* reorder: a retry's text arriving before its own reset is accepted and then
|
|
85
|
+
* cleared by the reset that follows, leaving the surface blank. A transport that
|
|
86
|
+
* already guarantees ordered exactly-once delivery collapses the `seq` step to a
|
|
87
|
+
* no-op, but that is a property to assert deliberately rather than assume.
|
|
88
|
+
*
|
|
89
|
+
* ## Why the reset is lazy
|
|
90
|
+
*
|
|
91
|
+
* It fires on the retry's **first byte**, not when the previous attempt failed.
|
|
92
|
+
* A retry that dies before producing anything — no viable endpoint, an immediate
|
|
93
|
+
* 401 — would otherwise have wiped the screen to show nothing. Partial text plus
|
|
94
|
+
* an error is strictly more useful to a reader than a blank space plus an error.
|
|
95
|
+
* A turn where no attempt ever emits therefore emits no events at all.
|
|
96
|
+
*
|
|
97
|
+
* The one thing that must not be lazy is a reset still armed when the turn ends
|
|
98
|
+
* — hence `finish()`, which flushes it. By then the text it retracts is known to
|
|
99
|
+
* have come from an attempt that was thrown away.
|
|
100
|
+
*/
|
|
101
|
+
export function createTurnTextStream(options) {
|
|
102
|
+
const { sink, turnId } = options;
|
|
103
|
+
const maxResets = options.maxResets;
|
|
104
|
+
if (maxResets !== undefined &&
|
|
105
|
+
(!Number.isInteger(maxResets) || maxResets < 0)) {
|
|
106
|
+
// `NaN` compares false against everything, so an unvalidated non-finite cap
|
|
107
|
+
// reads as "unbounded" — the exact opposite of the safe failure, on the one
|
|
108
|
+
// option whose whole purpose is to bound something.
|
|
109
|
+
throw new TypeError(`createTurnTextStream: maxResets must be a non-negative integer, got ${String(maxResets)}`);
|
|
110
|
+
}
|
|
111
|
+
if (typeof turnId !== "string" || turnId === "") {
|
|
112
|
+
// Every plausible way to get here — `turnId: obj?.id ?? ""` — collapses
|
|
113
|
+
// every turn of a run into one scope on the client, which is the bug the
|
|
114
|
+
// field was added to prevent.
|
|
115
|
+
throw new TypeError(`createTurnTextStream: turnId must be a non-empty string, got ${JSON.stringify(turnId)}`);
|
|
116
|
+
}
|
|
117
|
+
// Snapshotted, never re-read — see `TurnStreamSink.retractable`.
|
|
118
|
+
const retractable = sink.retractable;
|
|
119
|
+
let epoch = -1;
|
|
120
|
+
let seq = 0;
|
|
121
|
+
let resetCount = 0;
|
|
122
|
+
let sinkErrors = 0;
|
|
123
|
+
let finished = false;
|
|
124
|
+
// Text has reached the sink at least once this turn. Distinct from
|
|
125
|
+
// `producedOutput`, which additionally asks whether it can be taken back.
|
|
126
|
+
let emitted = false;
|
|
127
|
+
// Held rather than sent immediately so an attempt that dies before producing
|
|
128
|
+
// anything leaves the previous text on screen.
|
|
129
|
+
let resetPending = null;
|
|
130
|
+
// The surface is showing an epoch it will never be told to clear — either a
|
|
131
|
+
// reset was refused, or the flicker budget ran out before one could be armed.
|
|
132
|
+
// Both mean the same thing and must behave the same way: stop writing to it.
|
|
133
|
+
// They did not, and the asymmetry spliced two answers together — the budget
|
|
134
|
+
// path armed no reset and then happily appended the next attempt's text on top
|
|
135
|
+
// of the abandoned one.
|
|
136
|
+
let staleSurface = false;
|
|
137
|
+
// Whether a reset was EVER lost. Separate from `staleSurface` because that one
|
|
138
|
+
// clears when a later reset lands, and `producedOutput` must not un-latch.
|
|
139
|
+
let everLostReset = false;
|
|
140
|
+
let externalEffect = false;
|
|
141
|
+
const budgetSpent = () => maxResets !== undefined && resetCount >= maxResets;
|
|
142
|
+
const deliver = (event) => {
|
|
143
|
+
try {
|
|
144
|
+
// Frozen because the type says `Readonly` and a sink that fans out to two
|
|
145
|
+
// consumers would otherwise let the first mutate what the second sees.
|
|
146
|
+
sink.emit(Object.freeze(event));
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
sinkErrors += 1;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const flushReset = () => {
|
|
155
|
+
const reason = resetPending;
|
|
156
|
+
if (reason === null)
|
|
157
|
+
return;
|
|
158
|
+
resetPending = null;
|
|
159
|
+
resetCount += 1;
|
|
160
|
+
const stamp = epoch;
|
|
161
|
+
if (deliver({ kind: "reset", turnId, epoch: stamp, seq: seq++, reason })) {
|
|
162
|
+
// The wipe landed, so whatever was stranded on the surface is gone and it
|
|
163
|
+
// is safe to write again.
|
|
164
|
+
staleSurface = false;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
staleSurface = true;
|
|
168
|
+
everLostReset = true;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
beginAttempt(reason) {
|
|
173
|
+
if (finished)
|
|
174
|
+
return;
|
|
175
|
+
// The epoch advances on every attempt, whether or not a reset is owed:
|
|
176
|
+
// it identifies the attempt, and a host correlating logs on it should not
|
|
177
|
+
// see it stall because an earlier attempt happened to stay silent.
|
|
178
|
+
epoch += 1;
|
|
179
|
+
// Nothing on screen yet, so nothing to retract.
|
|
180
|
+
if (!emitted)
|
|
181
|
+
return;
|
|
182
|
+
// A permanent surface cannot honour a retraction, and telling it to is
|
|
183
|
+
// worse than not: a defensive sink reads it as a protocol error and a
|
|
184
|
+
// naive one may delete a message it already posted. Its `producedOutput`
|
|
185
|
+
// latched on the first byte, so routing has already stopped.
|
|
186
|
+
if (!retractable)
|
|
187
|
+
return;
|
|
188
|
+
// No wipe left, so this attempt's text would land on top of the last one
|
|
189
|
+
// rather than replacing it. The executor has already been told to stop
|
|
190
|
+
// (`producedOutput` is true), so anything still driving attempts is a
|
|
191
|
+
// caller-owned loop — a structured-output retry — and appending its output
|
|
192
|
+
// to an abandoned answer is the splice this module exists to prevent.
|
|
193
|
+
if (budgetSpent()) {
|
|
194
|
+
staleSurface = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// An explicit reason outranks a later default — see `beginAttempt`. Spelt
|
|
198
|
+
// out rather than as a `??` chain: the chain reads as "reason wins" and
|
|
199
|
+
// hides that the middle term is what preserves an EARLIER explicit reason
|
|
200
|
+
// through a later defaulted call, which is the whole point.
|
|
201
|
+
if (reason !== undefined) {
|
|
202
|
+
resetPending = reason;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
resetPending ??= "attempt_failed";
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
observe(text) {
|
|
209
|
+
if (finished)
|
|
210
|
+
return;
|
|
211
|
+
// Checked before the empty-text short-circuit: this guard catches a host
|
|
212
|
+
// that never opened an attempt, and a host whose first chunk happens to be
|
|
213
|
+
// empty must not ship green and then produce indistinguishable retries.
|
|
214
|
+
if (epoch < 0) {
|
|
215
|
+
throw new Error("createTurnTextStream: observe() before beginAttempt() — every attempt must open one, or a retry cannot be told apart from the turn it replaces");
|
|
216
|
+
}
|
|
217
|
+
if (text === null || text === undefined || text === "")
|
|
218
|
+
return;
|
|
219
|
+
// Checked BEFORE the flush, not after: once the surface is stranded,
|
|
220
|
+
// sending it more — including another reset — only compounds the problem.
|
|
221
|
+
// A reset delivered now would clear text the reader is relying on and then
|
|
222
|
+
// be followed by nothing, leaving them with a blank message on a turn that
|
|
223
|
+
// may well succeed. One coherent (if stale) answer beats both a spliced
|
|
224
|
+
// one and an empty one; `sinkErrors` is the host's cue to force a resync.
|
|
225
|
+
if (staleSurface)
|
|
226
|
+
return;
|
|
227
|
+
flushReset();
|
|
228
|
+
// Re-checked: the flush above may have just failed, in which case the
|
|
229
|
+
// surface still shows the epoch it was told to clear and this attempt's
|
|
230
|
+
// text would be glued onto it.
|
|
231
|
+
if (staleSurface)
|
|
232
|
+
return;
|
|
233
|
+
emitted = true;
|
|
234
|
+
// Stamped before `deliver`, so a re-entrant sink that opens another
|
|
235
|
+
// attempt from inside `emit` cannot back-date this delta to a newer one.
|
|
236
|
+
const stamp = epoch;
|
|
237
|
+
deliver({ kind: "text", turnId, epoch: stamp, seq: seq++, text });
|
|
238
|
+
},
|
|
239
|
+
finish(outcome) {
|
|
240
|
+
if (finished)
|
|
241
|
+
return;
|
|
242
|
+
finished = true;
|
|
243
|
+
// A pending reset means an earlier attempt's text is still on screen. On
|
|
244
|
+
// success it has been superseded and must go; on failure it is the best
|
|
245
|
+
// thing the reader has, and wiping it is strictly worse than leaving it.
|
|
246
|
+
if (outcome === "succeeded")
|
|
247
|
+
flushReset();
|
|
248
|
+
else
|
|
249
|
+
resetPending = null;
|
|
250
|
+
},
|
|
251
|
+
markProducedOutput() {
|
|
252
|
+
if (finished)
|
|
253
|
+
return;
|
|
254
|
+
externalEffect = true;
|
|
255
|
+
},
|
|
256
|
+
get producedOutput() {
|
|
257
|
+
if (externalEffect)
|
|
258
|
+
return true;
|
|
259
|
+
if (!emitted)
|
|
260
|
+
return false;
|
|
261
|
+
return !retractable || everLostReset || budgetSpent();
|
|
262
|
+
},
|
|
263
|
+
get epoch() {
|
|
264
|
+
return epoch;
|
|
265
|
+
},
|
|
266
|
+
get resetCount() {
|
|
267
|
+
return resetCount;
|
|
268
|
+
},
|
|
269
|
+
get sinkErrors() {
|
|
270
|
+
return sinkErrors;
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
package/contracts/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateRun, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn.js";
|
|
1
|
+
export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateAuxiliarySpend, accumulateRun, type AuxiliarySpend, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn.js";
|
package/contracts/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateRun, } from "./turn.js";
|
|
1
|
+
export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateAuxiliarySpend, accumulateRun, } from "./turn.js";
|