@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 +254 -9
- package/completion/defects.js +5 -3
- package/completion/index.d.ts +2 -0
- package/completion/index.js +2 -0
- package/completion/stream-assembly.d.ts +108 -0
- package/completion/stream-assembly.js +117 -0
- package/completion/tool-calls.d.ts +90 -0
- package/completion/tool-calls.js +67 -0
- package/package.json +1 -1
- package/routing/errors.d.ts +20 -0
- package/routing/errors.js +30 -0
- package/routing/executor.d.ts +12 -0
- package/routing/executor.js +7 -2
- package/routing/index.d.ts +1 -1
- package/routing/index.js +1 -1
- package/run/children.d.ts +55 -3
- package/run/children.js +43 -10
- package/run/index.d.ts +1 -0
- package/run/index.js +1 -0
- package/run/receipts.d.ts +204 -0
- package/run/receipts.js +226 -0
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,
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
|
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
|
|
package/completion/defects.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
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(
|
|
87
|
+
return !jsonParses(args.trim());
|
|
86
88
|
});
|
|
87
89
|
if (truncated !== undefined) {
|
|
88
90
|
return { kind: "truncated_tool_call", toolCall: truncated };
|
package/completion/index.d.ts
CHANGED
|
@@ -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";
|
package/completion/index.js
CHANGED
|
@@ -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
|
+
}
|