@juno-ai/bind 6.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 +181 -3
- package/completion/index.d.ts +1 -0
- package/completion/index.js +1 -0
- package/completion/stream-assembly.d.ts +108 -0
- package/completion/stream-assembly.js +117 -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/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
|
@@ -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
|
|
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
|
|
package/completion/index.d.ts
CHANGED
|
@@ -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";
|
package/completion/index.js
CHANGED
|
@@ -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": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/routing/errors.d.ts
CHANGED
|
@@ -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.
|
package/routing/executor.d.ts
CHANGED
|
@@ -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
|
package/routing/executor.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/routing/index.d.ts
CHANGED
|
@@ -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;
|
package/run/receipts.js
ADDED
|
@@ -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
|
+
}
|