@meantjs/tools 0.0.0-stage → 0.2.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/CHANGELOG.md +52 -0
- package/LICENSE +202 -0
- package/README.md +76 -2
- package/package.json +51 -4
- package/src/index.d.mts +205 -0
- package/src/index.mjs +6 -0
- package/src/input.mjs +618 -0
- package/src/layer.mjs +449 -0
- package/src/plan.mjs +530 -0
package/src/layer.mjs
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// The tool layer (#16, item 3): tools from a resolved form, run against an injected encoder, call and clock, with a
|
|
2
|
+
// confirmation hook awaited before any send. No transport, no keys, no Node built-ins: the host supplies all three.
|
|
3
|
+
//
|
|
4
|
+
// What it owes the profile spec (5.3, "idempotency", the outcome classes and the consumer rules) and D-017:
|
|
5
|
+
// - An intent is the canister, the method and the converted arguments without the stamp (a relative time counts by
|
|
6
|
+
// its duration). Preparing an intent that is prepared, unknown or waiting on a reconciliation returns the same
|
|
7
|
+
// preview. Only a settled intent is stamped again, with a later stamp.
|
|
8
|
+
// - commit awaits `confirm(preview)` before every send. A decline sends nothing and answers `declined`; a prepared
|
|
9
|
+
// intent with no attempt is settled by it, an intent with an attempt keeps its state (#10, item 4, recommended
|
|
10
|
+
// answer; open).
|
|
11
|
+
// - success and already_done settle the intent; a later commit of it answers already_done without sending.
|
|
12
|
+
// - retry_same is handed back: nothing sleeps here, the caller (or the person) commits again. The third retry_same of
|
|
13
|
+
// an intent stops it (consumer rule 3): a reconciliation after an unknown outcome, else settled as fail.
|
|
14
|
+
// - retry_new re-stamps into a new preview for the person to confirm, its relative times resolved again against the
|
|
15
|
+
// new clock reading (#10, item 3, recommended; open). A CreatedInFuture re-stamp is capped by the ledger_time the
|
|
16
|
+
// error reports (consumer rule 2).
|
|
17
|
+
// - After an unknown outcome, any answer other than success, already_done or retry_same needs a reconciliation
|
|
18
|
+
// (consumer rule 1, widened as #9 item 1 recommends; open), run by the injected `reconcile` when there is one.
|
|
19
|
+
// retry_same is the exception because committing it again resends the same bytes with the same stamp, which
|
|
20
|
+
// replay_same_payload makes safe: an earlier attempt that executed answers Duplicate (already_done) within the
|
|
21
|
+
// window, and TooOld (retry_new, so a reconciliation) once the window has closed.
|
|
22
|
+
// - A reject with code 1, 2 or 3 proves the call did not execute; 4 and 5, and any reject from the management
|
|
23
|
+
// canister, are unknown outcomes (#8, recommended; open). A third answered unknown of an intent needs a
|
|
24
|
+
// reconciliation, so a call that traps every time is not committed forever (#8, item 4).
|
|
25
|
+
// - A `call` that throws is treated as an unknown outcome before the error is passed on: it may have executed.
|
|
26
|
+
// - An update method without `idempotency` has no stamp, so nothing can make a second send safe or match the first in
|
|
27
|
+
// the canister's history: after an unknown outcome (a throw, `unknown`, a reject 4 or 5) its intent is held at
|
|
28
|
+
// `reconcile` with no read-back, and never sent again (spec 5.3: without the tag, never retried automatically).
|
|
29
|
+
// - A read-only update (D-038) is a read: no preview, no confirmation, sent once as an update call.
|
|
30
|
+
|
|
31
|
+
import { COMMIT_TOOL, buildToolPlans, labelId, namesSets, relpathIds, toolDefinitions } from "./plan.mjs";
|
|
32
|
+
import { argTuple, displayArguments, displayValue, hex, readToolInput, withStamp } from "./input.mjs";
|
|
33
|
+
|
|
34
|
+
const MANAGEMENT = "aaaaa-aa";
|
|
35
|
+
/** Reject codes that prove the call did not execute (#8). */
|
|
36
|
+
const NOT_EXECUTED_CODES = new Set([1, 2, 3]);
|
|
37
|
+
/** Answered unknown outcomes (reject 4 or 5) of one intent before a reconciliation is needed (#8, item 4). */
|
|
38
|
+
const MAX_ANSWERED_UNKNOWN = 2;
|
|
39
|
+
/** retry_same answers of one intent before it stops being resent (spec 5.3, consumer rule 3: "at most a few"). */
|
|
40
|
+
const MAX_RETRY_SAME = 3;
|
|
41
|
+
/** Why an update without an idempotency stamp is held after an unknown outcome. */
|
|
42
|
+
const UNSTAMPED =
|
|
43
|
+
"the method has no idempotency stamp, so a second send could run it twice and its history cannot be matched to this attempt; it is not sent again. Read the canister's state to learn whether it ran";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The class of a decoded variant reply: its path against the outcome tag's keys, longest first. The path follows the
|
|
47
|
+
* reply's variant cases, and at a record the one field the keys read there (`Ok.state.Failed`; `buildToolPlans` skips
|
|
48
|
+
* keys that read two fields of one record).
|
|
49
|
+
*/
|
|
50
|
+
export function classifyReply(plan, reply) {
|
|
51
|
+
// keys are relpaths, compared by the positions they resolve to (`(0).Ok`, `.Ok` and `Ok` are one key)
|
|
52
|
+
const keys = Object.entries(plan.outcome.classes ?? {}).map(([k, cls]) => ({ ids: relpathIds(k) ?? [], cls }));
|
|
53
|
+
const path = [];
|
|
54
|
+
const ids = [];
|
|
55
|
+
let node = plan.results[0];
|
|
56
|
+
let value = reply;
|
|
57
|
+
for (;;) {
|
|
58
|
+
let step;
|
|
59
|
+
if (node?.candid === "variant" && value !== null && typeof value === "object" && typeof value.tag === "string") {
|
|
60
|
+
step = value.tag;
|
|
61
|
+
node = node.cases?.[step];
|
|
62
|
+
value = value.value;
|
|
63
|
+
} else if (node?.candid === "record" && value !== null && typeof value === "object") {
|
|
64
|
+
const next = keys.find((k) => k.ids.length > ids.length && ids.every((id, i) => k.ids[i] === id))?.ids[ids.length];
|
|
65
|
+
step = next === undefined ? undefined : Object.keys(node.fields ?? {}).find((f) => labelId(f) === next);
|
|
66
|
+
if (step === undefined || !Object.hasOwn(value, step)) break;
|
|
67
|
+
node = node.fields[step];
|
|
68
|
+
value = value[step];
|
|
69
|
+
} else break;
|
|
70
|
+
path.push(step);
|
|
71
|
+
ids.push(labelId(step));
|
|
72
|
+
}
|
|
73
|
+
for (let n = path.length; n > 0; n--) {
|
|
74
|
+
const want = ids.slice(0, n).join(".");
|
|
75
|
+
const hit = keys.find((k) => k.ids.join(".") === want);
|
|
76
|
+
if (hit) return { cls: hit.cls, path, node, value };
|
|
77
|
+
}
|
|
78
|
+
return { cls: plan.outcome.default ?? "fail", path, node, value };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SUMMARY = {
|
|
82
|
+
replied: "The query answered.",
|
|
83
|
+
read_rejected: "The canister refused the read, so nothing was read.",
|
|
84
|
+
read_unknown: "No answer arrived, so nothing was read. Reading changes nothing; call the tool again to read again.",
|
|
85
|
+
prepared: "Prepared. Nothing has been sent; commit it with its preview_id once the person has seen it.",
|
|
86
|
+
repeated: "This call is already prepared and not settled: the same preview is returned. It has not executed.",
|
|
87
|
+
declined: "Declined: the person did not confirm, so nothing was sent. Do not commit it again without a new instruction.",
|
|
88
|
+
declined_after_unknown: "Declined: nothing was sent this time, but an earlier attempt had an unknown outcome and may have executed. Tell the person; do not prepare a new call.",
|
|
89
|
+
success: "Success: the call was executed.",
|
|
90
|
+
already_done: "Already done: an identical call had already been executed. Treat it as done.",
|
|
91
|
+
retry_same: "Temporarily refused, not executed. Commit the same preview_id again later, or tell the person.",
|
|
92
|
+
retry_new: "The stamp was refused, so it was not executed. A new preview was prepared; show it to the person and commit it.",
|
|
93
|
+
fix_input: "Not executed: correct the input and prepare the call again.",
|
|
94
|
+
fail: "Not executed, and trying again will not help.",
|
|
95
|
+
not_executed: "Rejected before it ran, so it was not executed. Commit the same preview_id again, or tell the person.",
|
|
96
|
+
unknown: "The outcome is unknown: it may have executed. Commit the same preview_id again; do not prepare a new call.",
|
|
97
|
+
reconcile: "An earlier attempt may have executed. Tell the person what happened; do not send or prepare this call again until they decide.",
|
|
98
|
+
error: "Nothing was sent.",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const result = (structured, isError = false, summary = undefined) => {
|
|
102
|
+
const key = summary ?? (structured.outcome === "declined" && structured.earlier_outcome === "unknown" ? "declined_after_unknown" : (structured.outcome ?? structured.status));
|
|
103
|
+
const out = { content: [{ type: "text", text: `${SUMMARY[key] ?? ""}\n${JSON.stringify(structured, null, 2)}` }], structuredContent: structured };
|
|
104
|
+
return isError ? { ...out, isError: true } : out;
|
|
105
|
+
};
|
|
106
|
+
const failure = (message, extra = {}) => result({ status: "error", message, ...extra }, true);
|
|
107
|
+
|
|
108
|
+
/** The value at a names relpath (`Ok`, `(0)`, `label`, `a.b`), walking the resolved nodes beside it; undefined if absent. */
|
|
109
|
+
function walk(node, value, path) {
|
|
110
|
+
for (const step of path.replace(/^\(0\)/, "").split(".").filter(Boolean)) {
|
|
111
|
+
if (value === null || value === undefined || typeof value !== "object") return undefined;
|
|
112
|
+
if (node?.candid === "variant") {
|
|
113
|
+
if (value.tag !== step) return undefined;
|
|
114
|
+
node = node.cases?.[step];
|
|
115
|
+
value = value.value;
|
|
116
|
+
} else if (node?.candid === "record" && Object.hasOwn(value, step)) {
|
|
117
|
+
node = node.fields?.[step];
|
|
118
|
+
value = value[step];
|
|
119
|
+
} else return undefined;
|
|
120
|
+
}
|
|
121
|
+
return { node, value };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A names set's entries from its method's reply (spec 5.3, D-045), or undefined when the reply holds no list. */
|
|
125
|
+
function entriesOf(set, reply) {
|
|
126
|
+
const items = walk(set.results[0], reply, set.args.items);
|
|
127
|
+
if (!items || !Array.isArray(items.value)) return undefined;
|
|
128
|
+
const element = items.node?.element;
|
|
129
|
+
const field = (item, k) => (set.args[k] === undefined ? undefined : walk(element, item, set.args[k])?.value);
|
|
130
|
+
return items.value
|
|
131
|
+
.map((item) => ({ name: field(item, "name"), value: field(item, "value"), show: field(item, "show"), kind: field(item, "kind") }))
|
|
132
|
+
.filter((e) => typeof e.name === "string");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The entries of names set `set` in a reply of the method that lists it (spec 5.3, D-045), as the layer reads them
|
|
137
|
+
* when a call is prepared: `{ name, value, show, kind }` per item, read through the profile's `items`, `name`, `value`,
|
|
138
|
+
* `show` and `kind` relpaths. Undefined when the resolved form has no such set or the reply holds no list.
|
|
139
|
+
*/
|
|
140
|
+
export function nameEntries(resolved, set, reply) {
|
|
141
|
+
const s = namesSets(resolved).get(set);
|
|
142
|
+
return s === undefined ? undefined : entriesOf(s, reply);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {object} config
|
|
147
|
+
* @param {object | object[]} config.resolved candid-profile-resolved v0 documents
|
|
148
|
+
* @param {{ id: string, label?: string, refs?: Record<string, unknown> }[]} config.canisters the canisters the tools
|
|
149
|
+
* may call, each with its resolved dynamic tag values (`{ "query:icrc1_decimals": 8, ... }`)
|
|
150
|
+
* @param {(method: string, value: unknown, args: unknown[]) => Uint8Array | Promise<Uint8Array>} config.encode the
|
|
151
|
+
* method's arguments (candid-core domain shapes) to the bytes that will be sent: candid-core's `encodeArgs` of
|
|
152
|
+
* `args`, the whole argument tuple. `value` is the single argument (the tuple again for a method with more than
|
|
153
|
+
* one), for hosts written before methods with more than one argument had tools
|
|
154
|
+
* @param {(req: { canister: string, method: string, mode: string, arg: Uint8Array, value: unknown, args: unknown[] }) => Promise<
|
|
155
|
+
* { status: "replied", reply: unknown } | { status: "rejected", reject_code: number, message: string } | { status: "unknown", message: string }>} config.call
|
|
156
|
+
* sends `arg`, exactly, as a query or an update call (`mode`); `reply` is the decoded first result value. `value`
|
|
157
|
+
* and `args` are what `arg` encodes, as `encode` gets them, for hosts that send values (ic-reactor 4 today:
|
|
158
|
+
* B3Pay/ic-reactor#876, #877)
|
|
159
|
+
* @param {(o: { canister: string }) => Promise<bigint>} config.now the clock of the canister named, ns since the Unix
|
|
160
|
+
* epoch (canisters on different subnets keep different times)
|
|
161
|
+
* @param {(preview: object) => Promise<boolean | { approved: boolean }>} [config.confirm] asked before every send;
|
|
162
|
+
* required when the form has an update method (there is no auto-approve default)
|
|
163
|
+
* @param {(o: { canister: string, method: string, value: unknown, stamp: bigint, preview: object }) => Promise<
|
|
164
|
+
* { status: "found", block?: bigint | string } | { status: "absent" | "not_yet" } | { status: "unreadable", reason?: string }>} [config.reconcile]
|
|
165
|
+
* consumer rule 1: look for the original in the canister's history (for ICRC-1/2, `@meantjs/reconcile`)
|
|
166
|
+
*/
|
|
167
|
+
export function createToolLayer({ resolved, canisters, encode, call, now, confirm, reconcile }) {
|
|
168
|
+
if (!Array.isArray(canisters) || canisters.length === 0) throw new TypeError("createToolLayer: give at least one canister");
|
|
169
|
+
for (const [name, fn] of [["encode", encode], ["call", call], ["now", now]]) if (typeof fn !== "function") throw new TypeError(`createToolLayer: \`${name}\` must be a function`);
|
|
170
|
+
if (reconcile !== undefined && typeof reconcile !== "function") throw new TypeError("createToolLayer: `reconcile` must be a function");
|
|
171
|
+
const { plans, skipped } = buildToolPlans(resolved);
|
|
172
|
+
const writes = [...plans.values()].some((p) => p.kind === "prepare");
|
|
173
|
+
if (writes && typeof confirm !== "function") throw new TypeError("createToolLayer: `confirm` must be a function (it is awaited before every send)");
|
|
174
|
+
const definitions = toolDefinitions(resolved, { canisters });
|
|
175
|
+
const sets = namesSets(resolved);
|
|
176
|
+
const byId = new Map(canisters.map((c) => [c.id, c]));
|
|
177
|
+
|
|
178
|
+
let counter = 0;
|
|
179
|
+
const previews = new Map();
|
|
180
|
+
const intents = new Map();
|
|
181
|
+
// per intent key: the highest stamp sent (the ordering floor), and every stamp handed out (never reused exactly)
|
|
182
|
+
const lastStamp = new Map();
|
|
183
|
+
const takenStamps = new Map();
|
|
184
|
+
|
|
185
|
+
function canisterOf(input) {
|
|
186
|
+
const id = input?.canister_id;
|
|
187
|
+
if (typeof id !== "string" || !byId.has(id)) return { error: `canister_id must be one of ${[...byId.keys()].join(", ")}` };
|
|
188
|
+
return { canister: byId.get(id) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const withoutCanister = (input) => {
|
|
192
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return input;
|
|
193
|
+
const { canister_id, ...rest } = input;
|
|
194
|
+
return rest;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const needsClock = (plan) => plan.params.some((p) => p.node.tags?.timestamp);
|
|
198
|
+
|
|
199
|
+
/** The entries of each names set the plan's inputs may use, read from the canister now: once per intent (spec 5.3). */
|
|
200
|
+
async function readNames(plan, canister) {
|
|
201
|
+
const out = {};
|
|
202
|
+
for (const p of plan.params) {
|
|
203
|
+
const set = p.node.tags?.named?.args?.set;
|
|
204
|
+
const s = sets.get(set);
|
|
205
|
+
if (s === undefined || Object.hasOwn(out, set)) continue; // an undeclared set is E_NAMES_UNREAD at reading
|
|
206
|
+
const arg = await encode(s.method, undefined, []);
|
|
207
|
+
// a read-only update goes as an update call, as any read of it does (D-038)
|
|
208
|
+
const r = await call({ canister: canister.id, method: s.method, mode: s.replicated ? "update" : "query", arg, value: undefined, args: [] });
|
|
209
|
+
const entries = r.status === "replied" ? entriesOf(s, r.reply) : undefined;
|
|
210
|
+
if (entries !== undefined) out[set] = entries;
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function runQuery(plan, input) {
|
|
216
|
+
const c = canisterOf(input);
|
|
217
|
+
if (c.error) return failure(c.error);
|
|
218
|
+
const clock = needsClock(plan) ? await now({ canister: c.canister.id }) : undefined;
|
|
219
|
+
const names = await readNames(plan, c.canister);
|
|
220
|
+
const read = readToolInput(plan, withoutCanister(input), { refs: c.canister.refs ?? {}, now: clock, names });
|
|
221
|
+
if (read.issues) return failure("the inputs were not read", { issues: read.issues });
|
|
222
|
+
const args = argTuple(plan, read.value);
|
|
223
|
+
const arg = await encode(plan.method, read.value, args);
|
|
224
|
+
// a read-only update goes as an update call, or the certification it declares is lost (spec 5.3, D-038)
|
|
225
|
+
const r = await call({ canister: c.canister.id, method: plan.method, mode: plan.replicated ? "update" : "query", arg, value: read.value, args });
|
|
226
|
+
if (r.status !== "replied") {
|
|
227
|
+
return result({ status: r.status, method: plan.method, canister: c.canister.id, message: r.message, reject_code: r.reject_code }, true, r.status === "rejected" ? "read_rejected" : "read_unknown");
|
|
228
|
+
}
|
|
229
|
+
return result({ status: "replied", method: plan.method, canister: c.canister.id, value: displayValue(plan.results[0], r.reply, c.canister.refs ?? {}) });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const previewView = (p) => ({
|
|
233
|
+
preview_id: p.id,
|
|
234
|
+
method: p.plan.method,
|
|
235
|
+
canister: p.canister.id,
|
|
236
|
+
title: p.plan.title,
|
|
237
|
+
effect: p.plan.effect,
|
|
238
|
+
arguments: displayArguments(p.plan, p.value, p.canister.refs ?? {}, p.intent?.names),
|
|
239
|
+
arg_hex: hex(p.bytes),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
async function makePreview(intent, clock, cap) {
|
|
243
|
+
const read = readToolInput(intent.plan, intent.input, { refs: intent.canister.refs ?? {}, now: clock, names: intent.names });
|
|
244
|
+
if (read.issues) return { issues: read.issues };
|
|
245
|
+
let stamp;
|
|
246
|
+
if (intent.plan.idempotency) {
|
|
247
|
+
const earlier = lastStamp.get(intent.key);
|
|
248
|
+
stamp = clock;
|
|
249
|
+
if (earlier !== undefined && stamp <= earlier) stamp = earlier + 1n;
|
|
250
|
+
const capped = cap !== undefined && cap < stamp;
|
|
251
|
+
if (capped) stamp = cap; // consumer rule 2, on the final stamp
|
|
252
|
+
// a stamp declined before any send set no floor (it was never seen), but it is never handed out again; a capped
|
|
253
|
+
// stamp steps down so it stays at most the cap
|
|
254
|
+
let taken = takenStamps.get(intent.key);
|
|
255
|
+
if (taken === undefined) takenStamps.set(intent.key, (taken = new Set()));
|
|
256
|
+
while (taken.has(stamp)) stamp += capped ? -1n : 1n;
|
|
257
|
+
taken.add(stamp);
|
|
258
|
+
}
|
|
259
|
+
const value = withStamp(intent.plan, read.value, stamp);
|
|
260
|
+
const bytes = await encode(intent.plan.method, value, argTuple(intent.plan, value));
|
|
261
|
+
const p = { id: `p${++counter}`, intent, plan: intent.plan, canister: intent.canister, value, bytes, stamp, state: "open" };
|
|
262
|
+
previews.set(p.id, p);
|
|
263
|
+
intent.preview = p;
|
|
264
|
+
return { preview: p };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function runPrepare(plan, input) {
|
|
268
|
+
const c = canisterOf(input);
|
|
269
|
+
if (c.error) return failure(c.error);
|
|
270
|
+
const raw = withoutCanister(input);
|
|
271
|
+
const clock = await now({ canister: c.canister.id });
|
|
272
|
+
// the names as they are now: the intent keeps them, so every preview of it reads the same values
|
|
273
|
+
const names = await readNames(plan, c.canister);
|
|
274
|
+
const read = readToolInput(plan, raw, { refs: c.canister.refs ?? {}, now: clock, names });
|
|
275
|
+
if (read.issues) return failure("the call was not prepared", { issues: read.issues });
|
|
276
|
+
const key = JSON.stringify([c.canister.id, plan.method, read.identity]);
|
|
277
|
+
let existing = intents.get(key);
|
|
278
|
+
// A preparation of the same intent still encoding: wait for it, then answer as for any repeat (one intent, one stamp).
|
|
279
|
+
if (existing?.state === "preparing") {
|
|
280
|
+
await existing.ready.catch(() => {});
|
|
281
|
+
existing = intents.get(key);
|
|
282
|
+
}
|
|
283
|
+
if (existing !== undefined && ["prepared", "unknown", "reconcile"].includes(existing.state)) {
|
|
284
|
+
if (existing.state === "reconcile") return result({ outcome: "reconcile", preview: previewView(existing.preview), finding: existing.finding }, true);
|
|
285
|
+
// after an unknown outcome the call may have executed: say so, never "not executed"
|
|
286
|
+
if (existing.state === "unknown" || existing.hadUnknown) return result({ status: "repeated", outcome: "unknown", preview: previewView(existing.preview) });
|
|
287
|
+
return result({ status: "repeated", preview: previewView(existing.preview) });
|
|
288
|
+
}
|
|
289
|
+
// Registered before the first await, so a concurrent preparation of it finds it.
|
|
290
|
+
const intent = { key, plan, canister: c.canister, input: raw, names, state: "preparing", hadUnknown: false, answeredUnknown: 0, retriedSame: 0, attempts: 0, busy: false };
|
|
291
|
+
const previous = existing;
|
|
292
|
+
intents.set(key, intent);
|
|
293
|
+
let made;
|
|
294
|
+
intent.ready = (async () => {
|
|
295
|
+
made = await makePreview(intent, clock);
|
|
296
|
+
})();
|
|
297
|
+
try {
|
|
298
|
+
await intent.ready;
|
|
299
|
+
} catch (e) {
|
|
300
|
+
if (previous === undefined) intents.delete(key);
|
|
301
|
+
else intents.set(key, previous);
|
|
302
|
+
throw e;
|
|
303
|
+
}
|
|
304
|
+
if (made.issues) {
|
|
305
|
+
if (previous === undefined) intents.delete(key);
|
|
306
|
+
else intents.set(key, previous);
|
|
307
|
+
return failure("the call was not prepared", { issues: made.issues });
|
|
308
|
+
}
|
|
309
|
+
intent.state = "prepared";
|
|
310
|
+
return result({ status: "prepared", preview: previewView(made.preview) });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function runReconcile(intent, caseName) {
|
|
314
|
+
const p = intent.preview;
|
|
315
|
+
// fail closed: until the read-back settles it, no commit sends again, even if the hook throws (consumer rule 1)
|
|
316
|
+
intent.state = "reconcile";
|
|
317
|
+
let finding;
|
|
318
|
+
try {
|
|
319
|
+
if (!p.plan.idempotency) finding = { status: "unreadable", reason: UNSTAMPED };
|
|
320
|
+
else if (reconcile === undefined) finding = { status: "unreadable", reason: "this tool layer has no reconcile hook" };
|
|
321
|
+
else finding = await reconcile({ canister: p.canister.id, method: p.plan.method, value: p.value, stamp: p.stamp, preview: previewView(p) });
|
|
322
|
+
} catch (e) {
|
|
323
|
+
finding = { status: "unreadable", reason: `the reconcile hook failed: ${e?.message ?? String(e)}` };
|
|
324
|
+
}
|
|
325
|
+
if (finding === null || typeof finding !== "object") finding = { status: "unreadable", reason: "the reconcile hook gave no answer" };
|
|
326
|
+
const view = { status: finding.status, ...(finding.block === undefined ? {} : { block: String(finding.block) }), ...(finding.reason === undefined ? {} : { reason: finding.reason }), ...(caseName ? { after: caseName } : {}) };
|
|
327
|
+
if (finding.status === "found") {
|
|
328
|
+
intent.state = "done";
|
|
329
|
+
return result({ outcome: "already_done", preview_id: p.id, reconciled: view });
|
|
330
|
+
}
|
|
331
|
+
// absent: the read-back is conclusive (the hook answers not_yet while an attempt can still land), so the intent is
|
|
332
|
+
// settled and may be prepared again with a new stamp, after the person decides
|
|
333
|
+
intent.state = finding.status === "absent" ? "settled" : "reconcile";
|
|
334
|
+
intent.finding = view;
|
|
335
|
+
return result({ outcome: "reconcile", preview_id: p.id, finding: view }, true);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function runCommit(input) {
|
|
339
|
+
const id = input?.preview_id;
|
|
340
|
+
if (input === null || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((k) => k !== "preview_id")) return failure("commit takes one input, preview_id");
|
|
341
|
+
const p = typeof id === "string" ? previews.get(id) : undefined;
|
|
342
|
+
if (p === undefined) return failure(`${JSON.stringify(id)} is not a preview_id from this tool layer`);
|
|
343
|
+
if (p.state === "superseded") return failure(`${p.id} was replaced by ${p.successor}, which needs the person's confirmation`, { successor: p.successor });
|
|
344
|
+
const intent = p.intent;
|
|
345
|
+
if (intent.state === "done") return result({ outcome: "already_done", preview_id: p.id, sent: false });
|
|
346
|
+
if (intent.state === "settled") return failure(`${p.id} is settled; prepare the call again for a new attempt`);
|
|
347
|
+
if (intent.busy) return failure(`${p.id} is being committed already`);
|
|
348
|
+
intent.busy = true;
|
|
349
|
+
try {
|
|
350
|
+
if (intent.state === "reconcile") return await runReconcile(intent);
|
|
351
|
+
const answer = await confirm(previewView(p));
|
|
352
|
+
const approved = answer === true || (answer !== null && typeof answer === "object" && answer.approved === true);
|
|
353
|
+
if (!approved) {
|
|
354
|
+
if (intent.attempts === 0) intent.state = "settled";
|
|
355
|
+
// an earlier unknown attempt may have executed: the decline sends nothing, but it does not make the call unsent
|
|
356
|
+
return result({ outcome: "declined", preview_id: p.id, sent: false, ...(intent.hadUnknown ? { earlier_outcome: "unknown" } : {}) });
|
|
357
|
+
}
|
|
358
|
+
intent.attempts++;
|
|
359
|
+
// the floor counts sent stamps only, and never moves down: a capped re-stamp must not let a later intent reuse a stamp that ran
|
|
360
|
+
const floor = lastStamp.get(intent.key);
|
|
361
|
+
if (p.stamp !== undefined && (floor === undefined || p.stamp > floor)) lastStamp.set(intent.key, p.stamp);
|
|
362
|
+
let r;
|
|
363
|
+
try {
|
|
364
|
+
r = await call({ canister: p.canister.id, method: p.plan.method, mode: "update", arg: p.bytes, value: p.value, args: argTuple(p.plan, p.value) });
|
|
365
|
+
} catch (e) {
|
|
366
|
+
// A call that throws (a network timeout, in most agents) may have executed: it is an unknown outcome, answered as
|
|
367
|
+
// one (spec section 4: repeat the same commit), not passed on as an error the agent could take for "not sent"
|
|
368
|
+
r = { status: "unknown", message: String(e?.message ?? e) };
|
|
369
|
+
}
|
|
370
|
+
return await settle(intent, p, r);
|
|
371
|
+
} finally {
|
|
372
|
+
intent.busy = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function settle(intent, p, r) {
|
|
377
|
+
const base = { preview_id: p.id, method: p.plan.method, canister: p.canister.id };
|
|
378
|
+
if (r.status === "unknown") {
|
|
379
|
+
intent.hadUnknown = true;
|
|
380
|
+
if (!p.plan.idempotency) return runReconcile(intent, "unknown");
|
|
381
|
+
intent.state = "unknown";
|
|
382
|
+
return result({ outcome: "unknown", ...base, message: r.message }, true);
|
|
383
|
+
}
|
|
384
|
+
if (r.status === "rejected") {
|
|
385
|
+
if (NOT_EXECUTED_CODES.has(r.reject_code) && p.canister.id !== MANAGEMENT) {
|
|
386
|
+
return result({ outcome: "not_executed", ...base, reject_code: r.reject_code, message: r.message }, true);
|
|
387
|
+
}
|
|
388
|
+
intent.hadUnknown = true;
|
|
389
|
+
intent.answeredUnknown++;
|
|
390
|
+
if (!p.plan.idempotency || intent.answeredUnknown > MAX_ANSWERED_UNKNOWN) return runReconcile(intent, `reject ${r.reject_code}`);
|
|
391
|
+
intent.state = "unknown";
|
|
392
|
+
return result({ outcome: "unknown", ...base, reject_code: r.reject_code, message: r.message }, true);
|
|
393
|
+
}
|
|
394
|
+
const c = classifyReply(p.plan, r.reply);
|
|
395
|
+
const caseName = c.path.join(".");
|
|
396
|
+
const details = displayValue(c.node, c.value, p.canister.refs ?? {});
|
|
397
|
+
const answer = { ...base, case: caseName, ...(details === null ? {} : { details }) };
|
|
398
|
+
if (c.cls === "success" || c.cls === "already_done") {
|
|
399
|
+
intent.state = "done";
|
|
400
|
+
return result({ outcome: c.cls, ...answer });
|
|
401
|
+
}
|
|
402
|
+
if (intent.hadUnknown && c.cls !== "retry_same") return runReconcile(intent, caseName);
|
|
403
|
+
switch (c.cls) {
|
|
404
|
+
case "retry_same":
|
|
405
|
+
// consumer rule 3: the retries stop after a few. With an earlier unknown outcome the call may have executed, so
|
|
406
|
+
// rule 1 applies; without one every answer said "not executed", so the intent settles and a new attempt is a
|
|
407
|
+
// new preparation the person confirms again
|
|
408
|
+
if (++intent.retriedSame >= MAX_RETRY_SAME) {
|
|
409
|
+
if (intent.hadUnknown) return runReconcile(intent, caseName);
|
|
410
|
+
intent.state = "settled";
|
|
411
|
+
return result({ outcome: "fail", ...answer, retries_spent: intent.retriedSame }, true);
|
|
412
|
+
}
|
|
413
|
+
return result({ outcome: "retry_same", ...answer }, true);
|
|
414
|
+
case "retry_new": {
|
|
415
|
+
// consumer rule 2 caps only a CreatedInFuture re-stamp, by the case the reply named, never by a field's name alone
|
|
416
|
+
const createdInFuture = c.path[c.path.length - 1] === "CreatedInFuture";
|
|
417
|
+
const reported = createdInFuture && c.value !== null && typeof c.value === "object" && typeof c.value.ledger_time === "bigint" ? c.value.ledger_time : undefined;
|
|
418
|
+
const made = await makePreview(intent, await now({ canister: p.canister.id }), reported);
|
|
419
|
+
if (made.issues) {
|
|
420
|
+
intent.state = "settled";
|
|
421
|
+
return result({ outcome: "fix_input", ...answer, issues: made.issues }, true);
|
|
422
|
+
}
|
|
423
|
+
p.state = "superseded";
|
|
424
|
+
p.successor = made.preview.id;
|
|
425
|
+
intent.attempts = 0;
|
|
426
|
+
return result({ outcome: "retry_new", ...answer, next: previewView(made.preview) }, true);
|
|
427
|
+
}
|
|
428
|
+
default:
|
|
429
|
+
intent.state = "settled";
|
|
430
|
+
return result({ outcome: c.cls === "fix_input" ? "fix_input" : "fail", ...answer }, true);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const handlers = new Map([[COMMIT_TOOL, runCommit]]);
|
|
435
|
+
for (const plan of plans.values()) handlers.set(plan.name, plan.kind === "read" ? (input) => runQuery(plan, input) : (input) => runPrepare(plan, input));
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
/** MCP tool definitions; a fresh copy each call. */
|
|
439
|
+
listTools: () => JSON.parse(JSON.stringify(definitions)),
|
|
440
|
+
/** The methods the form has that got no tool, and why. */
|
|
441
|
+
skipped: skipped.map((s) => ({ ...s })),
|
|
442
|
+
/** Run a tool. Resolves to `{ content, structuredContent, isError? }`; throws only when a host function throws. */
|
|
443
|
+
async callTool(name, input) {
|
|
444
|
+
const handler = handlers.get(name);
|
|
445
|
+
if (handler === undefined) return failure(`unknown tool ${String(name)}; the tools are ${[...handlers.keys()].sort().join(", ")}`);
|
|
446
|
+
return handler(input);
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|