@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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Deciding whether a tool call has already happened, so a retry does not repeat
3
+ * its side effect.
4
+ *
5
+ * A durable runtime retries. A queue redelivers, a workflow step re-runs, a
6
+ * crashed worker's claim is reclaimed — and the agent loop starts the turn
7
+ * again. If the turn asked a tool to send an email, the retry sends a second
8
+ * one. A *receipt* is the row that lets the second attempt find out.
9
+ *
10
+ * Two things in that are the same for every host and belong here: **what
11
+ * identifies a call**, and **what to do about a receipt you found**. Everything
12
+ * else — the table, the clock, the transaction — is the host's, and this module
13
+ * deliberately owns none of it.
14
+ *
15
+ * ## Identity is content, not position
16
+ *
17
+ * A call is identified by `(tenant, run, tool, arguments)`. The tempting
18
+ * alternative is position — run + turn index + index within the batch — and it
19
+ * is wrong: **a retried turn is a fresh completion.** At any temperature above
20
+ * zero the model may reorder the batch, drop a call, or ask for a different
21
+ * tool at the same index. Position-keyed receipts then match calls that are not
22
+ * the same call, and the failure is the one this module exists to prevent: the
23
+ * first tool's recorded result is replayed as the second tool's, and the tool
24
+ * actually requested never runs.
25
+ *
26
+ * Content keying has the opposite failure mode, which is the safe one. A
27
+ * genuinely new call finds no receipt and executes; a repeated one finds its
28
+ * own. Nondeterminism costs an extra execution of something that was never run
29
+ * before, rather than a skipped execution of something that was.
30
+ *
31
+ * **The tenant is part of the key, not context.** A key without it cannot be
32
+ * partitioned or relocated by tenant, and two tenants' runs are not guaranteed
33
+ * to live in the same database.
34
+ *
35
+ * ## The decision needs a lease, not just a status
36
+ *
37
+ * "Recorded but not completed" does **not** mean "this may have reached the
38
+ * provider." On an at-least-once substrate it is also the ordinary state while
39
+ * another worker is *still running the call* — a queue that reclaims a wedged
40
+ * handler's claim can have two workers on one job by design. Treating that as
41
+ * ambiguous-and-never-repeat blocks the call permanently and needs a human.
42
+ *
43
+ * So the host records a **lease** and an **attempt counter**, and
44
+ * {@link decideToolCallReceipt} distinguishes the three cases a bare status
45
+ * cannot: someone else holds it and is alive (wait), someone else held it and
46
+ * died (reclaim, if the effect can resume), or it finished (replay).
47
+ *
48
+ * Two host obligations this module cannot enforce and a correct implementation
49
+ * needs:
50
+ *
51
+ * - **Lease times come from the store's clock, not the process's.** Application
52
+ * clocks drift enough to steal a live lease.
53
+ * - **Every write gates on the attempt fence** (`status = 'running' AND
54
+ * attempts = <mine>`), inside the same transaction as the side effect where
55
+ * the store allows it. Fencing only the completion write leaves the window
56
+ * where two attempts both believe they own the call.
57
+ *
58
+ * Monad's `agent_side_effect_receipts` is the worked example of all of the
59
+ * above; this module is the part of it that is not Postgres.
60
+ */
61
+ /**
62
+ * What identifies a tool call across retries.
63
+ *
64
+ * All four parts are required. Dropping `tenantId` makes the key unpartitionable
65
+ * and, in a multi-database deployment, ambiguous; dropping `toolName` makes any
66
+ * two zero-argument calls in a run identical, and `{}` is the commonest
67
+ * argument bag there is.
68
+ */
69
+ export interface ToolCallReceiptKey {
70
+ readonly tenantId: string;
71
+ readonly runId: string;
72
+ readonly toolName: string;
73
+ /** From {@link toolCallArgsHash}. */
74
+ readonly argsHash: string;
75
+ }
76
+ /**
77
+ * The key as one opaque string, for a store without composite keys (KV, a
78
+ * document id). A host with a composite primary key should use the parts
79
+ * directly and ignore this.
80
+ *
81
+ * Each part is length-prefixed, so no value can impersonate a boundary however
82
+ * many delimiters it contains.
83
+ */
84
+ export declare function toolCallReceiptKeyString(key: ToolCallReceiptKey): string;
85
+ /**
86
+ * Serialize a value so that two structurally equal values produce byte-equal
87
+ * strings.
88
+ *
89
+ * `JSON.stringify` does not: it emits object keys in insertion order, so
90
+ * `{a:1,b:2}` and `{b:2,a:1}` — the same arguments, assembled by two code paths
91
+ * or streamed in a different chunk order — serialize differently. A retry then
92
+ * reads "different arguments" and executes a call it should have replayed.
93
+ *
94
+ * Keys are sorted; arrays keep their order, because in an argument bag order is
95
+ * meaning. `undefined` becomes `null` rather than vanishing, so a key whose
96
+ * value is absent cannot be confused with a key that is not there.
97
+ *
98
+ * **A value this cannot represent faithfully throws rather than serializing to
99
+ * something wrong.** `toJSON` is honored exactly as `JSON.stringify` honors it,
100
+ * so a `Date` canonicalizes to its ISO string; but a `Map`, a `Set`, or a class
101
+ * instance keeping its state off the enumerable own keys has no such escape and
102
+ * would otherwise come out as `{}` — colliding with an *empty argument bag* and
103
+ * with every other such value. In a hash that decides whether a write already
104
+ * happened, a silent collision is the one failure worth crashing over. Cycles
105
+ * and excessive depth throw for the same reason, rather than overflowing the
106
+ * stack: a model can author deeply nested arguments, and `JSON.parse` accepts
107
+ * far deeper input than a recursive walk survives.
108
+ *
109
+ * Hash the raw parsed arguments, not the output of a schema parse that coerced
110
+ * types — that is how a `Date` gets in.
111
+ */
112
+ export declare function canonicalJson(value: unknown): string;
113
+ /**
114
+ * Hash the canonical form of a call's arguments. Supplied by the host because
115
+ * the package cannot name a digest: it builds with no DOM and no Node types, so
116
+ * neither `crypto.subtle` nor `node:crypto` is in scope — both are one line away
117
+ * in every runtime that would use this.
118
+ *
119
+ * **Use a full-width cryptographic digest.** The value is compared for equality
120
+ * here, but it is also *persisted*, and its input is tool arguments — routinely
121
+ * a recipient address or a document title. Over a low-entropy input domain an
122
+ * equality-comparable hash of user data is a confirmation oracle for anyone who
123
+ * can read the receipt table, so preimage resistance matters even though this
124
+ * code never inverts it. Truncating to save a column re-introduces collisions
125
+ * in exactly the comparison that decides whether a write already happened.
126
+ *
127
+ * It must also be **stable across process restarts and package versions** — a
128
+ * digest that changes orphans every receipt already stored.
129
+ */
130
+ export type DigestFn = (canonical: string) => Promise<string>;
131
+ /** Hash a call's arguments into the `argsHash` half of a {@link ToolCallReceiptKey}. */
132
+ export declare function toolCallArgsHash(args: unknown, digest: DigestFn): Promise<string>;
133
+ /**
134
+ * A receipt as the host found it. `absent` covers both "never recorded" and
135
+ * "recorded under a different key", which are the same thing to the decision.
136
+ *
137
+ * `leaseExpired` must be computed on the **store's** clock.
138
+ */
139
+ export type ReceiptState = {
140
+ readonly kind: "absent";
141
+ } | {
142
+ readonly kind: "completed";
143
+ } | {
144
+ readonly kind: "failed";
145
+ readonly attempts: number;
146
+ } | {
147
+ readonly kind: "running";
148
+ readonly attempts: number;
149
+ readonly leaseExpired: boolean;
150
+ };
151
+ /**
152
+ * Whether an interrupted effect can be safely resumed.
153
+ *
154
+ * `resumable` — the host records each sub-operation as it completes, so a
155
+ * reclaimed attempt skips what already happened. Monad's canvas plans work this
156
+ * way.
157
+ *
158
+ * `opaque` — one indivisible side effect with no record of whether it landed.
159
+ * A reclaimed attempt cannot tell "never sent" from "sent, then crashed", and
160
+ * this module refuses to guess.
161
+ */
162
+ export type EffectResumability = "resumable" | "opaque";
163
+ /** What to do about the receipt that was found. */
164
+ export type ReceiptDecision =
165
+ /** Execute, then complete the receipt under this attempt number. */
166
+ {
167
+ readonly kind: "execute";
168
+ readonly attempt: number;
169
+ }
170
+ /** Do not execute. The recorded result is this call's result. */
171
+ | {
172
+ readonly kind: "replay";
173
+ }
174
+ /**
175
+ * Another attempt holds a live lease. Not a failure — come back. The host
176
+ * chooses the delay; a queue redelivery is the natural one.
177
+ */
178
+ | {
179
+ readonly kind: "wait";
180
+ readonly reason: string;
181
+ }
182
+ /**
183
+ * The effect may or may not have reached the outside world, and nothing can
184
+ * tell. Refuse rather than risk repeating it, and surface it — this is the
185
+ * state a human resolves.
186
+ */
187
+ | {
188
+ readonly kind: "ambiguous";
189
+ readonly reason: string;
190
+ };
191
+ /**
192
+ * Decide what a found receipt means. Pure; the host does the reading and the
193
+ * writing, and owns the clock that decided `leaseExpired`.
194
+ *
195
+ * The `running`-with-an-expired-lease case is the one worth understanding. It
196
+ * is **not** automatically ambiguous: on an at-least-once substrate a lease
197
+ * expires whenever a worker dies *or merely stalls*, which is common and
198
+ * recoverable. Whether it is safe to reclaim depends on something only the host
199
+ * knows — whether the effect left a trail. Hence {@link EffectResumability}.
200
+ */
201
+ export declare function decideToolCallReceipt(input: {
202
+ readonly state: ReceiptState;
203
+ readonly effect: EffectResumability;
204
+ }): ReceiptDecision;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Deciding whether a tool call has already happened, so a retry does not repeat
3
+ * its side effect.
4
+ *
5
+ * A durable runtime retries. A queue redelivers, a workflow step re-runs, a
6
+ * crashed worker's claim is reclaimed — and the agent loop starts the turn
7
+ * again. If the turn asked a tool to send an email, the retry sends a second
8
+ * one. A *receipt* is the row that lets the second attempt find out.
9
+ *
10
+ * Two things in that are the same for every host and belong here: **what
11
+ * identifies a call**, and **what to do about a receipt you found**. Everything
12
+ * else — the table, the clock, the transaction — is the host's, and this module
13
+ * deliberately owns none of it.
14
+ *
15
+ * ## Identity is content, not position
16
+ *
17
+ * A call is identified by `(tenant, run, tool, arguments)`. The tempting
18
+ * alternative is position — run + turn index + index within the batch — and it
19
+ * is wrong: **a retried turn is a fresh completion.** At any temperature above
20
+ * zero the model may reorder the batch, drop a call, or ask for a different
21
+ * tool at the same index. Position-keyed receipts then match calls that are not
22
+ * the same call, and the failure is the one this module exists to prevent: the
23
+ * first tool's recorded result is replayed as the second tool's, and the tool
24
+ * actually requested never runs.
25
+ *
26
+ * Content keying has the opposite failure mode, which is the safe one. A
27
+ * genuinely new call finds no receipt and executes; a repeated one finds its
28
+ * own. Nondeterminism costs an extra execution of something that was never run
29
+ * before, rather than a skipped execution of something that was.
30
+ *
31
+ * **The tenant is part of the key, not context.** A key without it cannot be
32
+ * partitioned or relocated by tenant, and two tenants' runs are not guaranteed
33
+ * to live in the same database.
34
+ *
35
+ * ## The decision needs a lease, not just a status
36
+ *
37
+ * "Recorded but not completed" does **not** mean "this may have reached the
38
+ * provider." On an at-least-once substrate it is also the ordinary state while
39
+ * another worker is *still running the call* — a queue that reclaims a wedged
40
+ * handler's claim can have two workers on one job by design. Treating that as
41
+ * ambiguous-and-never-repeat blocks the call permanently and needs a human.
42
+ *
43
+ * So the host records a **lease** and an **attempt counter**, and
44
+ * {@link decideToolCallReceipt} distinguishes the three cases a bare status
45
+ * cannot: someone else holds it and is alive (wait), someone else held it and
46
+ * died (reclaim, if the effect can resume), or it finished (replay).
47
+ *
48
+ * Two host obligations this module cannot enforce and a correct implementation
49
+ * needs:
50
+ *
51
+ * - **Lease times come from the store's clock, not the process's.** Application
52
+ * clocks drift enough to steal a live lease.
53
+ * - **Every write gates on the attempt fence** (`status = 'running' AND
54
+ * attempts = <mine>`), inside the same transaction as the side effect where
55
+ * the store allows it. Fencing only the completion write leaves the window
56
+ * where two attempts both believe they own the call.
57
+ *
58
+ * Monad's `agent_side_effect_receipts` is the worked example of all of the
59
+ * above; this module is the part of it that is not Postgres.
60
+ */
61
+ /**
62
+ * The key as one opaque string, for a store without composite keys (KV, a
63
+ * document id). A host with a composite primary key should use the parts
64
+ * directly and ignore this.
65
+ *
66
+ * Each part is length-prefixed, so no value can impersonate a boundary however
67
+ * many delimiters it contains.
68
+ */
69
+ export function toolCallReceiptKeyString(key) {
70
+ return [key.tenantId, key.runId, key.toolName, key.argsHash]
71
+ .map((part) => `${part.length}:${part}`)
72
+ .join("|");
73
+ }
74
+ /**
75
+ * Serialize a value so that two structurally equal values produce byte-equal
76
+ * strings.
77
+ *
78
+ * `JSON.stringify` does not: it emits object keys in insertion order, so
79
+ * `{a:1,b:2}` and `{b:2,a:1}` — the same arguments, assembled by two code paths
80
+ * or streamed in a different chunk order — serialize differently. A retry then
81
+ * reads "different arguments" and executes a call it should have replayed.
82
+ *
83
+ * Keys are sorted; arrays keep their order, because in an argument bag order is
84
+ * meaning. `undefined` becomes `null` rather than vanishing, so a key whose
85
+ * value is absent cannot be confused with a key that is not there.
86
+ *
87
+ * **A value this cannot represent faithfully throws rather than serializing to
88
+ * something wrong.** `toJSON` is honored exactly as `JSON.stringify` honors it,
89
+ * so a `Date` canonicalizes to its ISO string; but a `Map`, a `Set`, or a class
90
+ * instance keeping its state off the enumerable own keys has no such escape and
91
+ * would otherwise come out as `{}` — colliding with an *empty argument bag* and
92
+ * with every other such value. In a hash that decides whether a write already
93
+ * happened, a silent collision is the one failure worth crashing over. Cycles
94
+ * and excessive depth throw for the same reason, rather than overflowing the
95
+ * stack: a model can author deeply nested arguments, and `JSON.parse` accepts
96
+ * far deeper input than a recursive walk survives.
97
+ *
98
+ * Hash the raw parsed arguments, not the output of a schema parse that coerced
99
+ * types — that is how a `Date` gets in.
100
+ */
101
+ export function canonicalJson(value) {
102
+ return canonicalize(value, new Set(), 0);
103
+ }
104
+ /** Depth at which input is refused. Comfortably past any real argument bag, and
105
+ * far below where a recursive walk overflows. */
106
+ const MAX_DEPTH = 200;
107
+ function canonicalize(value, seen, depth) {
108
+ if (depth > MAX_DEPTH) {
109
+ throw new TypeError(`canonicalJson: value nested deeper than ${MAX_DEPTH} levels. Tool ` +
110
+ `arguments are model-authored, so this is refused rather than walked.`);
111
+ }
112
+ if (typeof value === "object" && value !== null) {
113
+ if (seen.has(value)) {
114
+ throw new TypeError("canonicalJson: value contains a cycle.");
115
+ }
116
+ seen.add(value);
117
+ try {
118
+ if (Array.isArray(value)) {
119
+ // A plain `.map` preserves holes and `join` renders them empty, which
120
+ // emits `[,1]` — not valid JSON. Index explicitly so a hole becomes the
121
+ // `null` that `JSON.stringify` would have produced.
122
+ const items = [];
123
+ for (let i = 0; i < value.length; i++) {
124
+ items.push(canonicalize(value[i], seen, depth + 1));
125
+ }
126
+ return `[${items.join(",")}]`;
127
+ }
128
+ const toJSON = value.toJSON;
129
+ if (typeof toJSON === "function") {
130
+ return canonicalize(toJSON.call(value), seen, depth + 1);
131
+ }
132
+ const proto = Object.getPrototypeOf(value);
133
+ if (proto !== Object.prototype && proto !== null) {
134
+ throw new TypeError(`canonicalJson cannot faithfully serialize ${constructorNameOf(value)}: ` +
135
+ `it is not a plain object and has no toJSON, so it would collapse to ` +
136
+ `an empty object and collide with one.`);
137
+ }
138
+ return `{${Object.entries(value)
139
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
140
+ .map(([key, nested]) => `${JSON.stringify(key)}:${canonicalize(nested, seen, depth + 1)}`)
141
+ .join(",")}}`;
142
+ }
143
+ finally {
144
+ seen.delete(value);
145
+ }
146
+ }
147
+ // Everything below would otherwise reach `JSON.stringify` and come back as
148
+ // the string "null" — the same bytes as an explicit `null`, and as each
149
+ // other. `{timeout: NaN}` and `{timeout: null}` fingerprinting alike means a
150
+ // replay skips a write that never happened, which is the failure this module
151
+ // exists to prevent. `undefined` is the one deliberate exception: it maps to
152
+ // `null` so a key whose value is absent stays distinguishable from a key that
153
+ // is not there, and that is worth its collision with an explicit `null`.
154
+ if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") {
155
+ throw new TypeError(`canonicalJson cannot faithfully serialize a ${typeof value}: it has no ` +
156
+ `JSON form and would collapse to null, colliding with an explicit null.`);
157
+ }
158
+ if (typeof value === "number" && !Number.isFinite(value)) {
159
+ throw new TypeError(`canonicalJson cannot faithfully serialize ${String(value)}: JSON has no ` +
160
+ `representation for it, so it would collapse to null and collide with ` +
161
+ `an explicit null. A NaN here usually means a schema coerced a bad ` +
162
+ `value — fingerprint the raw parsed arguments instead.`);
163
+ }
164
+ // `JSON.stringify` returns undefined for a bare `undefined`.
165
+ return JSON.stringify(value) ?? "null";
166
+ }
167
+ function constructorNameOf(value) {
168
+ const name = value.constructor?.name;
169
+ return typeof name === "string" && name.length > 0 ? name : "a non-plain object";
170
+ }
171
+ /** Hash a call's arguments into the `argsHash` half of a {@link ToolCallReceiptKey}. */
172
+ export async function toolCallArgsHash(args, digest) {
173
+ return digest(canonicalJson(args));
174
+ }
175
+ /**
176
+ * Decide what a found receipt means. Pure; the host does the reading and the
177
+ * writing, and owns the clock that decided `leaseExpired`.
178
+ *
179
+ * The `running`-with-an-expired-lease case is the one worth understanding. It
180
+ * is **not** automatically ambiguous: on an at-least-once substrate a lease
181
+ * expires whenever a worker dies *or merely stalls*, which is common and
182
+ * recoverable. Whether it is safe to reclaim depends on something only the host
183
+ * knows — whether the effect left a trail. Hence {@link EffectResumability}.
184
+ */
185
+ export function decideToolCallReceipt(input) {
186
+ const { state, effect } = input;
187
+ switch (state.kind) {
188
+ case "absent":
189
+ return { kind: "execute", attempt: 1 };
190
+ case "completed":
191
+ return { kind: "replay" };
192
+ case "failed":
193
+ // A failed attempt is a finished one: it released its lease and recorded
194
+ // that it did not succeed. Retrying is the point of recording the failure.
195
+ return { kind: "execute", attempt: nextAttempt(state.attempts) };
196
+ case "running":
197
+ if (!state.leaseExpired) {
198
+ return {
199
+ kind: "wait",
200
+ reason: "another attempt holds a live lease on this call — it is running, " +
201
+ "not stuck; retry once the lease would have expired",
202
+ };
203
+ }
204
+ return effect === "resumable"
205
+ ? { kind: "execute", attempt: nextAttempt(state.attempts) }
206
+ : {
207
+ kind: "ambiguous",
208
+ reason: "an attempt claimed this call and its lease expired without " +
209
+ "completing. The effect is not resumable, so whether it reached " +
210
+ "the outside world cannot be determined — repeating it may " +
211
+ "duplicate it",
212
+ };
213
+ default: {
214
+ const _exhaustive = state;
215
+ throw new Error(`unknown receipt state: ${JSON.stringify(_exhaustive)}`);
216
+ }
217
+ }
218
+ }
219
+ function nextAttempt(attempts) {
220
+ // A broken counter must not silently reset the fence to 1 and let two
221
+ // attempts believe they own the call.
222
+ if (!Number.isInteger(attempts) || attempts < 1) {
223
+ throw new TypeError(`receipt attempts must be a positive integer, got ${String(attempts)}`);
224
+ }
225
+ return attempts + 1;
226
+ }