@juno-ai/bind 6.0.0 → 8.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
@@ -463,6 +463,164 @@ a tool an array where it expects fields turns a clear failure here into a
463
463
  confusing one inside the tool, after any side effect it performs before its own
464
464
  validation.
465
465
 
466
+ ### How to assemble streamed tool calls
467
+
468
+ Providers send tool calls as indexed deltas, and the obvious accumulation loop
469
+ is wrong in four ways that all fail silently.
470
+
471
+ ```ts
472
+ import { createToolCallAccumulator } from "@juno-ai/bind/completion";
473
+
474
+ const toolCalls = createToolCallAccumulator();
475
+ for await (const chunk of stream) {
476
+ toolCalls.observe(chunk.choices?.[0]?.delta?.tool_calls);
477
+ }
478
+ const assembled = toolCalls.isEmpty ? undefined : toolCalls.assembled();
479
+ ```
480
+
481
+ Key by the provider's `index`, not arrival order — deltas for index 1 can
482
+ precede index 0, and pushing onto an array transposes the calls while leaving
483
+ both parseable. Read `id` on every delta, not just the first for a slot; a
484
+ late one is legal and a call without an id cannot be answered. Concatenate
485
+ `name` as well as `arguments` — providers split it, and assigning keeps only
486
+ the last fragment (`_file` from `read_file`), which surfaces as an unknown-tool
487
+ error naming a tool the model never asked for. Order by index at the end, since
488
+ `Map` iterates in insertion order.
489
+
490
+ ### How to keep a retry from repeating a tool's side effect
491
+
492
+ A durable runtime retries: a queue redelivers, a workflow step re-runs, a
493
+ reclaimed job starts the turn again. If the turn sent an email, the retry sends
494
+ a second one. A *receipt* is the row that lets the second attempt find out.
495
+
496
+ ```ts
497
+ import {
498
+ toolCallArgsHash, decideToolCallReceipt, type DigestFn,
499
+ } from "@juno-ai/bind/run";
500
+
501
+ const sha256: DigestFn = async (s) => {
502
+ const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
503
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
504
+ };
505
+
506
+ const key = { tenantId, runId, toolName, argsHash: await toolCallArgsHash(args, sha256) };
507
+ const decision = decideToolCallReceipt({
508
+ state: await store.read(key), // yours — see the shape below
509
+ effect: tool.resumable ? "resumable" : "opaque",
510
+ });
511
+
512
+ switch (decision.kind) {
513
+ case "replay": return (await store.read(key)).result;
514
+ case "wait": return retryLater(decision.reason);
515
+ case "ambiguous": return surfaceToAHuman(decision.reason);
516
+ case "execute": {
517
+ await store.claim(key, decision.attempt); // BEFORE executing
518
+ const result = await tool.execute(args);
519
+ await store.complete(key, decision.attempt, result);
520
+ return result;
521
+ }
522
+ }
523
+ ```
524
+
525
+ **Identity is content, not position.** The key is
526
+ `(tenant, run, tool, arguments)`. Keying by position — run + turn + index in the
527
+ batch — looks equivalent and is not: a retried turn is a *fresh completion*, so
528
+ above temperature zero the model may reorder the batch or ask for a different
529
+ tool at the same index. Position-keyed receipts then match calls that are not
530
+ the same call, replay one tool's result as another's, and skip the tool actually
531
+ requested. Content keying fails the other way, which is the safe way: a genuinely
532
+ new call finds no receipt and runs.
533
+
534
+ **The tenant is part of the key.** Without it the key cannot be partitioned or
535
+ relocated by tenant, and two tenants' runs are not guaranteed to share a database.
536
+
537
+ **Record before executing, not after.** A receipt claimed but never completed is
538
+ the evidence that a write *may* have landed. Record afterwards and that window is
539
+ invisible.
540
+
541
+ **"Claimed but not completed" is not automatically ambiguous** — this is the part
542
+ worth getting right. On any at-least-once substrate a lease expires whenever a
543
+ worker dies *or merely stalls*, which is routine. `decideToolCallReceipt` needs a
544
+ lease and an attempt counter to tell the three cases apart:
545
+
546
+ | Receipt state | Decision |
547
+ |---|---|
548
+ | absent | `execute`, attempt 1 |
549
+ | completed | `replay` |
550
+ | failed | `execute`, attempt n+1 |
551
+ | running, lease **live** | `wait` — another attempt owns it and is alive |
552
+ | running, lease **expired**, effect `resumable` | `execute`, attempt n+1 — reclaim |
553
+ | running, lease **expired**, effect `opaque` | `ambiguous` — refuse, surface it |
554
+
555
+ `resumable` means the host records each sub-operation as it completes, so a
556
+ reclaimed attempt skips what already happened. `opaque` is one indivisible
557
+ effect with no trail, where reclaiming cannot tell "never sent" from "sent, then
558
+ crashed".
559
+
560
+ Two obligations the package cannot enforce: **lease times must come from the
561
+ store's clock**, because application clocks drift enough to steal a live lease;
562
+ and **every write must gate on the attempt fence** (`status = 'running' AND
563
+ attempts = <mine>`), inside the same transaction as the side effect where the
564
+ store allows it — fencing only the completion write leaves a window where two
565
+ attempts both believe they own the call.
566
+
567
+ **Hash the raw parsed arguments**, not the output of a schema parse that coerced
568
+ types. `canonicalJson` throws rather than serializing a value it cannot
569
+ represent faithfully — a `Map`, a `Set`, a class instance, `NaN`, a cycle —
570
+ because a silent collision here reads as "same call" and skips a write that
571
+ never happened.
572
+
573
+ ### How to stream tokens to a user without breaking fallback
574
+
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.
579
+
580
+ | You want | Use |
581
+ |---|---|
582
+ | 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
+ | 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` |
585
+
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.
590
+
591
+ ```ts
592
+ attempt: async (candidate, cursor) => {
593
+ let forwarded = false;
594
+ try {
595
+ const value = await callProvider(candidate, {
596
+ onDelta: (text) => { forwarded = true; sendToClient(text); },
597
+ });
598
+ return { kind: "success", value };
599
+ } catch (error) {
600
+ return { kind: "failure", error: classify(error, candidate, cursor), producedOutput: forwarded };
601
+ }
602
+ }
603
+ ```
604
+
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.
623
+
466
624
  ### How to map your transport errors onto the routing taxonomy
467
625
 
468
626
  `failureDisposition` decides what the router does with a classified failure, but
@@ -643,10 +801,10 @@ keeps a consumer who only wants routing from pulling in the rest.
643
801
  | Import | Owns | Reach for it when |
644
802
  |---|---|---|
645
803
  | `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy and the classifier that maps your errors onto it, the plan executor, the circuit breaker, billing-basis arithmetic, config degradation | You call more than one provider or model, or you want retries and fallback governed by one table |
