@juno-ai/bind 8.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 +176 -43
- 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/loop/tool-loop.d.ts +14 -0
- package/loop/tool-loop.js +64 -3
- package/package.json +1 -1
- 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/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
|
-
|
|
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 |
|
|
603
|
+
| Individual tokens in a UI | `createTurnTextStream` from `@juno-ai/bind/completion`, driven from inside your `AttemptFn` |
|
|
585
604
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
your
|
|
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
|
-
|
|
593
|
-
|
|
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) =>
|
|
621
|
+
onDelta: (text) => stream.observe(text), // content only — see below
|
|
597
622
|
});
|
|
598
623
|
return { kind: "success", value };
|
|
599
624
|
} catch (error) {
|
|
600
|
-
return {
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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.**
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
stream
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
|
package/completion/index.d.ts
CHANGED
|
@@ -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";
|
package/completion/index.js
CHANGED
|
@@ -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
|
+
}
|
package/loop/tool-loop.d.ts
CHANGED
|
@@ -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": "
|
|
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";
|
package/run/tool-batch.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|