@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
|
@@ -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
|
+
}
|