646
- | `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), completion-defect detection, the tool-call argument read (`parseToolCallArguments`), and the structured-output retry predicates | You read a streamed completion |
804
+ | `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), streamed tool-call assembly, completion-defect detection, the tool-call argument read (`parseToolCallArguments`), and the structured-output retry predicates | You read a streamed completion |
647
805
  | `@juno-ai/bind/contracts` | Turn vocabulary — `TurnFn`, `ModelTurnResult`, `StopReason`, `RunStats` and its folds | You want one seam between your loop and any LLM client, and comparable per-run metrics |
648
806
  | `@juno-ai/bind/loop` | `runToolLoop` — the iteration engine: model turn, two-phase tool batch, activation, compaction, interrupts, suspend | You want the agent loop itself, not just the pieces to build one |
649
- | `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
807
+ | `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff, tool-call receipts for retry-safe side effects | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
650
808
  | `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
651
809
  | `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
652
810
  | `@juno-ai/bind/plugins` | Tool/plugin vocabulary, the registry factory, progressive-disclosure activation | You have more tools than fit comfortably in one prompt |
@@ -663,6 +821,17 @@ fact (`aborted` / `completion_defect` / `network` / `http` + category);
663
821
  propagate. Putting routing logic in a transport is the one way to break the
664
822
  guarantee that identical inputs produce identical plans.
665
823
 
824
+ **Routing — an attempt that already produced output is never replayed.** Every
825
+ other input to a disposition is a property of the *error*; this one is a
826
+ property of the *attempt*, and only your transport knows it. A transport that
827
+ buffers the whole completion can always replay; one that forwards deltas to a
828
+ live view has already shown someone tokens, and retrying — on the same endpoint
829
+ or another provider — appends a second partial answer to what they are reading.
830
+ Set `producedOutput: true` on the failure outcome and the executor withholds all
831
+ traversal. **If you stream to a UI and do not set it, you have this bug.** The
832
+ breaker still records the failure: the endpoint really did fail, and hiding that
833
+ because it failed late is backwards.
834
+
666
835
  **Routing — a healthy endpoint must not be punished for a bad request.**
667
836
  Request-shaped rejections (a 400, a moderation refusal) traverse to another
668
837
  provider but record **no** breaker failure. Otherwise one caller's malformed or
@@ -1287,7 +1456,16 @@ an oversight.
1287
1456
  when the loop folds `RunStats` directly; today a host that wants throughput
1288
1457
  metrics accumulates them alongside.
1289
1458
  - **A streaming turn contract.** `callModel` reports an output-token estimate
1290
- mid-stream; the assistant message itself still arrives whole.
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.
1291
1469
 
1292
1470
  ---
1293
1471
 
