@juno-ai/bind 5.0.0 → 7.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
@@ -428,6 +428,199 @@ cancellation is never reported as a retriable upstream stall. Call it in the
428
428
  (`setTimeout` coerces a non-finite delay to ~1ms — it does not disable the
429
429
  timer).
430
430
 
431
+ ### How to read the arguments of a tool call the model asked for
432
+
433
+ `JSON.parse(toolCall.function.arguments)` is the obvious implementation and it
434
+ is wrong for the commonest tool there is. Providers send `""` for a
435
+ **zero-argument** call as readily as `"{}"`, so the obvious version kills a
436
+ perfectly good call as a JSON syntax error and burns a recovery turn on a turn
437
+ that was never broken.
438
+
439
+ ```ts
440
+ import { parseToolCallArguments } from "@juno-ai/bind/completion";
441
+
442
+ const read = parseToolCallArguments(toolCall);
443
+ switch (read.kind) {
444
+ case "parsed":
445
+ return dispatch(toolCall.function.name, read.arguments);
446
+ case "unsupported_type":
447
+ return toolMessage(toolCall.id, `Unsupported tool call type: ${read.type}`);
448
+ case "unparseable":
449
+ // Put `detail` in front of the MODEL, not only in a log — its next turn is
450
+ // the only thing that can correct the arguments.
451
+ return toolMessage(toolCall.id, `Invalid tool arguments: ${read.detail}`);
452
+ }
453
+ ```
454
+
455
+ Every outcome is a value, not a throw, because every outcome has to end with a
456
+ `tool` message carrying this call's id — a transcript where an assistant asked
457
+ for a tool and nothing answered it is rejected by the provider on the *next*
458
+ request, so "give up on this call" was never an option.
459
+
460
+ Valid JSON that is not an object — `null`, `[]`, `42` — is refused rather than
461
+ dispatched. Tool arguments are a named parameter bag by definition, and handing
462
+ a tool an array where it expects fields turns a clear failure here into a
463
+ confusing one inside the tool, after any side effect it performs before its own
464
+ validation.
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
+
431
624
  ### How to map your transport errors onto the routing taxonomy
432
625
 
433
626
  `failureDisposition` decides what the router does with a classified failure, but
@@ -608,10 +801,10 @@ keeps a consumer who only wants routing from pulling in the rest.
608
801
  | Import | Owns | Reach for it when |
609
802
  |---|---|---|
610
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 |
611
- | `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), completion-defect detection, 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 |
612
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 |
613
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 |
614
- | `@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 |
615
808
  | `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
616
809
  | `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
617
810
  | `@juno-ai/bind/plugins` | Tool/plugin vocabulary, the registry factory, progressive-disclosure activation | You have more tools than fit comfortably in one prompt |
@@ -628,6 +821,17 @@ fact (`aborted` / `completion_defect` / `network` / `http` + category);
628
821
  propagate. Putting routing logic in a transport is the one way to break the
629
822
  guarantee that identical inputs produce identical plans.
630
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
+
631
835
  **Routing — a healthy endpoint must not be punished for a bad request.**
632
836
  Request-shaped rejections (a 400, a moderation refusal) traverse to another
633
837
  provider but record **no** breaker failure. Otherwise one caller's malformed or
@@ -650,6 +854,22 @@ its runs are on the queue is not bounded, it is billed. A rule whose measurement
650
854
  is `NaN` or `Infinity` refuses rather than admits — every comparison is false
651
855
  against `NaN`, so the naive reading of a broken count is an unbounded chain.
652
856
 
857
+ **Run — "could not measure" is an omission, never a sentinel.** The refusal
858
+ above is for a count you *did* supply and that came back broken. A bound you
859
+ could not measure at all — the query threw, the counter was unreachable — is
860
+ expressed by leaving that rule out of the array, which admits. The two are
861
+ opposite answers to opposite questions, and passing `NaN` for "unknown" turns a
862
+ transient database blip into every agent in your system refusing to run. Which
863
+ of the two a given failure is is yours to decide; the harness only judges what
864
+ it was handed.
865
+
866
+ **Completion — the zero-argument rule has two halves and they must agree.**
867
+ `detectCompletionDefect` decides a tool call with *empty* arguments is
868
+ legitimate and passes it through; `parseToolCallArguments` is what then reads
869
+ it as the zero-argument call it is. Both read one `toolCallArgumentsAbsent`, so
870
+ adopting only the detection half means independently reinventing the matching
871
+ parse — and getting it wrong the way everyone does, with a bare `JSON.parse`.
872
+
653
873
  **Run — chain lineage is "null means me".** A root run's `rootRunId` and
654
874
  `parentRunId` are both `null`, because a chain's origin has no id to point at
655
875
  until its own row exists. Read any chain's root as `chain.rootRunId ?? runId`;
