@juno-ai/bind 7.0.0 → 9.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 CHANGED
@@ -29,6 +29,28 @@ constraints that will fail CI if you break them.
29
29
 
30
30
  ---
31
31
 
32
+ ## Changelog
33
+
34
+ ### Unreleased
35
+
36
+ **Added**
37
+
38
+ - `runToolCallsPooledByTool` accepts an optional `signal`, and
39
+ `AbortedToolCallError` / `ToolBatchOptions` are exported from
40
+ `@juno-ai/bind/run`. Additive — a caller that passes nothing is unchanged, and
41
+ a test pins that. A batch that *is* given one stops claiming queued calls once
42
+ it aborts, and those come back `rejected` with an `AbortedToolCallError`
43
+ (`kind: "not_run"` once the loop synthesizes them), so a caller relying on
44
+ every call always executing simply does not pass a signal.
45
+
46
+ - `createTurnTextStream` (`@juno-ai/bind/completion`) — streams a turn's
47
+ assistant text to a live surface and repairs it across routing retries. A
48
+ retractable surface keeps the whole fallback chain; a permanent one clamps as
49
+ before. See "How to stream tokens to a user without breaking fallback".
50
+ - `ToolLoopParams.signal` — bounds the tool batch. Without it the deadline and
51
+ cancellation ports are only consulted between iterations, so a budget that
52
+ expired during the model call still let the batch run its side effects.
53
+
32
54
  ## Explanation
33
55
 
34
56
  *Understanding-oriented. Read this to know why the package is shaped the way it
@@ -572,54 +594,160 @@ never happened.
572
594
 
573
595
  ### How to stream tokens to a user without breaking fallback
574
596
 
575
- Content never passes through this package `callModel` and `AttemptFn` are
576
- *your* functions, so forwarding deltas from inside them is already supported and
577
- needs nothing from here. What the package gives you is three granularities and
578
- one rule.
597
+ Three granularities reach you, and the third is the one with a retry problem.
579
598
 
580
599
  | You want | Use |
581
600
  |---|---|
582
601
  | Each assistant message as it lands | `onAssistantMessage(content)` on the loop — fires per message, including text emitted alongside tool calls, rather than batching at turn end |
583
602
  | A live "N tokens so far" indicator | `onOutputProgress` into `callModel` → `onProgressUpdate(outputTokens, toolCalls)` — a count, never content |
584
- | Individual tokens in a UI | Forward them yourself, inside `callModel` / `AttemptFn` |
603
+ | Individual tokens in a UI | `createTurnTextStream` from `@juno-ai/bind/completion`, driven from inside your `AttemptFn` |
585
604
 
586
- **The rule, if you take the third option: set `producedOutput: true` on the
587
- failure outcome the moment you have forwarded anything.** Routing will otherwise
588
- retry the same endpoint or traverse to another provider, and re-render a turn
589
- your user is already reading.
605
+ The problem the third one has: routing's answer to a mid-stream failure is to
606
+ try again same endpoint, next provider, fallback model and each of those
607
+ re-renders a turn your user is already reading. `createTurnTextStream` solves it
608
+ by asking one question about your surface. **Can it be told to discard what it
609
+ rendered?**
590
610
 