@@ -1,3 +1,4 @@
1
1
  export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHUNK_MS, DEFAULT_MAX_CALL_DURATION_MS, type ChunkOutput, type StreamStall, type StreamWatchdog, type StreamWatchdogOptions, } from "./watchdog.js";
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
+ export { createToolCallAccumulator, type ToolCallDelta, type AssembledToolCall, type ToolCallAccumulator, } from "./stream-assembly.js";
@@ -1,3 +1,4 @@
1
1
  export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHUNK_MS, DEFAULT_MAX_CALL_DURATION_MS, } from "./watchdog.js";
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
+ export { createToolCallAccumulator, } from "./stream-assembly.js";
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Assembling streamed tool calls out of the deltas a provider sends.
3
+ *
4
+ * `detectCompletionDefect` and `parseToolCallArguments` both consume a
5
+ * *finished* tool call, which quietly assumes something turned chunks into one.
6
+ * That something is this module. Until now every host wrote it, and the loop
7
+ * looks trivial enough that it usually gets written from memory — which is the
8
+ * problem, because four of its details are wrong in the obvious version and all
9
+ * four fail silently:
10
+ *
11
+ * 1. **Key by `index`, not by arrival order.** Deltas for index 1 can arrive
12
+ * before index 0. Pushing onto an array in arrival order transposes the
13
+ * calls, and both still parse, so nothing errors — the model just gets the
14
+ * wrong tool's arguments.
15
+ * 2. **`id` can arrive in any chunk**, not necessarily the first for its
16
+ * index. Reading it only when the slot is created leaves the call with an
17
+ * empty id, and the `tool` message that answers it then pairs with nothing.
18
+ * 3. **`name` is concatenated, not assigned.** Providers split it across
19
+ * chunks. Assigning keeps only the last fragment (`_file` from
20
+ * `read_file`), which surfaces as an unknown-tool error naming a tool the
21
+ * model never asked for.
22
+ * 4. **Order the result by index at the end.** `Map` iterates in insertion
23
+ * order, which is arrival order, which is (1) again at the finish line.
24
+ *
25
+ * A fifth is this module's own to avoid: **a missing `index` is not a malformed
26
+ * one.** A provider that omits the field is describing a single call; one that
27
+ * sends `-1` or `"0"` is unintelligible. Dropping both loses a call the model
28
+ * asked for, and loses it silently, because the batch still comes back
29
+ * non-empty and defect detection sees nothing wrong.
30
+ *
31
+ * **The accumulator is unbounded by design and the host must bound the stream.**
32
+ * There is no cap on distinct indices or on accumulated argument bytes — a
33
+ * provider streaming either without limit will grow this `Map` until the
34
+ * process dies. That is the host's to prevent, with an inter-chunk watchdog
35
+ * (`createStreamWatchdog`), a completion-token cap, or a byte budget; a
36
+ * primitive that guessed a limit would break a legitimate large batch.
37
+ *
38
+ * Everything here is pure and structural. The host owns the transport — SSE
39
+ * framing, byte decoding, `[DONE]` — and passes the per-chunk delta arrays in.
40
+ */
41
+ /**
42
+ * One provider's tool-call delta. Structural rather than tied to an SDK: the
43
+ * fields are those the OpenAI streaming protocol defines, and every field but
44
+ * `index` is optional because a chunk may carry any subset of them.
45
+ */
46
+ export interface ToolCallDelta {
47
+ /**
48
+ * The call's position in the batch. Optional because a provider describing a
49
+ * single call may omit it — those fold into one slot rather than being
50
+ * dropped. A *present* but non-integer or negative value is malformed and is
51
+ * counted in {@link ToolCallAccumulator.droppedDeltas}.
52
+ */
53
+ readonly index?: number | undefined;
54
+ readonly id?: string | undefined;
55
+ /**
56
+ * Carried for structural compatibility and deliberately **not** read:
57
+ * `assembled()` always emits `"function"`. Every streamed tool call in the
58
+ * OpenAI protocol is a function call, and a non-function type
59
+ * (`type: "custom"`) does not arrive as a delta stream — `defects.ts` and
60
+ * `parseToolCallArguments` handle that shape on the assembled message. If a
61
+ * provider ever streams one, this is the line to revisit.
62
+ */
63
+ readonly type?: string | undefined;
64
+ readonly function?: {
65
+ readonly name?: string | undefined;
66
+ readonly arguments?: string | undefined;
67
+ } | undefined;
68
+ }
69
+ /** A tool call assembled from its deltas, ready to dispatch or inspect. */
70
+ export interface AssembledToolCall {
71
+ readonly id: string;
72
+ readonly type: "function";
73
+ readonly function: {
74
+ readonly name: string;
75
+ readonly arguments: string;
76
+ };
77
+ }
78
+ /**
79
+ * Accumulates tool-call deltas across the chunks of one completion.
80
+ *
81
+ * Stateful because a stream is: the host calls {@link observe} per chunk and
82
+ * {@link assembled} once at the end. One accumulator per completion — reusing
83
+ * one across two calls merges them.
84
+ */
85
+ export interface ToolCallAccumulator {
86
+ /** Fold one chunk's deltas in. Safe to call with an empty array. */
87
+ observe(deltas: readonly ToolCallDelta[] | undefined): void;
88
+ /** Whether any delta has been seen — cheaper than assembling to find out. */
89
+ readonly isEmpty: boolean;
90
+ /**
91
+ * Deltas discarded because their `index` was present but unusable — not an
92
+ * integer, or negative. Non-zero means the model asked for a call that is
93
+ * **not** in {@link assembled}, and nothing downstream can tell: the batch
94
+ * comes back non-empty, so defect detection sees no defect and no retry
95
+ * fires. Log it at minimum; a host that would rather fail the turn than
96
+ * answer a partial batch has to decide that here.
97
+ *
98
+ * A delta with **no** `index` is not counted — that is a provider describing
99
+ * a single call, and it is assembled rather than dropped.
100
+ */
101
+ readonly droppedDeltas: number;
102
+ /**
103
+ * The calls so far, ordered by the provider's `index`. Returns a fresh array
104
+ * each call and does not end accumulation, so it is safe to inspect mid-stream.
105
+ */
106
+ assembled(): AssembledToolCall[];
107
+ }
108
+ export declare function createToolCallAccumulator(): ToolCallAccumulator;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Assembling streamed tool calls out of the deltas a provider sends.
3
+ *
4
+ * `detectCompletionDefect` and `parseToolCallArguments` both consume a
5
+ * *finished* tool call, which quietly assumes something turned chunks into one.
6
+ * That something is this module. Until now every host wrote it, and the loop
7
+ * looks trivial enough that it usually gets written from memory — which is the
8
+ * problem, because four of its details are wrong in the obvious version and all
9
+ * four fail silently:
10
+ *
11
+ * 1. **Key by `index`, not by arrival order.** Deltas for index 1 can arrive
12
+ * before index 0. Pushing onto an array in arrival order transposes the
13
+ * calls, and both still parse, so nothing errors — the model just gets the
14
+ * wrong tool's arguments.
15
+ * 2. **`id` can arrive in any chunk**, not necessarily the first for its
16
+ * index. Reading it only when the slot is created leaves the call with an
17
+ * empty id, and the `tool` message that answers it then pairs with nothing.
18
+ * 3. **`name` is concatenated, not assigned.** Providers split it across
19
+ * chunks. Assigning keeps only the last fragment (`_file` from
20
+ * `read_file`), which surfaces as an unknown-tool error naming a tool the
21
+ * model never asked for.
22
+ * 4. **Order the result by index at the end.** `Map` iterates in insertion
23
+ * order, which is arrival order, which is (1) again at the finish line.
24
+ *
25
+ * A fifth is this module's own to avoid: **a missing `index` is not a malformed
26
+ * one.** A provider that omits the field is describing a single call; one that
27
+ * sends `-1` or `"0"` is unintelligible. Dropping both loses a call the model
28
+ * asked for, and loses it silently, because the batch still comes back
29
+ * non-empty and defect detection sees nothing wrong.
30
+ *
31
+ * **The accumulator is unbounded by design and the host must bound the stream.**
32
+ * There is no cap on distinct indices or on accumulated argument bytes — a
33
+ * provider streaming either without limit will grow this `Map` until the
34
+ * process dies. That is the host's to prevent, with an inter-chunk watchdog
35
+ * (`createStreamWatchdog`), a completion-token cap, or a byte budget; a
36
+ * primitive that guessed a limit would break a legitimate large batch.
37
+ *
38
+ * Everything here is pure and structural. The host owns the transport — SSE
39
+ * framing, byte decoding, `[DONE]` — and passes the per-chunk delta arrays in.
40
+ */
41
+ export function createToolCallAccumulator() {
42
+ const byIndex = new Map();
43
+ // Deltas carrying no `index` at all fold together here, in their OWN
44
+ // variable rather than a reserved key in `byIndex`. A sentinel index would
45
+ // share a keyspace with real ones — `Number.MAX_SAFE_INTEGER` is a legal
46
+ // index — and an explicit delta at that index would merge with the implicit
47
+ // call, concatenating two tools' names into one. That is footgun 3 of this
48
+ // module's own header, re-created by the fix for footgun 5.
49
+ //
50
+ // A missing index is distinct from a MALFORMED one and they must not be
51
+ // treated alike: a provider that omits the field is describing a single call
52
+ // and is intelligible, while one that sends `-1` or `"0"` is not.
53
+ let implicit = null;
54
+ let dropped = 0;
55
+ const upsert = (existing) => existing ?? { id: "", name: "", args: "" };
56
+ const fold = (call, delta) => {
57
+ // Read `id` on EVERY delta, not just the first: providers are free to send
58
+ // it late, and a call assembled without one cannot be answered.
59
+ if (typeof delta.id === "string" && delta.id !== "")
60
+ call.id = delta.id;
61
+ // Both are concatenated. `arguments` is obviously chunked; `name` is less
62
+ // obviously so, and assigning it keeps only the final fragment.
63
+ if (delta.function?.name)
64
+ call.name += delta.function.name;
65
+ if (delta.function?.arguments)
66
+ call.args += delta.function.arguments;
67
+ };
68
+ return {
69
+ observe(deltas) {
70
+ // `== null` rather than `=== undefined`: a transport that sets the field
71
+ // to `null` would otherwise reach `for...of` and throw.
72
+ if (deltas == null)
73
+ return;
74
+ for (const delta of deltas) {
75
+ const index = delta.index;
76
+ if (index === undefined || index === null) {
77
+ implicit = upsert(implicit);
78
+ fold(implicit, delta);
79
+ continue;
80
+ }
81
+ // A malformed index cannot be placed. Dropping loses that call;
82
+ // guessing a slot corrupts a different one, which is worse — a lost
83
+ // call reads as missing, a misfiled one reads as valid. The drop is
84
+ // counted rather than silent, because the host is the only layer that
85
+ // can decide whether a lost call is worth failing the turn over.
86
+ if (!Number.isInteger(index) || index < 0) {
87
+ dropped += 1;
88
+ continue;
89
+ }
90
+ const call = upsert(byIndex.get(index));
91
+ byIndex.set(index, call);
92
+ fold(call, delta);
93
+ }
94
+ },
95
+ get isEmpty() {
96
+ return byIndex.size === 0 && implicit === null;
97
+ },
98
+ get droppedDeltas() {
99
+ return dropped;
100
+ },
101
+ assembled() {
102
+ const ordered = [...byIndex.entries()]
103
+ .sort(([left], [right]) => left - right)
104
+ .map(([, call]) => call);
105
+ // The implicit call sorts last: it has no index to place it by, and a
106
+ // provider that mixes indexed and index-less deltas has told us nothing
107
+ // about where it belongs.
108
+ if (implicit !== null)
109
+ ordered.push(implicit);
110
+ return ordered.map((call) => ({
111
+ id: call.id,
112
+ type: "function",
113
+ function: { name: call.name, arguments: call.args },
114
+ }));
115
+ },
116
+ };
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juno-ai/bind",
3
- "version": "6.0.0",
3
+ "version": "8.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",
@@ -66,6 +66,26 @@ export interface FailureDisposition {
66
66
  /** Return to the caller immediately; no further traversal. */
67
67
  readonly propagate: boolean;
68
68
  }
69
+ /**
70
+ * The disposition for a failure whose attempt **already produced output the
71
+ * caller cannot take back** — tokens streamed to a UI, or a provider-side
72
+ * effect a replay would repeat.
73
+ *
74
+ * Every other input to `failureDisposition` is a property of the *error*. This
75
+ * one is a property of the *attempt*, and only the host knows it: a transport
76
+ * that buffers the whole completion before returning can always replay, and one
77
+ * that forwards deltas to a live view cannot. So it arrives as a fact on the
78
+ * failure outcome rather than as another arm of the taxonomy.
79
+ *
80
+ * Traversal is withheld entirely — not just the same-endpoint retry. Trying the
81
+ * next provider re-renders the same turn, which is the same duplication with a
82
+ * different label on it.
83
+ *
84
+ * The **breaker effect is preserved**: the endpoint really did fail, and that is
85
+ * true regardless of how far the response got. Suppressing it here would hide a
86
+ * dying endpoint from every later call precisely because it dies late.
87
+ */
88
+ export declare function propagateOnly(disposition: FailureDisposition): FailureDisposition;
69
89
  /**
70
90
  * The exhaustive failure → routing-behavior matrix (PRD §7.1). Pure; the
71
91
  * executor applies it, the circuit breaker consumes its `breaker` effect.
package/routing/errors.js CHANGED
@@ -39,6 +39,36 @@ const CREDENTIAL_FAILURE = Object.freeze({
39
39
  breaker: "open_immediately",
40
40
  propagate: false,
41
41
  });
42
+ /**
43
+ * The disposition for a failure whose attempt **already produced output the
44
+ * caller cannot take back** — tokens streamed to a UI, or a provider-side
45
+ * effect a replay would repeat.
46
+ *
47
+ * Every other input to `failureDisposition` is a property of the *error*. This
48
+ * one is a property of the *attempt*, and only the host knows it: a transport
49
+ * that buffers the whole completion before returning can always replay, and one
50
+ * that forwards deltas to a live view cannot. So it arrives as a fact on the
51
+ * failure outcome rather than as another arm of the taxonomy.
52
+ *
53
+ * Traversal is withheld entirely — not just the same-endpoint retry. Trying the
54
+ * next provider re-renders the same turn, which is the same duplication with a
55
+ * different label on it.
56
+ *
57
+ * The **breaker effect is preserved**: the endpoint really did fail, and that is
58
+ * true regardless of how far the response got. Suppressing it here would hide a
59
+ * dying endpoint from every later call precisely because it dies late.
60
+ */
61
+ export function propagateOnly(disposition) {
62
+ if (disposition.propagate)
63
+ return disposition;
64
+ return Object.freeze({
65
+ sameEndpointRetry: false,
66
+ nextProvider: false,
67
+ fallbackModel: false,
68
+ breaker: disposition.breaker,
69
+ propagate: true,
70
+ });
71
+ }
42
72
  /**
43
73
  * The exhaustive failure → routing-behavior matrix (PRD §7.1). Pure; the
44
74
  * executor applies it, the circuit breaker consumes its `breaker` effect.
@@ -7,6 +7,18 @@ export type AttemptOutcome<T> = Readonly<{
7
7
  }> | Readonly<{
8
8
  kind: "failure";
9
9
  error: InferenceAttemptError;
10
+ /**
11
+ * This attempt already put output somewhere the caller cannot take it
12
+ * back — tokens forwarded to a live view, or a provider-side effect a
13
+ * replay would repeat. The executor then **withholds all traversal** and
14
+ * propagates, because retrying re-renders a turn the user has partly seen.
15
+ *
16
+ * Omitted means replayable, which is right for a transport that buffers
17
+ * the whole completion before returning — it has shown nobody anything
18
+ * yet. Set it from the transport, at the point the first byte leaves:
19
+ * a boolean the transport flips when it forwards its first delta.
20
+ */
21
+ producedOutput?: boolean;
10
22
  }>;