@@ -1124,13 +1344,15 @@ import { admitChildRun, descendChain, type ChainRule } from "@juno-ai/bind/run";
1124
1344
  const rules: ChainRule[] = [
1125
1345
  { kind: "depth", parentDepth: chain.depth, maxDepth: 5 },
1126
1346
  { kind: "chain_budget", runsInChain: await countRunsInChain(chain), maxRuns: 50 },
1347
+ { kind: "pair_seen", alreadyPaired: await hasPairedInChain(chain, targetId) },
1127
1348
  { kind: "pair_cooldown", msSinceLastSpawn: await msSinceLastSpawn(runId), cooldownMs: 30_000 },
1349
+ { kind: "tenant_rate", runsInWindow: await countRecentRuns(tenantId), maxRuns: 100, windowMs: 30_000 },
1128
1350
  { kind: "tenant_ceiling", activeRuns: await countActiveRuns(tenantId), maxActiveRuns: 200 },
1129
1351
  ];
1130
1352
 
1131
1353
  const admission = admitChildRun(rules);
1132
1354
  if (!admission.admitted) {
1133
- log.warn("child run refused", { rule: admission.rule });
1355
+ log.warn("child run refused", { rule: admission.rule, retryable: admission.retryable });
1134
1356
  return { success: false, kind: "validation", error: admission.reason };
1135
1357
  }
1136
1358
  ```
@@ -1138,11 +1360,25 @@ if (!admission.admitted) {
1138
1360
  Rules are evaluated in order and the first refusal wins, so you choose which
1139
1361
  reason the model sees. Pick the set against your own cost model: depth caps
1140
1362
  runaway recursion, a chain budget caps a chain that stays shallow but keeps
1141
- fanning out, a pair cooldown stops two runs ping-ponging, and a tenant ceiling
1142
- covers what none of the chain rules can someone starting a thousand
1143
- independent chains. A broken measurement (`NaN`, `Infinity`) refuses rather
1144
- than admits: every comparison is false against `NaN`, so the naive reading
1145
- would turn a broken count into an unbounded chain.
1363
+ fanning out, `pair_seen` allows a given pair to work together once per chain
1364
+ and a `pair_cooldown` merely spaces them out, a tenant rate limit bounds a burst
1365
+ over a rolling window, and a tenant ceiling bounds what is running *right now* —
1366
+ the last two covering what no chain rule can, someone starting a thousand
1367
+ independent chains.
1368
+
1369
+ A refusal carries `rule` (which bound fired) and `retryable`, which separates a
1370
+ bound that clears on its own — a cooldown, a rate window rolling — from one that
1371
+ never will, so a caller can choose between waiting and giving up. Only a
1372
+ refusal carries them; `retryable === undefined` means there was nothing to
1373
+ retry, not "not retryable".
1374
+
1375
+ Two failure modes get opposite treatment, and the difference is the part to get
1376
+ right. A **broken measurement** (`NaN`, `Infinity`, a negative count) refuses:
1377
+ every comparison is false against `NaN`, so the naive reading would turn a
1378
+ broken count into an unbounded chain. A bound you **could not measure at all**
1379
+ — the count query threw — is expressed by *omitting the rule*, which admits.
1380
+ Reaching for `NaN` to mean "unknown" collapses the two and makes a transient
1381
+ database blip refuse every child run you have.
1146
1382
 
1147
1383
  `descendChain` handles the lineage arithmetic, including the root-id fallback
1148
1384
  that is easy to get backwards — a first-generation child adopts its parent's
@@ -1220,7 +1456,16 @@ an oversight.
1220
1456
  when the loop folds `RunStats` directly; today a host that wants throughput
1221
1457
  metrics accumulates them alongside.
1222
1458
  - **A streaming turn contract.** `callModel` reports an output-token estimate
1223
- 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.
1224
1469
 
1225
1470
  ---
1226
1471
 
@@ -18,6 +18,7 @@
18
18
  * any SDK's message class, so a host assembling chunks by hand and a host
19
19
  * handing over an `openai` message both fit without a cast.
20
20
  */
21
+ import { toolCallArgumentsAbsent } from "./tool-calls.js";
21
22
  /**
22
23
  * Additional attempts when a structured-output call returns content that will
23
24
  * not parse as JSON: the initial call plus this many retries. Providers
@@ -79,10 +80,11 @@ export function detectCompletionDefect(completion, cutByTokenLimit) {
79
80
  const args = toolCall?.function?.arguments;
80
81
  if (typeof args !== "string")
81
82
  return false;
82
- const rawArgs = args.trim();
83
- if (rawArgs.length === 0)
83
+ // The same predicate the dispatcher reads, so the two halves of the
84
+ // zero-argument rule cannot drift — see `tool-calls.ts`.
85
+ if (toolCallArgumentsAbsent(args))
84
86
  return cutByTokenLimit;
85
- return !jsonParses(rawArgs);
87
+ return !jsonParses(args.trim());
86
88
  });
87
89
  if (truncated !== undefined) {
88
90
  return { kind: "truncated_tool_call", toolCall: truncated };
@@ -1,2 +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
+ 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,2 +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
+ 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
+ }