591
611
  ```ts
592
- attempt: async (candidate, cursor) => {
593
- let forwarded = false;
612
+ const stream = createTurnTextStream({
613
+ turnId: messageId,
614
+ sink: { retractable: true, emit: (event) => socket.send(JSON.stringify(event)) },
615
+ });
616
+
617
+ const attempt = async (candidate, cursor) => {
618
+ stream.beginAttempt(); // once per attempt, including the first
594
619
  try {
595
620
  const value = await callProvider(candidate, {
596
- onDelta: (text) => { forwarded = true; sendToClient(text); },
621
+ onDelta: (text) => stream.observe(text), // content only see below
597
622
  });
598
623
  return { kind: "success", value };
599
624
  } catch (error) {
600
- return { kind: "failure", error: classify(error, candidate, cursor), producedOutput: forwarded };
625
+ return {
626
+ kind: "failure",
627
+ error: classify(error, candidate, cursor),
628
+ producedOutput: stream.producedOutput, // never hand-rolled again
629
+ };
601
630
  }
602
- }
631
+ };
632
+
633
+ const result = await executeRoutePlan({ plan, attempt, breaker });
634
+ stream.finish(result.ok ? "succeeded" : "failed");
635
+ return result;
636
+ ```
637
+
638
+ Two lifetime rules, and neither is enforceable from inside the package:
639
+
640
+ **Construct it once per turn** — outside the executor and outside your
641
+ structured-output retry loop. One per *attempt* never sees a second attempt, so
642
+ it never resets and `producedOutput` is never true; the single-attempt path is
643
+ indistinguishable from correct, and the bug shows up only under fallback, as two
644
+ partial answers glued together.
645
+
646
+ **Always call `finish()`, and tell it how the turn ended.** A reset still armed
647
+ at the finish line means an earlier attempt's text is on screen, and the two
648
+ outcomes want opposite things. `finish("succeeded")` flushes it — the retry
649
+ succeeded with `content: null` plus tool calls, so nothing triggered the lazy
650
+ reset and that narration belongs to a turn that never said it.
651
+ `finish("failed")` drops it — every attempt failed, so the partial text is the
652
+ best thing the reader is going to get, and wiping it hands them a blank space
653
+ plus an error instead.
654
+
655
+ With `retractable: true`, `producedOutput` stays `false`, so the plan keeps
656
+ every stage. On the retry's first byte the sink receives
657
+ `{ kind: "reset", epoch, seq, reason }` and re-renders from scratch. With
658
+ `retractable: false` — a message already posted through a third-party API, an
659
+ email, a webhook, an append-only row — it latches on the first byte and routing
660
+ clamps to propagate-only, which is the old behaviour and the right one.
661
+
662
+ Your client needs three lines to be correct under a transport that can reorder or
663
+ duplicate, because an in-flight delta from attempt 1 can arrive *after* attempt
664
+ 2's reset and text alone cannot be told apart from stale text:
665
+
666
+ ```ts
667
+ // Per turn: { lastSeq: -1, newestEpoch: -1, rendered: "", held: new Map() }
668
+ // `lastSeq` starts at -1 — `seq` starts at 0, so seeding it to 0 silently drops
669
+ // the first delta of every turn, on the single-attempt path that is almost all
670
+ // traffic.
671
+ const turn = state.getOrCreate(event.turnId); // scope everything to the turn
672
+ if (event.seq <= turn.lastSeq) return; // replay or duplicate, drop
673
+ if (event.seq > turn.lastSeq + 1) return buffer(turn, event); // arrived early
674
+ turn.lastSeq = event.seq;
675
+ if (event.epoch < turn.newestEpoch) return; // stale attempt, drop
676
+ turn.newestEpoch = event.epoch;
677
+ if (event.kind === "reset") turn.rendered = "";
678
+ else turn.rendered += event.text;
679
+ drainBuffered(turn); // apply anything that was early
603
680
  ```
604
681
 