11
23
  /**
12
24
  * One provider request. The host's transport adapter performs the network
@@ -1,4 +1,4 @@
1
- import { failureDisposition, isRetriableAttemptError, } from "./errors.js";
1
+ import { failureDisposition, propagateOnly, isRetriableAttemptError, } from "./errors.js";
2
2
  /**
3
3
  * Drive one structured-output attempt over a frozen route plan with the
4
4
  * normative loop nesting (PRD §5.2): model stage → provider candidate →
@@ -71,7 +71,12 @@ export async function executeRoutePlan(options) {
71
71
  };
72
72
  }
73
73
  failures.push(outcome.error);
74
- const disposition = failureDisposition(outcome.error);
74
+ // The clamp is applied to the taxonomy's answer rather than folded into
75
+ // it, so `failureDisposition` stays total over the error kinds and this
76
+ // stays one testable step. See `propagateOnly`.
77
+ const disposition = outcome.producedOutput === true
78
+ ? propagateOnly(failureDisposition(outcome.error))
79
+ : failureDisposition(outcome.error);
75
80
  switch (disposition.breaker) {
76
81
  case "none":
77
82
  // A breaker-invisible failure (abort, propagated client error)
@@ -6,7 +6,7 @@ export { buildRoutePlan, type RoutePlanRequest, type RoutePlanResult } from "./p
6
6
  export { buildRoutePlanWithConfigDegradation, type DegradedStage, type DegradedPlanResult, } from "./plan-degradation.js";
7
7
  export { computeConfiguredRatesCostCents, type BillingBasisUsage, type BillingBasisResult, } from "./billing-basis.js";
8
8
  export { fallbackKindOfCursor } from "./executor.js";
9
- export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, type RouteAttemptCursor, type AttemptTarget, type HttpFailureCategory, type InferenceAttemptError, type FailureDisposition, type BreakerEffect, } from "./errors.js";
9
+ export { failureDisposition, propagateOnly, categorizeHttpStatus, isRetriableAttemptError, type RouteAttemptCursor, type AttemptTarget, type HttpFailureCategory, type InferenceAttemptError, type FailureDisposition, type BreakerEffect, } from "./errors.js";
10
10
  export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, type AttemptClassification, type TransportFailure, type TransportFailureKind, } from "./attempt-errors.js";
11
11
  export { createCircuitBreaker, type BreakerKey, type CircuitBreakerOptions, type EndpointAdmission, type RecordFailureOptions, type RouteCircuitBreaker, } from "./circuit-breaker.js";
12
12
  export { executeRoutePlan, type AttemptOutcome, type AttemptFn, type FallbackKind, type ExecutePlanOptions, type RouteExecutionResult, } from "./executor.js";
package/routing/index.js CHANGED
@@ -6,7 +6,7 @@ export { buildRoutePlan } from "./planner.js";
6
6
  export { buildRoutePlanWithConfigDegradation, } from "./plan-degradation.js";
7
7
  export { computeConfiguredRatesCostCents, } from "./billing-basis.js";
8
8
  export { fallbackKindOfCursor } from "./executor.js";
9
- export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, } from "./errors.js";
9
+ export { failureDisposition, propagateOnly, categorizeHttpStatus, isRetriableAttemptError, } from "./errors.js";
10
10
  export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, } from "./attempt-errors.js";
11
11
  export { createCircuitBreaker, } from "./circuit-breaker.js";
12
12
  export { executeRoutePlan, } from "./executor.js";
package/run/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, type RunDeadline, type CoalescedHeartbeat, } from "./harness.js";
2
2
  export { runToolCallsPooledByTool } 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
+ export { toolCallReceiptKeyString, canonicalJson, toolCallArgsHash, decideToolCallReceipt, type ToolCallReceiptKey, type DigestFn, type ReceiptState, type EffectResumability, type ReceiptDecision, } from "./receipts.js";
package/run/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, } from "./harness.js";
2
2
  export { runToolCallsPooledByTool } from "./tool-batch.js";
3
3
  export { rootChain, descendChain, admitChildRun, createPollSchedule, } from "./children.js";
4
+ export { toolCallReceiptKeyString, canonicalJson, toolCallArgsHash, decideToolCallReceipt, } from "./receipts.js";
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Deciding whether a tool call has already happened, so a retry does not repeat
3
+ * its side effect.
4
+ *
5
+ * A durable runtime retries. A queue redelivers, a workflow step re-runs, a
6
+ * crashed worker's claim is reclaimed — and the agent loop starts the turn
7
+ * again. If the turn asked a tool to send an email, the retry sends a second
8
+ * one. A *receipt* is the row that lets the second attempt find out.
9
+ *
10
+ * Two things in that are the same for every host and belong here: **what
11
+ * identifies a call**, and **what to do about a receipt you found**. Everything
12
+ * else — the table, the clock, the transaction — is the host's, and this module
13
+ * deliberately owns none of it.
14
+ *
15
+ * ## Identity is content, not position
16
+ *
17
+ * A call is identified by `(tenant, run, tool, arguments)`. The tempting
18
+ * alternative is position — run + turn index + index within the batch — and it
19
+ * is wrong: **a retried turn is a fresh completion.** At any temperature above
20
+ * zero the model may reorder the batch, drop a call, or ask for a different
21
+ * tool at the same index. Position-keyed receipts then match calls that are not
22
+ * the same call, and the failure is the one this module exists to prevent: the
23
+ * first tool's recorded result is replayed as the second tool's, and the tool
24
+ * actually requested never runs.
25
+ *
26
+ * Content keying has the opposite failure mode, which is the safe one. A
27
+ * genuinely new call finds no receipt and executes; a repeated one finds its
28
+ * own. Nondeterminism costs an extra execution of something that was never run
29
+ * before, rather than a skipped execution of something that was.
30
+ *
31
+ * **The tenant is part of the key, not context.** A key without it cannot be
32
+ * partitioned or relocated by tenant, and two tenants' runs are not guaranteed
33
+ * to live in the same database.
34
+ *
35
+ * ## The decision needs a lease, not just a status
36
+ *
37
+ * "Recorded but not completed" does **not** mean "this may have reached the
38
+ * provider." On an at-least-once substrate it is also the ordinary state while
39
+ * another worker is *still running the call* — a queue that reclaims a wedged
40
+ * handler's claim can have two workers on one job by design. Treating that as
41
+ * ambiguous-and-never-repeat blocks the call permanently and needs a human.
42
+ *
43
+ * So the host records a **lease** and an **attempt counter**, and
44
+ * {@link decideToolCallReceipt} distinguishes the three cases a bare status
45
+ * cannot: someone else holds it and is alive (wait), someone else held it and
46
+ * died (reclaim, if the effect can resume), or it finished (replay).
47
+ *
48
+ * Two host obligations this module cannot enforce and a correct implementation
49
+ * needs:
50
+ *
51
+ * - **Lease times come from the store's clock, not the process's.** Application
52
+ * clocks drift enough to steal a live lease.
53
+ * - **Every write gates on the attempt fence** (`status = 'running' AND
54
+ * attempts = <mine>`), inside the same transaction as the side effect where
55
+ * the store allows it. Fencing only the completion write leaves the window
56
+ * where two attempts both believe they own the call.
57
+ *
58
+ * Monad's `agent_side_effect_receipts` is the worked example of all of the
59
+ * above; this module is the part of it that is not Postgres.
60
+ */
61
+ /**
62
+ * What identifies a tool call across retries.
63
+ *
64
+ * All four parts are required. Dropping `tenantId` makes the key unpartitionable
65
+ * and, in a multi-database deployment, ambiguous; dropping `toolName` makes any
66
+ * two zero-argument calls in a run identical, and `{}` is the commonest
67
+ * argument bag there is.
68
+ */
69
+ export interface ToolCallReceiptKey {
70
+ readonly tenantId: string;
71
+ readonly runId: string;
72
+ readonly toolName: string;
73
+ /** From {@link toolCallArgsHash}. */
74
+ readonly argsHash: string;
75
+ }
76
+ /**
77
+ * The key as one opaque string, for a store without composite keys (KV, a
78
+ * document id). A host with a composite primary key should use the parts
79
+ * directly and ignore this.
80
+ *
81
+ * Each part is length-prefixed, so no value can impersonate a boundary however
82
+ * many delimiters it contains.
83
+ */
84
+ export declare function toolCallReceiptKeyString(key: ToolCallReceiptKey): string;
85
+ /**
86
+ * Serialize a value so that two structurally equal values produce byte-equal
87
+ * strings.
88
+ *
89
+ * `JSON.stringify` does not: it emits object keys in insertion order, so
90
+ * `{a:1,b:2}` and `{b:2,a:1}` — the same arguments, assembled by two code paths
91
+ * or streamed in a different chunk order — serialize differently. A retry then
92
+ * reads "different arguments" and executes a call it should have replayed.
93
+ *
94
+ * Keys are sorted; arrays keep their order, because in an argument bag order is
95
+ * meaning. `undefined` becomes `null` rather than vanishing, so a key whose
96
+ * value is absent cannot be confused with a key that is not there.
97
+ *
98
+ * **A value this cannot represent faithfully throws rather than serializing to
99
+ * something wrong.** `toJSON` is honored exactly as `JSON.stringify` honors it,
100
+ * so a `Date` canonicalizes to its ISO string; but a `Map`, a `Set`, or a class
101
+ * instance keeping its state off the enumerable own keys has no such escape and
102
+ * would otherwise come out as `{}` — colliding with an *empty argument bag* and
103
+ * with every other such value. In a hash that decides whether a write already
104
+ * happened, a silent collision is the one failure worth crashing over. Cycles
105
+ * and excessive depth throw for the same reason, rather than overflowing the
106
+ * stack: a model can author deeply nested arguments, and `JSON.parse` accepts
107
+ * far deeper input than a recursive walk survives.
108
+ *
109
+ * Hash the raw parsed arguments, not the output of a schema parse that coerced
110
+ * types — that is how a `Date` gets in.
111
+ */
112
+ export declare function canonicalJson(value: unknown): string;
113
+ /**
114
+ * Hash the canonical form of a call's arguments. Supplied by the host because
115
+ * the package cannot name a digest: it builds with no DOM and no Node types, so
116
+ * neither `crypto.subtle` nor `node:crypto` is in scope — both are one line away
117
+ * in every runtime that would use this.
118
+ *
119
+ * **Use a full-width cryptographic digest.** The value is compared for equality
120
+ * here, but it is also *persisted*, and its input is tool arguments — routinely
121
+ * a recipient address or a document title. Over a low-entropy input domain an
122
+ * equality-comparable hash of user data is a confirmation oracle for anyone who
123
+ * can read the receipt table, so preimage resistance matters even though this
124
+ * code never inverts it. Truncating to save a column re-introduces collisions
125
+ * in exactly the comparison that decides whether a write already happened.
126
+ *
127
+ * It must also be **stable across process restarts and package versions** — a
128
+ * digest that changes orphans every receipt already stored.
129
+ */
130
+ export type DigestFn = (canonical: string) => Promise<string>;
131
+ /** Hash a call's arguments into the `argsHash` half of a {@link ToolCallReceiptKey}. */
132
+ export declare function toolCallArgsHash(args: unknown, digest: DigestFn): Promise<string>;
133
+ /**
134
+ * A receipt as the host found it. `absent` covers both "never recorded" and
135
+ * "recorded under a different key", which are the same thing to the decision.
136
+ *
137
+ * `leaseExpired` must be computed on the **store's** clock.
138
+ */
139
+ export type ReceiptState = {
140
+ readonly kind: "absent";
141
+ } | {
142
+ readonly kind: "completed";
143
+ } | {
144
+ readonly kind: "failed";
145
+ readonly attempts: number;
146
+ } | {
147
+ readonly kind: "running";
148
+ readonly attempts: number;
149
+ readonly leaseExpired: boolean;
150
+ };
151
+ /**
152
+ * Whether an interrupted effect can be safely resumed.
153
+ *
154
+ * `resumable` — the host records each sub-operation as it completes, so a
155
+ * reclaimed attempt skips what already happened. Monad's canvas plans work this
156
+ * way.
157
+ *
158
+ * `opaque` — one indivisible side effect with no record of whether it landed.
159
+ * A reclaimed attempt cannot tell "never sent" from "sent, then crashed", and
160
+ * this module refuses to guess.
161
+ */
162
+ export type EffectResumability = "resumable" | "opaque";
163
+ /** What to do about the receipt that was found. */
164
+ export type ReceiptDecision =
165
+ /** Execute, then complete the receipt under this attempt number. */
166
+ {
167
+ readonly kind: "execute";
168
+ readonly attempt: number;
169
+ }
170
+ /** Do not execute. The recorded result is this call's result. */
171
+ | {
172
+ readonly kind: "replay";
173
+ }
174
+ /**
175
+ * Another attempt holds a live lease. Not a failure — come back. The host
176
+ * chooses the delay; a queue redelivery is the natural one.
177
+ */
178
+ | {
179
+ readonly kind: "wait";
180
+ readonly reason: string;
181
+ }
182
+ /**
183
+ * The effect may or may not have reached the outside world, and nothing can
184
+ * tell. Refuse rather than risk repeating it, and surface it — this is the
185
+ * state a human resolves.
186
+ */
187
+ | {
188
+ readonly kind: "ambiguous";
189
+ readonly reason: string;
190
+ };
191
+ /**
192
+ * Decide what a found receipt means. Pure; the host does the reading and the
193
+ * writing, and owns the clock that decided `leaseExpired`.
194
+ *
195
+ * The `running`-with-an-expired-lease case is the one worth understanding. It
196
+ * is **not** automatically ambiguous: on an at-least-once substrate a lease
197
+ * expires whenever a worker dies *or merely stalls*, which is common and
198
+ * recoverable. Whether it is safe to reclaim depends on something only the host
199
+ * knows — whether the effect left a trail. Hence {@link EffectResumability}.
200
+ */
201
+ export declare function decideToolCallReceipt(input: {
202
+ readonly state: ReceiptState;
203
+ readonly effect: EffectResumability;
204
+ }): ReceiptDecision;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Deciding whether a tool call has already happened, so a retry does not repeat
3
+ * its side effect.
4
+ *
5
+ * A durable runtime retries. A queue redelivers, a workflow step re-runs, a
6
+ * crashed worker's claim is reclaimed — and the agent loop starts the turn
7
+ * again. If the turn asked a tool to send an email, the retry sends a second
8
+ * one. A *receipt* is the row that lets the second attempt find out.
9
+ *
10
+ * Two things in that are the same for every host and belong here: **what
11
+ * identifies a call**, and **what to do about a receipt you found**. Everything
12
+ * else — the table, the clock, the transaction — is the host's, and this module
13
+ * deliberately owns none of it.
14
+ *
15
+ * ## Identity is content, not position
16
+ *
17
+ * A call is identified by `(tenant, run, tool, arguments)`. The tempting
18
+ * alternative is position — run + turn index + index within the batch — and it
19
+ * is wrong: **a retried turn is a fresh completion.** At any temperature above
20
+ * zero the model may reorder the batch, drop a call, or ask for a different
21
+ * tool at the same index. Position-keyed receipts then match calls that are not
22
+ * the same call, and the failure is the one this module exists to prevent: the
23
+ * first tool's recorded result is replayed as the second tool's, and the tool
24
+ * actually requested never runs.
25
+ *
26
+ * Content keying has the opposite failure mode, which is the safe one. A
27
+ * genuinely new call finds no receipt and executes; a repeated one finds its
28
+ * own. Nondeterminism costs an extra execution of something that was never run
29
+ * before, rather than a skipped execution of something that was.
30
+ *
31
+ * **The tenant is part of the key, not context.** A key without it cannot be
32
+ * partitioned or relocated by tenant, and two tenants' runs are not guaranteed
33
+ * to live in the same database.
34
+ *
35
+ * ## The decision needs a lease, not just a status
36
+ *
37
+ * "Recorded but not completed" does **not** mean "this may have reached the
38
+ * provider." On an at-least-once substrate it is also the ordinary state while
39
+ * another worker is *still running the call* — a queue that reclaims a wedged
40
+ * handler's claim can have two workers on one job by design. Treating that as
41
+ * ambiguous-and-never-repeat blocks the call permanently and needs a human.
42
+ *
43
+ * So the host records a **lease** and an **attempt counter**, and
44
+ * {@link decideToolCallReceipt} distinguishes the three cases a bare status
45
+ * cannot: someone else holds it and is alive (wait), someone else held it and
46
+ * died (reclaim, if the effect can resume), or it finished (replay).
47
+ *
48
+ * Two host obligations this module cannot enforce and a correct implementation
49
+ * needs:
50
+ *
51
+ * - **Lease times come from the store's clock, not the process's.** Application
52
+ * clocks drift enough to steal a live lease.
53
+ * - **Every write gates on the attempt fence** (`status = 'running' AND
54
+ * attempts = <mine>`), inside the same transaction as the side effect where
55
+ * the store allows it. Fencing only the completion write leaves the window
56
+ * where two attempts both believe they own the call.
57
+ *
58
+ * Monad's `agent_side_effect_receipts` is the worked example of all of the
59
+ * above; this module is the part of it that is not Postgres.
60
+ */
61
+ /**
62
+ * The key as one opaque string, for a store without composite keys (KV, a
63
+ * document id). A host with a composite primary key should use the parts
64
+ * directly and ignore this.
65
+ *
66
+ * Each part is length-prefixed, so no value can impersonate a boundary however
67
+ * many delimiters it contains.
68
+ */
69
+ export function toolCallReceiptKeyString(key) {
70
+ return [key.tenantId, key.runId, key.toolName, key.argsHash]
71
+ .map((part) => `${part.length}:${part}`)
72
+ .join("|");
73
+ }
74
+ /**
75
+ * Serialize a value so that two structurally equal values produce byte-equal
76
+ * strings.
77
+ *
78
+ * `JSON.stringify` does not: it emits object keys in insertion order, so
79
+ * `{a:1,b:2}` and `{b:2,a:1}` — the same arguments, assembled by two code paths
80
+ * or streamed in a different chunk order — serialize differently. A retry then
81
+ * reads "different arguments" and executes a call it should have replayed.
82
+ *
83
+ * Keys are sorted; arrays keep their order, because in an argument bag order is
84
+ * meaning. `undefined` becomes `null` rather than vanishing, so a key whose
85
+ * value is absent cannot be confused with a key that is not there.
86
+ *
87
+ * **A value this cannot represent faithfully throws rather than serializing to
88
+ * something wrong.** `toJSON` is honored exactly as `JSON.stringify` honors it,
89
+ * so a `Date` canonicalizes to its ISO string; but a `Map`, a `Set`, or a class
90
+ * instance keeping its state off the enumerable own keys has no such escape and
91
+ * would otherwise come out as `{}` — colliding with an *empty argument bag* and
92
+ * with every other such value. In a hash that decides whether a write already
93
+ * happened, a silent collision is the one failure worth crashing over. Cycles
94
+ * and excessive depth throw for the same reason, rather than overflowing the
95
+ * stack: a model can author deeply nested arguments, and `JSON.parse` accepts
96
+ * far deeper input than a recursive walk survives.
97
+ *
98
+ * Hash the raw parsed arguments, not the output of a schema parse that coerced
99
+ * types — that is how a `Date` gets in.
100
+ */
101
+ export function canonicalJson(value) {
102
+ return canonicalize(value, new Set(), 0);
103
+ }
104
+ /** Depth at which input is refused. Comfortably past any real argument bag, and
105
+ * far below where a recursive walk overflows. */
106
+ const MAX_DEPTH = 200;
107
+ function canonicalize(value, seen, depth) {
108
+ if (depth > MAX_DEPTH) {
109
+ throw new TypeError(`canonicalJson: value nested deeper than ${MAX_DEPTH} levels. Tool ` +
110
+ `arguments are model-authored, so this is refused rather than walked.`);
111
+ }
112
+ if (typeof value === "object" && value !== null) {
113
+ if (seen.has(value)) {
114
+ throw new TypeError("canonicalJson: value contains a cycle.");
115
+ }
116
+ seen.add(value);
117
+ try {
118
+ if (Array.isArray(value)) {
119
+ // A plain `.map` preserves holes and `join` renders them empty, which
120
+ // emits `[,1]` — not valid JSON. Index explicitly so a hole becomes the
121
+ // `null` that `JSON.stringify` would have produced.
122
+ const items = [];
123
+ for (let i = 0; i < value.length; i++) {
124
+ items.push(canonicalize(value[i], seen, depth + 1));
125
+ }
126
+ return `[${items.join(",")}]`;
127
+ }
128
+ const toJSON = value.toJSON;
129
+ if (typeof toJSON === "function") {
130
+ return canonicalize(toJSON.call(value), seen, depth + 1);
131
+ }
132
+ const proto = Object.getPrototypeOf(value);
133
+ if (proto !== Object.prototype && proto !== null) {
134
+ throw new TypeError(`canonicalJson cannot faithfully serialize ${constructorNameOf(value)}: ` +
135
+ `it is not a plain object and has no toJSON, so it would collapse to ` +
136
+ `an empty object and collide with one.`);
137
+ }
138
+ return `{${Object.entries(value)
139
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
140
+ .map(([key, nested]) => `${JSON.stringify(key)}:${canonicalize(nested, seen, depth + 1)}`)
141
+ .join(",")}}`;
142
+ }
143
+ finally {
144
+ seen.delete(value);
145
+ }
146
+ }
147
+ // Everything below would otherwise reach `JSON.stringify` and come back as
148
+ // the string "null" — the same bytes as an explicit `null`, and as each
149
+ // other. `{timeout: NaN}` and `{timeout: null}` fingerprinting alike means a
150
+ // replay skips a write that never happened, which is the failure this module
151
+ // exists to prevent. `undefined` is the one deliberate exception: it maps to
152
+ // `null` so a key whose value is absent stays distinguishable from a key that
153
+ // is not there, and that is worth its collision with an explicit `null`.
154
+ if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") {
155
+ throw new TypeError(`canonicalJson cannot faithfully serialize a ${typeof value}: it has no ` +
156
+ `JSON form and would collapse to null, colliding with an explicit null.`);
157
+ }
158
+ if (typeof value === "number" && !Number.isFinite(value)) {
159
+ throw new TypeError(`canonicalJson cannot faithfully serialize ${String(value)}: JSON has no ` +
160
+ `representation for it, so it would collapse to null and collide with ` +
161
+ `an explicit null. A NaN here usually means a schema coerced a bad ` +
162
+ `value — fingerprint the raw parsed arguments instead.`);
163
+ }
164
+ // `JSON.stringify` returns undefined for a bare `undefined`.
165
+ return JSON.stringify(value) ?? "null";
166
+ }
167
+ function constructorNameOf(value) {
168
+ const name = value.constructor?.name;
169
+ return typeof name === "string" && name.length > 0 ? name : "a non-plain object";
170
+ }
171
+ /** Hash a call's arguments into the `argsHash` half of a {@link ToolCallReceiptKey}. */
172
+ export async function toolCallArgsHash(args, digest) {
173
+ return digest(canonicalJson(args));
174
+ }
175
+ /**
176
+ * Decide what a found receipt means. Pure; the host does the reading and the
177
+ * writing, and owns the clock that decided `leaseExpired`.
178
+ *
179
+ * The `running`-with-an-expired-lease case is the one worth understanding. It
180
+ * is **not** automatically ambiguous: on an at-least-once substrate a lease
181
+ * expires whenever a worker dies *or merely stalls*, which is common and
182
+ * recoverable. Whether it is safe to reclaim depends on something only the host
183
+ * knows — whether the effect left a trail. Hence {@link EffectResumability}.
184
+ */
185
+ export function decideToolCallReceipt(input) {
186
+ const { state, effect } = input;
187
+ switch (state.kind) {
188
+ case "absent":
189
+ return { kind: "execute", attempt: 1 };
190
+ case "completed":
191
+ return { kind: "replay" };
192
+ case "failed":
193
+ // A failed attempt is a finished one: it released its lease and recorded
194
+ // that it did not succeed. Retrying is the point of recording the failure.
195
+ return { kind: "execute", attempt: nextAttempt(state.attempts) };
196
+ case "running":
197
+ if (!state.leaseExpired) {
198
+ return {
199
+ kind: "wait",
200
+ reason: "another attempt holds a live lease on this call — it is running, " +
201
+ "not stuck; retry once the lease would have expired",
202
+ };
203
+ }
204
+ return effect === "resumable"
205
+ ? { kind: "execute", attempt: nextAttempt(state.attempts) }
206
+ : {
207
+ kind: "ambiguous",
208
+ reason: "an attempt claimed this call and its lease expired without " +
209
+ "completing. The effect is not resumable, so whether it reached " +
210
+ "the outside world cannot be determined — repeating it may " +
211
+ "duplicate it",
212
+ };
213
+ default: {
214
+ const _exhaustive = state;
215
+ throw new Error(`unknown receipt state: ${JSON.stringify(_exhaustive)}`);
216
+ }
217
+ }
218
+ }
219
+ function nextAttempt(attempts) {
220
+ // A broken counter must not silently reset the fence to 1 and let two
221
+ // attempts believe they own the call.
222
+ if (!Number.isInteger(attempts) || attempts < 1) {
223
+ throw new TypeError(`receipt attempts must be a positive integer, got ${String(attempts)}`);
224
+ }
225
+ return attempts + 1;
226
+ }