605
- This costs less fallback than it looks. Retriable failures split cleanly:
606
- connection refused, a 429, a 5xx at the header, a time-to-first-token stall —
607
- none has forwarded anything, so `forwarded` is false and traversal is
608
- unaffected. What the flag removes is the mid-stream retry, which was never safe
609
- to replay in the first place.
610
-
611
- Two edges are yours to decide, because the answer is a product judgement rather
612
- than a routing one:
613
-
614
- - **Structured-output retries need the same care.** `structuredOutputParses`
615
- retries a completion whose JSON will not parse but if you forwarded that
616
- JSON, the user has already seen it. Either withhold structured output from the
617
- stream, or clamp the retry.
618
- - **Reasoning deltas are ambiguous.** The watchdog gates its tight budget on the
619
- first *answer* token, so reasoning can flow well before one. A UI rendering
620
- "thinking…" has emitted *something*, but discarding it on retry usually
621
- confuses nobody. Decide whether reasoning counts as output for you; the
622
- package deliberately does not decide it.
682
+ Both keys are load-bearing and they do different jobs. **`seq`** is monotonic
683
+ within the turn and never restarts, so it is what makes the stream tolerant of a
684
+ transport that duplicates or reorders — drop anything at or below the last seq
685
+ applied, hold anything that arrives ahead of it. **`epoch`** identifies the
686
+ attempt, so it is what tells a *fresh* delta from a stale one after a reset.
687
+ Neither substitutes for the other: without `seq` a duplicated text event appends
688
+ twice and same-epoch chunks concatenate in arrival order; without `epoch` a
689
+ delta from the wiped attempt is indistinguishable from the retry's.
690
+
691
+ **Bound the hold buffer, and define when a turn ends.** The rule above holds an
692
+ early event until its gap fills and on a reconnect the gap never fills, because
693
+ the events that would have closed it were dropped. Left alone, the surface then
694
+ freezes on whatever the *retracted* attempt rendered, which is the exact outcome
695
+ this module exists to prevent, and the buffer grows one entry per token. So: cap
696
+ the buffer (a count or a timeout), and on overflow resync from the persisted
697
+ message rather than continuing to hold. For the same reason the client needs a
698
+ turn-final signal `onAssistantMessage`, or the persisted row landing at which
699
+ it drops the turn's state entirely. Nothing on this wire tells it; that is the
700
+ host's to define.
701
+
702
+ If your transport already guarantees ordered exactly-once delivery to the client
703
+ (a single WebSocket with no replay window, say), the `seq` half collapses to a
704
+ no-op and the two `epoch` lines are enough — but say so deliberately rather than
705
+ discovering it under load.
706
+
707
+ **In React**, the recipe above mutates in place. Dropped into a store as written,
708
+ `getSnapshot` returns an identity-stable object and `useSyncExternalStore` never
709
+ re-renders — the stream looks dead. Publish a fresh snapshot per applied event.
710
+ And key the rendered element by `turnId`, never by `epoch`: keying by epoch turns
711
+ every reset into an unmount, discarding focus, selection and scroll position when
712
+ the contract only ever needed a content update.
713
+
714
+ **`turnId` is not decoration either.** `epoch` and `seq` both restart each turn,
715
+ so a client that carried "newest epoch" across turns drops every event after the
716
+ first turn that retried, and a later turn's reset tells it to wipe an earlier,
717
+ committed message.
718
+
719
+ Three things stay yours:
720
+
721
+ - **Reasoning deltas.** The watchdog gates its tight budget on the first
722
+ *answer* token, so reasoning can flow well before one. Whether "thinking…"
723
+ counts as output the user has seen is a product call — express it by choosing
724
+ what you pass to `observe`.
725
+ - **Tool-call deltas are not output** and should not go through `observe`. A
726
+ tool call is not a side effect until it is *dispatched*, which happens after
727
+ the turn — so a turn that dies having streamed only tool-call bytes changed
728
+ nothing anyone can see, and clamping it forfeits a fallback for free. If you
729
+ have a genuine mid-attempt effect, call `stream.markProducedOutput()`.
730
+ - **How much flicker is acceptable.** A wipe is not only a flicker — it collapses
731
+ the message's height mid-stream, so an auto-scrolled transcript lurches, and it
732
+ destroys any text selection inside that message. If the body is an `aria-live`
733
+ region, every wipe re-announces the whole answer from the top; keep it
734
+ `aria-busy` while streaming and announce once at the end instead.
735
+ `maxResets` is opt-in with no default: a
736
+ plan with three stages, three candidates and two defect retries can legally
737
+ wipe the screen more than twenty times. Spending the budget latches
738
+ `producedOutput` so routing stops traversing — it does *not* stop emission,
739
+ because an attempt already in flight may be the one that succeeds and its
740
+ answer still has to reach the reader. Note the budget therefore also shapes how
741
+ many endpoints record a failure against the circuit breaker for one turn.
742
+
743
+ - **Which surface gets which callback.** `onAssistantMessage` on the loop fires
744
+ once per completed assistant message; these events stream one attempt of one
745
+ message. Wiring both to the same UI element delivers the text twice. Deltas
746
+ drive the live view; `onAssistantMessage` drives the permanent record.
747
+
748
+ Structured-output retries run in your loop, outside the executor, and get the
749
+ same treatment — open them with `stream.beginAttempt("structured_output_retry")`
750
+ so the user sees the re-ask replace the malformed JSON rather than follow it.
623
751
 
624
752
  ### How to map your transport errors onto the routing taxonomy
625
753
 
@@ -1455,17 +1583,22 @@ an oversight.
1455
1583
  (tokens and cost) while `ModelTurnResult` carries timings too. They converge
1456
1584
  when the loop folds `RunStats` directly; today a host that wants throughput
1457
1585
  metrics accumulates them alongside.
1458
- - **A streaming turn contract.** `callModel` reports an output-token estimate
1459
- mid-stream and `onAssistantMessage` fires per message, but the message itself
1460
- still arrives whole no token-level content crosses this boundary. Hosts
1461
- stream inside their own `callModel` / `AttemptFn` today
1462
- ([how-to](#how-to-stream-tokens-to-a-user-without-breaking-fallback)), which
1463
- works and keeps the package content-blind. Moving emission *in* would mean
1464
- owning the emit/retry interaction rather than delegating it: forwarding the
1465
- first delta would have to arm the `producedOutput` clamp automatically, and
1466
- the structured-output retry budget would need the same treatment. Deferred
1467
- because the host's transport is a better place for a hot-path content
1468
- callback, not because the design is unclear.
1586
+ - **A streaming turn contract.** Half of this landed:
1587
+ `createTurnTextStream` owns the emit/retry interaction for assistant *text*,
1588
+ arms `producedOutput` itself, and repairs a retractable surface between
1589
+ attempts ([how-to](#how-to-stream-tokens-to-a-user-without-breaking-fallback)).
1590
+ What has not landed is the *contract*: `TurnFn` still returns one finished
1591
+ `ModelTurnResult`, so `runToolLoop` cannot see the stream and a host must
1592
+ thread it through its own `AttemptFn`. A loop-level streaming turn — where the
1593
+ kernel wires the stream and the reset lands without host cooperation — needs
1594
+ `TurnFn` to grow a streaming variant, and that is a breaking change to the
1595
+ package's central type. Deferred for that reason, not for lack of a design.
1596
+ - **Streaming for tool calls and reasoning.** `createTurnTextStream` handles
1597
+ text only, deliberately. Reasoning is a product judgement the host expresses
1598
+ by what it forwards; tool-call deltas are not output at all until dispatch.
1599
+ Neither has a natural primitive yet, and a host that renders a tool call as it
1600
+ is being assembled reads `assembled()` off `createToolCallAccumulator`
1601
+ mid-stream today, which works.
1469
1602
 
1470
1603
  ---
1471
1604
 
@@ -2,3 +2,4 @@ export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHU
2
2
  export { detectCompletionDefect, expectsJsonOutput, jsonParses, structuredOutputParses, DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES, DEFAULT_COMPLETION_DEFECT_MAX_RETRIES, type AssembledCompletion, type CompletionDefect, type CompletionOutputs, type ResponseFormatShape, type StreamedToolCall, } from "./defects.js";
3
3
  export { parseToolCallArguments, toolCallArgumentsAbsent, type DispatchableToolCall, type ToolCallArguments, } from "./tool-calls.js";
4
4
  export { createToolCallAccumulator, type ToolCallDelta, type AssembledToolCall, type ToolCallAccumulator, } from "./stream-assembly.js";
5
+ export { createTurnTextStream, type TurnResetReason, type TurnStreamEvent, type TurnStreamSink, type TurnTextStream, type TurnTextStreamOptions, } from "./text-stream.js";
@@ -2,3 +2,4 @@ export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHU
2
2
  export { detectCompletionDefect, expectsJsonOutput, jsonParses, structuredOutputParses, DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES, DEFAULT_COMPLETION_DEFECT_MAX_RETRIES, } from "./defects.js";
3
3
  export { parseToolCallArguments, toolCallArgumentsAbsent, } from "./tool-calls.js";
4
4
  export { createToolCallAccumulator, } from "./stream-assembly.js";
5
+ export { createTurnTextStream, } from "./text-stream.js";
@@ -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
+ }
@@ -235,6 +235,20 @@ export interface ToolLoopParams {
235
235
  userId: string;
236
236
  content: string;
237
237
  }) => Promise<void> | void;
238
+ /**
239
+ * Bounds the tool batch. Combine the run's deadline with any cancellation
240
+ * signal (`deadline.withExternal(cancelSignal)`) and pass the result.
241
+ *
242
+ * Without it, `throwIfTimedOut` and `ensureNotCancelled` are only consulted
243
+ * between iterations, so a deadline that fires while the model is being
244
+ * called still lets the whole batch execute its side effects, and a hung tool
245
+ * holds the run open for as long as it runs. Those are throw-based ports and
246
+ * cannot express "stop claiming new work" to a pool already in flight — only
247
+ * a signal can.
248
+ *
249
+ * Optional so an existing host is unchanged until it opts in.
250
+ */
251
+ signal?: AbortSignal | undefined;
238
252
  /** Decide whether the live context (token count) needs auto-compaction. */
239
253
  needsCompaction?: (currentTokens: number) => boolean;
240
254
  /**
package/loop/tool-loop.js CHANGED
@@ -1,4 +1,4 @@
1
- import { runToolCallsPooledByTool } from "../run/tool-batch.js";
1
+ import { runToolCallsPooledByTool, AbortedToolCallError, } from "../run/tool-batch.js";
2
2
  /**
3
3
  * Repeatedly call the model and execute the tools it requests, until it stops
4
4
  * requesting them, a caller stops the loop, a tool suspends the run, or
@@ -13,7 +13,7 @@ import { runToolCallsPooledByTool } from "../run/tool-batch.js";
13
13
  * mid-loop, which a returned result could not provide until the run ended.
14
14
  */
15
15
  export async function runToolLoop(params) {
16
- const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, ensureNotCancelled, throwIfTimedOut, onStatus, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, } = params;
16
+ const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, ensureNotCancelled, throwIfTimedOut, onStatus, signal, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, } = params;
17
17
  // An observer must not be able to change control flow: a host logger that
18
18
  // throws while reporting a tool failure would otherwise turn a *reported*
19
19
  // failure into a fatal one, which is the opposite of what the report is for.
@@ -128,6 +128,31 @@ export async function runToolLoop(params) {
128
128
  for (let i = 0; i < toolCalls.length; i++) {
129
129
  const tc = toolCalls[i];
130
130
  if (runsSerially?.(tc)) {
131
+ // The serial phase runs before the pool and is a loop of its own, so it
132
+ // needs the same claim-time check — otherwise an aborted batch still
133
+ // executes every activation call ahead of the pool that refuses to.
134
+ if (signal?.aborted === true) {
135
+ const aborted = new AbortedToolCallError(tc.id);
136
+ // Routed through the same three seams as a pooled refusal — fatal
137
+ // classification, the rejection observer, and the synthesized answer.
138
+ // The serial phase used to do none of them, so one condition produced
139
+ // two behaviours depending on which half of the batch a call landed in.
140
+ if (isFatalToolError?.(aborted))
141
+ throw aborted;
142
+ reportRejection(tc.id, aborted);
143
+ outcomes[i] = {
144
+ toolMessage: {
145
+ role: "tool",
146
+ tool_call_id: tc.id,
147
+ content: JSON.stringify({
148
+ success: false,
149
+ kind: "not_run",
150
+ error: aborted.message,
151
+ }),
152
+ },
153
+ };
154
+ continue;
155
+ }
131
156
  // Mirror the concurrent batch's graceful error synthesis so a failing
132
157
  // activation call (transient network/DB/timeout) doesn't crash the
133
158
  // run — but let a fatal error propagate for an immediate abort.
@@ -166,7 +191,9 @@ export async function runToolLoop(params) {
166
191
  deferredIndices.push(i);
167
192
  deferredCalls.push(tc);
168
193
  }
169
- const settled = await runToolCallsPooledByTool(deferredCalls, runToolCall);
194
+ const settled = await runToolCallsPooledByTool(deferredCalls, runToolCall, {
195
+ signal,
196
+ });
170
197
  for (let j = 0; j < deferredCalls.length; j++) {
171
198
  const origIndex = deferredIndices[j];
172
199
  const call = deferredCalls[j];
@@ -188,6 +215,14 @@ export async function runToolLoop(params) {
188
215
  tool_call_id: call.id,
189
216
  content: JSON.stringify({
190
217
  success: false,
218
+ // A refused call is not a failed one, and the envelope alone
219
+ // cannot say so — `success: false` plus a sentence is exactly what
220
+ // a tool that ran and failed produces. The discriminant is what
221
+ // lets a model (or a host) tell them apart without matching on
222
+ // prose. See `ToolFailureKind`.
223
+ ...(settledResult.reason instanceof AbortedToolCallError
224
+ ? { kind: "not_run" }
225
+ : {}),
191
226
  error: settledResult.reason instanceof Error
192
227
  ? settledResult.reason.message
193
228
  : String(settledResult.reason),
@@ -233,6 +268,32 @@ export async function runToolLoop(params) {
233
268
  if (outcome.requestCompaction)
234
269
  compactionRequested = true;
235
270
  }
271
+ // An aborted batch ends the run HERE — after every refused call has been
272
+ // answered in the transcript (so nothing dangles), and before the suspend,
273
+ // compaction and persist arms below. Two reasons, and the second is the sharp one:
274
+ //
275
+ // - The transcript now contains a synthesized result for every refused
276
+ // call. Reaching `saveSession` durably records "these tools failed" for
277
+ // work that was never dispatched, and a resumed run reads that as fact —
278
+ // the same lie `isFatalToolError` already refuses to tell for a write it
279
+ // could not record. The suspend arm `break`s BEFORE the cancellation
280
+ // boundary below, so without this check that path persists it.
281
+ // - A host whose `signal` is not also reflected by `throwIfTimedOut` /
282
+ // `ensureNotCancelled` would otherwise refuse the batch, loop, and pay
283
+ // for another model call — every iteration to `maxIterations`.
284
+ //
285
+ // Delegating to the throw ports rather than throwing directly keeps the
286
+ // terminal error the host's to name (a timeout and a cancellation are
287
+ // different outcomes, and only the host knows which fired).
288
+ if (signal?.aborted === true) {
289
+ throwIfTimedOut?.();
290
+ await ensureNotCancelled?.();
291
+ // Neither port is required, and a host that wired only `signal` would
292
+ // otherwise refuse every batch and pay for a fresh model call each
293
+ // iteration until `maxIterations`. Break so the abort ends the run on its
294
+ // own, rather than only when some other port happens to agree.
295
+ break;
296
+ }
236
297
  // A tool asked to end the run (it scheduled its own resume, or recorded an
237
298
  // open call awaiting an answer). The tool results are already in
238
299
  // state.messages above; stop now so the run doesn't keep going. Mark it as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juno-ai/bind",
3
- "version": "7.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/plugins/tool.d.ts CHANGED
@@ -80,7 +80,7 @@ export interface ToolDef {
80
80
  * status, a retry decision — without matching on the error string, which is
81
81
  * brittle and locale-dependent. Plugins should set it explicitly.
82
82
  */
83
- export type ToolFailureKind = "authz" | "validation" | "not_found" | "conflict" | "external" | "system";
83
+ export type ToolFailureKind = "authz" | "validation" | "not_found" | "conflict" | "external" | "not_run" | "system";
84
84
  /**
85
85
  * Human-in-the-loop suspend directive. A first-party tool returns this on a
86
86
  * successful result to **end the run** and record its open tool-call as
package/run/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, type RunDeadline, type CoalescedHeartbeat, } from "./harness.js";
2
- export { runToolCallsPooledByTool } from "./tool-batch.js";
2
+ export { runToolCallsPooledByTool, AbortedToolCallError, ABORTED_TOOL_CALL_MESSAGE, type ToolBatchOptions, } from "./tool-batch.js";
3
3
  export { rootChain, descendChain, admitChildRun, createPollSchedule, type ChainRef, type ChainRule, type ChildAdmission, type PollStep, type PollSchedule, type PollScheduleOptions, } from "./children.js";
4
4
  export { toolCallReceiptKeyString, canonicalJson, toolCallArgsHash, decideToolCallReceipt, type ToolCallReceiptKey, type DigestFn, type ReceiptState, type EffectResumability, type ReceiptDecision, } from "./receipts.js";
package/run/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, } from "./harness.js";
2
- export { runToolCallsPooledByTool } from "./tool-batch.js";
2
+ export { runToolCallsPooledByTool, AbortedToolCallError, ABORTED_TOOL_CALL_MESSAGE, } from "./tool-batch.js";
3
3
  export { rootChain, descendChain, admitChildRun, createPollSchedule, } from "./children.js";
4
4
  export { toolCallReceiptKeyString, canonicalJson, toolCallArgsHash, decideToolCallReceipt, } from "./receipts.js";
@@ -12,5 +12,55 @@ import type OpenAI from "openai";
12
12
  * harness-owned, while the shape a host derives from a completed call (which
13
13
  * plugin activated, whether the run should compact, a suspend directive, …)
14
14
  * stays with the host.
15
+ *
16
+ * **`signal` bounds the batch, and bounding it is the point.** A batch of many
17
+ * calls to one tool runs five at a time, so the rest sit queued — and without a
18
+ * signal the pool claims every one of them however long ago the run's deadline
19
+ * fired or the user pressed stop. Each queued call is a side effect nobody
20
+ * wants any more: a write, an email, a third-party POST. Once the signal
21
+ * aborts, workers stop claiming new work.
22
+ *
23
+ * Calls that never ran still come back — as `rejected` with an
24
+ * {@link AbortedToolCallError} — because every `tool_call_id` in the assistant
25
+ * message needs a response either way, and a caller that received a shorter
26
+ * array than it passed would silently mispair the rest. A call already in
27
+ * flight is left alone: this cannot reach inside a host's tool, and cancelling
28
+ * one mid-write is the host's problem to solve with the same signal.
29
+ */
30
+ /**
31
+ * The message a refused call carries into the transcript, and therefore **into
32
+ * the model's next turn**. Shared by both refusal sites — the pool and the
33
+ * loop's serial phase — because to the only reader they are the same fact, and
34
+ * two literals for one fact drift.
35
+ *
36
+ * Three things it deliberately does *not* do. It does not name the batch, the
37
+ * worker, or the claim: those are this module's internals, the model cannot act
38
+ * on them, and they appear nowhere else in its context. It does not interpolate
39
+ * the call id, which the `tool` message's own `tool_call_id` already carries —
40
+ * restating it spends the sentence's most-attended position on something the
41
+ * reader has. And it does not lead with the cause: the actionable fact is that
42
+ * *nothing happened*, and a model skimming a batch of results reads the first
43
+ * clause.
44
+ */
45
+ export declare const ABORTED_TOOL_CALL_MESSAGE: string;
46
+ /**
47
+ * A call the batch refused because it was aborted before any worker claimed it.
48
+ *
49
+ * Carries the id as a **field** rather than in the message, so a host's
50
+ * rejection observer and operator log can filter on it (`CLAUDE.md`: identifying
51
+ * context is a field, not string interpolation).
15
52
  */
16
- export declare function runToolCallsPooledByTool<TOutcome>(calls: OpenAI.ChatCompletionMessageToolCall[], run: (tc: OpenAI.ChatCompletionMessageToolCall) => Promise<TOutcome>): Promise<PromiseSettledResult<TOutcome>[]>;
53
+ export declare class AbortedToolCallError extends Error {
54
+ readonly toolCallId: string;
55
+ readonly name = "AbortedToolCallError";
56
+ constructor(toolCallId: string);
57
+ }
58
+ export interface ToolBatchOptions {
59
+ /**
60
+ * Stop claiming queued calls once this aborts. Combine a run deadline with a
61
+ * cancellation signal (`AbortSignal.any`) before passing it — the pool does
62
+ * not care which one fired, only that no further work should start.
63
+ */
64
+ readonly signal?: AbortSignal | undefined;
65
+ }
66
+ export declare function runToolCallsPooledByTool<TOutcome>(calls: OpenAI.ChatCompletionMessageToolCall[], run: (tc: OpenAI.ChatCompletionMessageToolCall) => Promise<TOutcome>, options?: ToolBatchOptions): Promise<PromiseSettledResult<TOutcome>[]>;
package/run/tool-batch.js CHANGED
@@ -36,10 +36,57 @@ function poolKey(call) {
36
36
  * harness-owned, while the shape a host derives from a completed call (which
37
37
  * plugin activated, whether the run should compact, a suspend directive, …)
38
38
  * stays with the host.
39
+ *
40
+ * **`signal` bounds the batch, and bounding it is the point.** A batch of many
41
+ * calls to one tool runs five at a time, so the rest sit queued — and without a
42
+ * signal the pool claims every one of them however long ago the run's deadline
43
+ * fired or the user pressed stop. Each queued call is a side effect nobody
44
+ * wants any more: a write, an email, a third-party POST. Once the signal
45
+ * aborts, workers stop claiming new work.
46
+ *
47
+ * Calls that never ran still come back — as `rejected` with an
48
+ * {@link AbortedToolCallError} — because every `tool_call_id` in the assistant
49
+ * message needs a response either way, and a caller that received a shorter
50
+ * array than it passed would silently mispair the rest. A call already in
51
+ * flight is left alone: this cannot reach inside a host's tool, and cancelling
52
+ * one mid-write is the host's problem to solve with the same signal.
53
+ */
54
+ /**
55
+ * The message a refused call carries into the transcript, and therefore **into
56
+ * the model's next turn**. Shared by both refusal sites — the pool and the
57
+ * loop's serial phase — because to the only reader they are the same fact, and
58
+ * two literals for one fact drift.
59
+ *
60
+ * Three things it deliberately does *not* do. It does not name the batch, the
61
+ * worker, or the claim: those are this module's internals, the model cannot act
62
+ * on them, and they appear nowhere else in its context. It does not interpolate
63
+ * the call id, which the `tool` message's own `tool_call_id` already carries —
64
+ * restating it spends the sentence's most-attended position on something the
65
+ * reader has. And it does not lead with the cause: the actionable fact is that
66
+ * *nothing happened*, and a model skimming a batch of results reads the first
67
+ * clause.
39
68
  */
40
- export async function runToolCallsPooledByTool(calls, run) {
69
+ export const ABORTED_TOOL_CALL_MESSAGE = "This call was never started, so nothing happened and nothing changed. " +
70
+ "The run is ending — do not retry it and do not report it as a failure.";
71
+ /**
72
+ * A call the batch refused because it was aborted before any worker claimed it.
73
+ *
74
+ * Carries the id as a **field** rather than in the message, so a host's
75
+ * rejection observer and operator log can filter on it (`CLAUDE.md`: identifying
76
+ * context is a field, not string interpolation).
77
+ */
78
+ export class AbortedToolCallError extends Error {
79
+ toolCallId;
80
+ name = "AbortedToolCallError";
81
+ constructor(toolCallId) {
82
+ super(ABORTED_TOOL_CALL_MESSAGE);
83
+ this.toolCallId = toolCallId;
84
+ }
85
+ }
86
+ export async function runToolCallsPooledByTool(calls, run, options = {}) {
41
87
  if (calls.length === 0)
42
88
  return [];
89
+ const signal = options.signal;
43
90
  const results = new Array(calls.length);
44
91
  const groups = new Map();
45
92
  for (let i = 0; i < calls.length; i++) {
@@ -69,6 +116,17 @@ export async function runToolCallsPooledByTool(calls, run) {
69
116
  if (pos >= indices.length)
70
117
  return;
71
118
  const callIdx = indices[pos];
119
+ // Checked at claim time rather than before the loop: a batch that
120
+ // was fine when it started can be aborted while its first workers
121
+ // are in flight, and that is the ordinary case — the deadline fires
122
+ // or the user hits stop DURING the batch, not before it.
123
+ if (signal?.aborted === true) {
124
+ results[callIdx] = {
125
+ status: "rejected",
126
+ reason: new AbortedToolCallError(calls[callIdx].id),
127
+ };
128
+ continue;
129
+ }
72
130
  try {
73
131
  const value = await run(calls[callIdx]);
74
132
  results[callIdx] = { status: "fulfilled", value };