@meantjs/tools 0.0.0-stage → 0.1.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/src/input.mjs ADDED
@@ -0,0 +1,612 @@
1
+ // Reading a tool call's inputs into the Candid argument (#16, item 2), and showing values back.
2
+ //
3
+ // Every input is text in human units, read by the format library (profile spec 5.3), so nothing is rounded. The
4
+ // argument comes back in candid-core's domain shapes, the value `encodeArgs` (and so ic-reactor) takes: `T | null`
5
+ // for an opt, `bigint` for nat, int, nat64 and int64, `number` for the narrower integers, `Uint8Array` for a blob,
6
+ // a principal as its canonical text (candid-core's `Principal` is a branded string), `string` for text, `boolean`
7
+ // for bool, an object for a record and `{ tag, value }` for a variant. A method with more than one argument gets the
8
+ // argument tuple, an array.
9
+ //
10
+ // Beyond the value tags (D-050, proposal w6-adoption.md item 2): a `text` position reads the text as given, within
11
+ // its `text` tag's `max_len` and `pattern`; a `bool` position reads "true" or "false"; and a record, variant or vec
12
+ // position with no value tag is one input given as JSON (an object, `{ "tag", "value" }`, an array) whose leaves are
13
+ // text read by these same rules.
14
+
15
+ import { CODES, FormatError, VALUE_TAGS, asUint8Array, formatterFor, ownerOf, parseDuration, principalFromText, principalToText, resolveName } from "@meantjs/format";
16
+
17
+ const NARROW = new Set(["nat8", "nat16", "nat32", "int8", "int16", "int32"]);
18
+ const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
19
+ export const hex = (bytes) => {
20
+ let s = "";
21
+ for (const b of bytes) s += HEX[b];
22
+ return s;
23
+ };
24
+ const isZero = (bytes) => bytes === null || bytes === undefined || bytes.every((b) => b === 0);
25
+
26
+ const PRINCIPAL_EXAMPLES = ["ryjl3-tyaaa-aaaaa-aaaba-cai", "aaaaa-aa"];
27
+ /** The format library's bound on any text it parses (bounded work, profile spec 11.6). */
28
+ const MAX_INPUT_CHARS = 1024;
29
+
30
+ /** `safeParse` over a reader's `parse`: a FormatError becomes `{ ok: false, error }`, as the format library's do. */
31
+ const safely = (parse) => (input) => {
32
+ try {
33
+ return { ok: true, value: parse(input) };
34
+ } catch (e) {
35
+ if (e instanceof FormatError) return { ok: false, error: { code: e.code, message: e.message } };
36
+ throw e;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * A `principal` position with no value tag reads its one textual form: lowercase base32 in groups of five with a
42
+ * CRC-32 checksum, so it needs no profile meaning to be read exactly (profile spec 5.3 has no tag for it, and an
43
+ * agent tool falls back to raw Candid where a stale profile's value tags are dropped, section 7; a principal on its
44
+ * own renders as its text, section 12 rule 4).
45
+ * The value is the canonical text, candid-core's `Principal`. Same shape as a format-library Formatter.
46
+ */
47
+ const PRINCIPAL_READER = Object.freeze({
48
+ tag: "principal",
49
+ args: Object.freeze({}),
50
+ candid: "principal",
51
+ parse(input) {
52
+ if (typeof input !== "string") throw new FormatError(CODES.TYPE, `principal must be a string, got ${typeof input}`);
53
+ return principalToText(principalFromText(input.trim()));
54
+ },
55
+ safeParse: (input) => safely(PRINCIPAL_READER.parse)(input),
56
+ describe: () => ({
57
+ summary: `A principal (a canister or user id) in its textual form: groups of five letters a to z and digits 2 to 7 separated by dashes, ending with a shorter group, with a checksum that catches a mistyped character. For example ${PRINCIPAL_EXAMPLES.map((e) => `"${e}"`).join(" or ")}.`,
58
+ examples: [...PRINCIPAL_EXAMPLES],
59
+ // necessary only: everything parse accepts matches it; the checksum is checked by parse
60
+ pattern: "^\\s*[A-Za-z2-7]{5}(?:-[A-Za-z2-7]{5})*(?:-[A-Za-z2-7]{1,5})?\\s*$",
61
+ }),
62
+ });
63
+
64
+ /** Inclusive bounds of the Candid integer types; null is unbounded. */
65
+ const INTEGER_BOUNDS = {
66
+ nat: [0n, null],
67
+ nat8: [0n, 2n ** 8n - 1n],
68
+ nat16: [0n, 2n ** 16n - 1n],
69
+ nat32: [0n, 2n ** 32n - 1n],
70
+ nat64: [0n, 2n ** 64n - 1n],
71
+ int: [null, null],
72
+ int8: [-(2n ** 7n), 2n ** 7n - 1n],
73
+ int16: [-(2n ** 15n), 2n ** 15n - 1n],
74
+ int32: [-(2n ** 31n), 2n ** 31n - 1n],
75
+ int64: [-(2n ** 63n), 2n ** 63n - 1n],
76
+ };
77
+
78
+ /**
79
+ * An integer position with no value tag but a `describe` reads decimal digits as they are: no unit, no conversion,
80
+ * within the Candid type's bounds (an action id, a page size). The profile's words say what the number is; without
81
+ * them a bare number means nothing to an agent, so such a position stays without a rule.
82
+ * The value is the canonical decimal text, as the format library's are. Same shape as a format-library Formatter.
83
+ */
84
+ function integerReader(candid) {
85
+ const [min, max] = INTEGER_BOUNDS[candid];
86
+ const signed = min === null || min < 0n;
87
+ const digits = signed ? /^(?:0|-?[1-9][0-9]*)$/ : /^(?:0|[1-9][0-9]*)$/;
88
+ const range = max === null ? (min === null ? "of any size" : `from ${min} up`) : `from ${min} to ${max}`;
89
+ const examples = signed ? ["1", "-42"] : ["1", "42"];
90
+ const reader = {
91
+ tag: "integer",
92
+ args: Object.freeze({}),
93
+ candid,
94
+ parse(input) {
95
+ if (typeof input !== "string") throw new FormatError(CODES.TYPE, `${candid} must be a string of decimal digits, got ${typeof input}`);
96
+ if (input.length > MAX_INPUT_CHARS) throw new FormatError(CODES.TOO_LONG, `the number is longer than ${MAX_INPUT_CHARS} characters`);
97
+ const text = input.trim();
98
+ if (!digits.test(text)) {
99
+ if (!signed && /^-[1-9][0-9]*$/.test(text)) throw new FormatError(CODES.RANGE, `${text} is negative but the Candid type ${candid} is unsigned`);
100
+ throw new FormatError(CODES.SYNTAX, `cannot read ${JSON.stringify(text)} as a whole number: write decimal digits only, without a unit, separators, an exponent or leading zeros`);
101
+ }
102
+ const v = BigInt(text);
103
+ if ((min !== null && v < min) || (max !== null && v > max)) throw new FormatError(CODES.RANGE, `${text} is outside the range of the Candid type ${candid}, ${range}`);
104
+ return v.toString();
105
+ },
106
+ safeParse: (input) => safely(reader.parse)(input),
107
+ describe: () => ({
108
+ summary: `A whole number in decimal digits, ${range}, written as it is: no unit, separators, exponent or leading zeros. For example ${examples.map((e) => `"${e}"`).join(" or ")}.`,
109
+ examples: [...examples],
110
+ // necessary only: everything parse accepts matches it; the range is checked by parse
111
+ pattern: signed ? "^\\s*(?:0|-?[1-9][0-9]*)\\s*$" : "^\\s*(?:0|[1-9][0-9]*)\\s*$",
112
+ }),
113
+ };
114
+ return Object.freeze(reader);
115
+ }
116
+ const INTEGER_READERS = Object.fromEntries(Object.keys(INTEGER_BOUNDS).map((t) => [t, integerReader(t)]));
117
+ /** The Candid integer types. */
118
+ export const INTEGER_TYPES = new Set(Object.keys(INTEGER_BOUNDS));
119
+
120
+ /** The longest text a `text` position without a `max_len` takes (bounded work, profile spec 11.6). */
121
+ export const MAX_TEXT_CHARS = 1024;
122
+ /** The longest `text.pattern` the tool layer runs, and the shapes it refuses to run (see `patternWithoutRule`). */
123
+ const MAX_PATTERN_CHARS = 256;
124
+
125
+ /**
126
+ * Why a `text.pattern` is not run, or undefined. A pattern is the profile author's code, run on input an agent wrote,
127
+ * and JavaScript's regular expressions backtrack: a quantified group that holds a quantifier (`(a+)+`), a back
128
+ * reference or a lookaround can take exponential time. Those are refused, and so is a pattern that does not compile.
129
+ * This bounds the classic blowups, not every slow pattern (a linear-time engine would; open in D-050).
130
+ */
131
+ function patternWithoutRule(pattern) {
132
+ if (typeof pattern !== "string") return "the text pattern is not a string";
133
+ if (pattern.length > MAX_PATTERN_CHARS) return `the text pattern is longer than ${MAX_PATTERN_CHARS} characters`;
134
+ try {
135
+ new RegExp(pattern, "u");
136
+ } catch (e) {
137
+ return `the text pattern is not an ECMAScript regular expression: ${e.message}`;
138
+ }
139
+ if (/\\[1-9]|\\k</.test(pattern)) return "the text pattern has a back reference";
140
+ // escapes and character classes are one character each; what is left is groups, quantifiers and literals
141
+ let s = pattern.replace(/\\./gsu, "x").replace(/\[[^\]]*\]/gu, "x");
142
+ if (/\(\?<?[=!]/.test(s)) return "the text pattern has a lookaround";
143
+ s = s.replace(/\(\?(?::|<[A-Za-z][A-Za-z0-9]*>)/gu, "(");
144
+ // innermost group first: a group holding a variable quantifier that is itself quantified can blow up; otherwise it
145
+ // stands for one character, repeated when it repeats, and the group around it is looked at next
146
+ const VARIABLE = /[*+?]|\{\d+,/u;
147
+ // `?` and `{0,1}` take a group at most once; `*`, `+`, `{n}` with n > 1 and `{n,}` repeat it
148
+ const repeats = (q) => q === "*" || q === "+" || (q?.startsWith("{") && (/,\}$/u.test(q) || Math.max(...q.slice(1, -1).split(",").map(Number)) > 1));
149
+ for (let m; (m = /\(([^()]*)\)(\{\d+(?:,\d*)?\}|[*+?])?/u.exec(s)) !== null; ) {
150
+ const [whole, inner, outer] = m;
151
+ const varies = VARIABLE.test(inner);
152
+ if (varies && repeats(outer)) return "the text pattern repeats a group that repeats (it could take exponential time)";
153
+ s = s.slice(0, m.index) + (varies || (outer !== undefined && VARIABLE.test(outer)) ? "x*" : "x") + s.slice(m.index + whole.length);
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ const codePoints = (text) => {
159
+ let n = 0;
160
+ for (const _ of text) n++;
161
+ return n;
162
+ };
163
+
164
+ /**
165
+ * A `text` position reads the text as given (D-050): nothing is trimmed or changed. It must be well-formed Unicode (a
166
+ * lone surrogate has no UTF-8 encoding), at most `text.max_len` (and `range.max_len`) characters, counted as code
167
+ * points as JSON Schema counts them, and MAX_TEXT_CHARS when no `max_len` is given; at least `range.min_len`; and
168
+ * matched by `text.pattern` (an unanchored ECMAScript expression, as JSON Schema's `pattern`) when there is one.
169
+ * `format: "url"` needs an absolute http or https URL, `format: "email"` one `@` between non-empty parts without
170
+ * spaces; `plain` and `markdown` take any text. Same shape as a format-library Formatter, plus `isValue` for `named`.
171
+ * @throws {FormatError} E_ARG when the tag's pattern is one the layer does not run
172
+ */
173
+ function textReader(node) {
174
+ const t = node.tags?.text?.args ?? {};
175
+ const r = node.tags?.range?.args ?? {};
176
+ const limits = [t.max_len, r.max_len].filter((n) => Number.isInteger(n));
177
+ const max = limits.length ? Math.min(...limits) : MAX_TEXT_CHARS;
178
+ const min = Number.isInteger(r.min_len) ? r.min_len : 0;
179
+ const why = t.pattern === undefined ? undefined : patternWithoutRule(t.pattern);
180
+ if (why) throw new FormatError(CODES.ARG, why);
181
+ const pattern = t.pattern === undefined ? null : new RegExp(t.pattern, "u");
182
+ const format = t.format ?? "plain";
183
+ const reader = {
184
+ tag: "text",
185
+ args: Object.freeze({ ...t }),
186
+ candid: "text",
187
+ parse(input) {
188
+ if (typeof input !== "string") throw new FormatError(CODES.TYPE, `text must be a string, got ${typeof input}`);
189
+ if (input.length > 2 * max) throw new FormatError(CODES.MAX_LEN, `the text is longer than ${max} characters`); // before counting
190
+ if (!input.isWellFormed()) throw new FormatError(CODES.UTF8, "the text has a lone surrogate, which has no UTF-8 encoding");
191
+ const n = codePoints(input);
192
+ if (n > max) throw new FormatError(CODES.MAX_LEN, `the text is ${n} characters, longer than ${max}`);
193
+ if (n < min) throw new FormatError(CODES.RANGE, `the text is ${n} characters, shorter than ${min}`);
194
+ if (pattern !== null && !pattern.test(input)) throw new FormatError(CODES.SYNTAX, `the text does not match the pattern ${t.pattern}`);
195
+ if (format === "url" && !isWebUrl(input)) throw new FormatError(CODES.SYNTAX, "the text must be an absolute http or https URL");
196
+ if (format === "email" && !/^[^@\s]+@[^@\s]+$/u.test(input)) throw new FormatError(CODES.SYNTAX, "the text must be an email address: one @ between non-empty parts, without spaces");
197
+ return input;
198
+ },
199
+ safeParse: (input) => safely(reader.parse)(input),
200
+ /** For a `named` position (spec 5.3): only text the pattern matches is a value; without a pattern, no text is. */
201
+ isValue: (input) => pattern !== null && reader.safeParse(input).ok,
202
+ describe: () => ({
203
+ summary: `Text, exactly as given${format === "url" ? ": an absolute http or https URL" : format === "email" ? ": an email address" : format === "markdown" ? " (Markdown)" : ""}, at most ${max} characters${min ? ` and at least ${min}` : ""}${pattern ? `, matching the pattern ${t.pattern}` : ""}.`,
204
+ examples: [],
205
+ ...(pattern ? { pattern: t.pattern } : {}),
206
+ }),
207
+ };
208
+ return Object.freeze(reader);
209
+ }
210
+
211
+ function isWebUrl(text) {
212
+ try {
213
+ const u = new URL(text);
214
+ return (u.protocol === "https:" || u.protocol === "http:") && u.href.length > 0 && !/\s/u.test(text);
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ /** A `bool` position reads "true" or "false" (D-050), and nothing else: no "yes", "1" or other case. */
221
+ const BOOL_READER = Object.freeze({
222
+ tag: "bool",
223
+ args: Object.freeze({}),
224
+ candid: "bool",
225
+ parse(input) {
226
+ if (typeof input !== "string") throw new FormatError(CODES.TYPE, `bool must be the text "true" or "false", got ${typeof input}`);
227
+ const text = input.trim();
228
+ if (text === "true") return true;
229
+ if (text === "false") return false;
230
+ throw new FormatError(CODES.SYNTAX, `cannot read ${JSON.stringify(input.slice(0, 32))} as a bool: write true or false`);
231
+ },
232
+ safeParse: (input) => safely(BOOL_READER.parse)(input),
233
+ describe: () => ({ summary: 'Yes or no, written "true" or "false".', examples: ["true", "false"], pattern: "^\\s*(?:true|false)\\s*$" }),
234
+ });
235
+
236
+ /**
237
+ * What a position with no value tag is read as, if anything: `principal` for a principal (its one textual form),
238
+ * `integer` for an integer the profile describes, `text` for text, `bool` for a bool. Undefined otherwise.
239
+ */
240
+ export function untaggedReading(node) {
241
+ if (node?.candid === "principal") return "principal";
242
+ if (node?.candid === "text") return "text";
243
+ if (node?.candid === "bool") return "bool";
244
+ if (Object.hasOwn(INTEGER_READERS, node?.candid ?? "") && Object.hasOwn(node.tags ?? {}, "describe")) return "integer";
245
+ return undefined;
246
+ }
247
+
248
+ const valueTagOf = (node) => VALUE_TAGS.find((t) => Object.hasOwn(node?.tags ?? {}, t));
249
+ /** What a position is read as when it is one text input: its value tag, else `untaggedReading`. */
250
+ export const readAs = (node) => valueTagOf(node) ?? untaggedReading(node);
251
+
252
+ /**
253
+ * How one input is read: the format library's formatter for a value-tagged position, the principal or integer reader
254
+ * for an untagged position `untaggedReading` names, or null when the position has none of them (it is not an input).
255
+ * @throws {FormatError} as `formatterFor` does (a missing ref, a bad tag argument)
256
+ */
257
+ export function inputReader(node, refs = {}) {
258
+ const f = formatterFor(node, { refs });
259
+ if (f !== null) return f;
260
+ switch (untaggedReading(node)) {
261
+ case "principal":
262
+ return PRINCIPAL_READER;
263
+ case "integer":
264
+ return INTEGER_READERS[node.candid];
265
+ case "text":
266
+ return textReader(node);
267
+ case "bool":
268
+ return BOOL_READER;
269
+ default:
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /** A format-library value as candid-core's value for the position. */
275
+ export function toCandid(node, value) {
276
+ if (node.candid === "principal" || node.candid === "text" || node.candid === "bool") return value; // canonical text, the text, a boolean
277
+ if (typeof value === "string") return NARROW.has(node.candid) ? Number(value) : BigInt(value);
278
+ return value; // account { owner, subaccount }, subaccount and bytes (Uint8Array)
279
+ }
280
+
281
+ /** What an input adds to the identity of an intent (spec 5.3: the converted arguments; a relative time by its duration). */
282
+ function identityOf(f, input, value) {
283
+ switch (f.tag) {
284
+ case "timestamp": {
285
+ // `+PT24H` moves with the clock; its duration does not ("+P7D" and "+PT168H" are one intent)
286
+ const text = input.trim();
287
+ return text.startsWith("+") ? `rel:${parseDuration(text.slice(1), { unit: f.args.unit, candid: f.candid })}` : value;
288
+ }
289
+ case "account":
290
+ return `${value.owner}|${isZero(value.subaccount) ? "" : hex(value.subaccount)}`;
291
+ case "subaccount":
292
+ return isZero(value) ? null : hex(value); // "#0" is the same account as no subaccount
293
+ case "bytes":
294
+ return hex(value);
295
+ default:
296
+ return value;
297
+ }
298
+ }
299
+
300
+ /** The most elements one `vec` input takes, and the most values one nested input holds (bounded work, spec 11.6). */
301
+ export const MAX_VEC_ITEMS = 256;
302
+ export const MAX_NESTED_VALUES = 4096;
303
+ const COMPOSITE = new Set(["record", "variant", "vec"]);
304
+ const NO_VALUE = new Set(["null", "reserved"]);
305
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
306
+
307
+ /**
308
+ * How a field inside a nested input is filled: `param` (read from the input), `auto` (an `auto` default of null) or
309
+ * `hidden` (`ui.hidden`), both left null and not part of the input; or why it has no rule. The same rules as the
310
+ * top-level fields (plan.mjs), less the idempotency stamp, which is a top-level field.
311
+ */
312
+ export function nestedRole(name, node) {
313
+ const tags = node.tags ?? {};
314
+ const def = tags.default?.args;
315
+ if (def !== undefined && def.value !== "null") return { skip: `${name}: a default value other than null has no rule` };
316
+ if (def?.mode === "auto") return node.optional ? { role: "auto" } : { skip: `${name}: an auto default needs an optional position` };
317
+ if (tags.ui?.args?.hidden === true) return node.optional ? { role: "hidden" } : { skip: `${name}: a hidden position must be optional` };
318
+ return { role: "param" };
319
+ }
320
+
321
+ /**
322
+ * Why a position has no rule as an input, or undefined: a position `readAs` names is one text; a record, variant or
323
+ * vec with no value tag is read as JSON (D-050) when every position inside it has a rule. `null` and `reserved`
324
+ * positions take no input. Labels that are numbers (`#3`, tuples) have no rule: their JSON spelling is not settled.
325
+ */
326
+ export function inputWithoutRule(node, at) {
327
+ if (readAs(node) === "text") {
328
+ // a pattern the layer does not run is found here, when the tools are made, not when an agent first types
329
+ try {
330
+ textReader(node);
331
+ } catch (e) {
332
+ return `${at}: ${e.message}`;
333
+ }
334
+ }
335
+ if (readAs(node)) return undefined;
336
+ if (node.recursive) return `${at}: a recursive position has no rule`;
337
+ if (NO_VALUE.has(node.candid)) return undefined;
338
+ if (!COMPOSITE.has(node.candid)) {
339
+ return `${at}: no rule for a ${node.candid} position without a value tag${INTEGER_TYPES.has(node.candid) ? " or a describe" : ""}`;
340
+ }
341
+ if (node.candid === "vec") return inputWithoutRule(node.element ?? {}, `${at}[]`);
342
+ const members = Object.entries((node.candid === "record" ? node.fields : node.cases) ?? {});
343
+ if (node.candid === "variant" && members.length === 0) return `${at}: a variant without cases has no rule`;
344
+ for (const [name, child] of members) {
345
+ if (name.startsWith("#")) return `${at}.${name}: a numbered ${node.candid === "record" ? "field" : "case"} has no rule`;
346
+ if (node.candid === "record") {
347
+ const c = nestedRole(`${at}.${name}`, child);
348
+ if (c.skip) return c.skip;
349
+ if (c.role !== "param") continue;
350
+ }
351
+ const why = inputWithoutRule(child, `${at}.${name}`);
352
+ if (why) return why;
353
+ }
354
+ return undefined;
355
+ }
356
+
357
+ /**
358
+ * One text input at a position `readAs` names: `{ value, identity }` or `{ issue }`.
359
+ */
360
+ function readLeaf(node, text, { refs, now, names }) {
361
+ let f;
362
+ try {
363
+ f = inputReader(node, refs);
364
+ } catch (e) {
365
+ return { issue: { code: e.code ?? "E_ARG", message: e.message } };
366
+ }
367
+ const named = node.tags?.named?.args;
368
+ if (named) {
369
+ const found = readName(f, named, names[named.set], text, now);
370
+ if (found.issue) return { issue: found.issue };
371
+ if (found.value !== undefined) {
372
+ // the entry's value is already the position's Candid value, in the canister's own units (an amount in base
373
+ // units): it is sent as it is, never read again as typed input. Its identity is the one the same value typed
374
+ // has, so a name and the value it stands for are one intent.
375
+ const id = identityOf(f, "", forFormat(f, found.value));
376
+ return { value: found.value, identity: typeof id === "number" ? String(id) : plain(id) };
377
+ }
378
+ }
379
+ const r = f.safeParse(text, { now });
380
+ if (!r.ok) return { issue: r.error };
381
+ return { value: toCandid(node, r.value), identity: identityOf(f, text, r.value) };
382
+ }
383
+
384
+ /**
385
+ * One input at any position with a rule (`inputWithoutRule`): a text, or for a record, variant or vec JSON whose
386
+ * leaves are texts. Issues go to `ctx.issues` with their path; the result is `{ value, identity }`, or undefined after
387
+ * an issue.
388
+ */
389
+ function readInput(node, input, path, param, ctx) {
390
+ const issue = (code, message) => {
391
+ ctx.issues.push({ param, path, code, message });
392
+ return undefined;
393
+ };
394
+ if (++ctx.count > MAX_NESTED_VALUES) {
395
+ if (ctx.count === MAX_NESTED_VALUES + 1) issue(CODES.TOO_LONG, `${param} holds more than ${MAX_NESTED_VALUES} values`);
396
+ return undefined;
397
+ }
398
+ if (input === undefined || input === null) {
399
+ if (node.optional || NO_VALUE.has(node.candid)) return { value: null, identity: null };
400
+ return issue("E_MISSING", `${path} is required`);
401
+ }
402
+ if (readAs(node)) {
403
+ if (typeof input !== "string") return issue("E_TYPE", `${path} must be text, got ${Array.isArray(input) ? "an array" : typeof input}`);
404
+ const r = readLeaf(node, input, ctx);
405
+ return r.issue ? issue(r.issue.code, r.issue.message) : r;
406
+ }
407
+ switch (node.candid) {
408
+ case "null":
409
+ case "reserved":
410
+ return issue("E_TYPE", `${path} takes no value: leave it out`);
411
+ case "record": {
412
+ if (!isObject(input)) return issue("E_TYPE", `${path} must be a JSON object with the fields ${Object.keys(node.fields ?? {}).join(", ")}`);
413
+ const fields = Object.entries(node.fields ?? {});
414
+ const inputs = new Set(fields.filter(([name, f]) => nestedRole(name, f).role === "param").map(([name]) => name));
415
+ for (const key of Object.keys(input)) if (!inputs.has(key)) issue("E_UNKNOWN_PARAM", `${path} has no input ${key}; its inputs are ${[...inputs].join(", ") || "none"}`);
416
+ const value = [];
417
+ const identity = [];
418
+ for (const [name, f] of fields) {
419
+ if (!inputs.has(name)) {
420
+ value.push([name, null]);
421
+ continue;
422
+ }
423
+ const r = readInput(f, Object.hasOwn(input, name) ? input[name] : undefined, `${path}.${name}`, param, ctx);
424
+ if (r === undefined) continue;
425
+ value.push([name, r.value]);
426
+ identity.push([name, r.identity]);
427
+ }
428
+ // entries, not assignments: a field named `__proto__` must become an own property
429
+ return { value: Object.fromEntries(value), identity };
430
+ }
431
+ case "variant": {
432
+ const cases = node.cases ?? {};
433
+ const names = Object.keys(cases);
434
+ const shape = `{ "tag": one of ${names.join(", ")}, "value": its value }`;
435
+ if (!isObject(input) || typeof input.tag !== "string") return issue("E_TYPE", `${path} must be a JSON object ${shape}`);
436
+ for (const key of Object.keys(input)) if (key !== "tag" && key !== "value") return issue("E_UNKNOWN_PARAM", `${path} must be ${shape}; ${key} is not one of its keys`);
437
+ if (!Object.hasOwn(cases, input.tag)) return issue("E_TYPE", `${path}: ${JSON.stringify(input.tag.slice(0, 64))} is not a case; the cases are ${names.join(", ")}`);
438
+ const arm = cases[input.tag];
439
+ const r = readInput(arm, Object.hasOwn(input, "value") ? input.value : undefined, `${path}.${input.tag}`, param, ctx);
440
+ if (r === undefined) return undefined;
441
+ // candid-core's domain shape: a null-payload arm is a bare { tag }
442
+ const bare = arm.candid === "null" && !arm.optional;
443
+ return { value: bare ? { tag: input.tag } : { tag: input.tag, value: r.value }, identity: [input.tag, r.identity] };
444
+ }
445
+ case "vec": {
446
+ if (!Array.isArray(input)) return issue("E_TYPE", `${path} must be a JSON array`);
447
+ if (input.length > MAX_VEC_ITEMS) return issue(CODES.TOO_LONG, `${path} has ${input.length} elements; at most ${MAX_VEC_ITEMS} are read`);
448
+ const value = [];
449
+ const identity = [];
450
+ let ok = true;
451
+ for (const [i, element] of input.entries()) {
452
+ const r = readInput(node.element, element, `${path}[${i}]`, param, ctx);
453
+ if (r === undefined) ok = false;
454
+ else (value.push(r.value), identity.push(r.identity));
455
+ }
456
+ return ok ? { value, identity } : undefined;
457
+ }
458
+ default:
459
+ return issue("E_TYPE", `${path}: no rule for a ${node.candid} position`);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * @param {object} plan a plan from `buildToolPlans` (the tool)
465
+ * @param {unknown} input the tool call's arguments, without `canister_id`
466
+ * @param {{ refs?: Record<string, unknown>, now?: bigint, names?: Record<string, { name: string, value: unknown, show?: unknown, kind?: unknown }[]> }} [context]
467
+ * the canister's dynamic tag values; its clock (ns), for relative times; the entries of each names set a `named`
468
+ * input may use (spec 5.3, D-045), read when the call is prepared
469
+ * @returns {{ value: unknown, identity: [string, unknown][] } | { issues: { param: string, path: string, code: string, message: string }[] }}
470
+ * `value` is the method's single argument (undefined for a method without one; the argument tuple, an array, for a
471
+ * method with more than one), with the stamp left null; `identity` is what the inputs add to the intent's
472
+ * identity. Issue codes are the format library's, plus E_INPUT (not an object), E_UNKNOWN_PARAM, E_MISSING and
473
+ * E_TYPE (an input that is not text, or JSON of the wrong shape for a record, variant or vec input), and for a
474
+ * `named` input E_NAMES_UNREAD (its set's names were not read), E_NAME_NOT_FOUND and E_NAME_AMBIGUOUS (several
475
+ * entries have the name: ask the person which). An issue inside a JSON input names its place in `path`
476
+ * (`$args[0].command.Follow.followees[1].id`).
477
+ */
478
+ export function readToolInput(plan, input, { refs = {}, now, names = {} } = {}) {
479
+ const object = input === undefined || input === null ? {} : input;
480
+ if (typeof object !== "object" || Array.isArray(object)) {
481
+ return { issues: [{ param: "", path: "$args", code: "E_INPUT", message: `the inputs must be an object with ${plan.params.map((p) => p.name).join(", ") || "no properties"}` }] };
482
+ }
483
+ const issues = [];
484
+ const params = new Set(plan.params.map((p) => p.name));
485
+ for (const key of Object.keys(object)) {
486
+ if (!params.has(key)) issues.push({ param: key, path: "$args", code: "E_UNKNOWN_PARAM", message: `unknown input ${key}; the inputs are ${[...params].join(", ") || "none"}` });
487
+ }
488
+ // field-keyed maps never assign to a plain object: a field may be named `__proto__` (a valid Candid label)
489
+ const values = new Map();
490
+ const identity = [];
491
+ for (const p of plan.params) {
492
+ // own properties only: a field may be named `toString` or `constructor`, and omitting it must read as omitted
493
+ const given = Object.hasOwn(object, p.name) ? object[p.name] : undefined;
494
+ if (given === undefined || given === null) {
495
+ if (p.optional) {
496
+ values.set(p.name, null);
497
+ identity.push([p.name, null]);
498
+ } else issues.push({ param: p.name, path: p.path, code: "E_MISSING", message: `${p.name} is required` });
499
+ continue;
500
+ }
501
+ if (readAs(p.node) && typeof given !== "string") {
502
+ issues.push({ param: p.name, path: p.path, code: "E_TYPE", message: `${p.name} must be text, got ${Array.isArray(given) ? "an array" : typeof given}` });
503
+ continue;
504
+ }
505
+ const r = readInput(p.node, given, p.path, p.name, { refs, now, names, issues, count: 0 });
506
+ if (r === undefined) continue;
507
+ values.set(p.name, r.value);
508
+ identity.push([p.name, r.identity]);
509
+ }
510
+ if (issues.length > 0) return { issues };
511
+ if (plan.argNodes?.length > 1) return { value: plan.fields.map((field) => (field.role === "param" ? values.get(field.name) : null)), identity };
512
+ if (plan.argNode === undefined) return { value: undefined, identity };
513
+ if (plan.rootParam) return { value: values.get(plan.params[0].name), identity };
514
+ if (plan.rootNull) return { value: null, identity };
515
+ const value = Object.fromEntries(plan.fields.map((field) => [field.name, field.role === "param" ? values.get(field.name) : null]));
516
+ return { value, identity };
517
+ }
518
+
519
+ /**
520
+ * A `named` input (spec 5.3, D-045): text that is a value of the position is read as that value; other text is a name,
521
+ * and the one entry of the set with that name gives the value, already the position's Candid value (`{ value }`).
522
+ * A `text` position's values are the texts its pattern matches; without a pattern none is, so every text is looked up,
523
+ * and text that names no entry is passed on as text (spec 5.3, W_NAMED_UNPATTERNED).
524
+ */
525
+ function readName(f, named, entries, text, now) {
526
+ const isValue = f.isValue ?? ((t) => f.safeParse(t, { now }).ok);
527
+ // a value always wins (spec 5.3), so typed values never depend on the names list being read
528
+ if (isValue(text)) return {};
529
+ if (!Array.isArray(entries)) return { issue: { code: "E_NAMES_UNREAD", message: `the names of set ${named.set} were not read, so a name cannot be used here` } };
530
+ const r = resolveName(entries, text, { isValue, by: named.by });
531
+ if (r.error === "not_found") {
532
+ if (f.tag === "text") return {};
533
+ return { issue: { code: "E_NAME_NOT_FOUND", message: `"${text}" is not a value here, and no ${named.set} has that name` } };
534
+ }
535
+ if (r.error === "ambiguous") {
536
+ const which = r.entries.map((e) => JSON.stringify(plain(named.by === "show" ? e.show : e.value))).join(", ");
537
+ return { issue: { code: "E_NAME_AMBIGUOUS", message: `${r.entries.length} entries of ${named.set} are named "${text}" (${which}): ask the person which one` } };
538
+ }
539
+ return { value: r.value };
540
+ }
541
+
542
+ /** Set the stamp field of a prepared argument. */
543
+ export const withStamp = (plan, value, stamp) => (plan.idempotency ? { ...value, [plan.stampField]: stamp } : value);
544
+
545
+ /** The method's whole argument tuple, for `encode` and `call`: empty, the one argument, or the tuple `value` is. */
546
+ export const argTuple = (plan, value) => (plan.argNodes?.length > 1 ? value : plan.argNode === undefined ? [] : [value]);
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // Display
550
+ // ---------------------------------------------------------------------------
551
+
552
+ /** A candid-core value as the format library takes it. */
553
+ function forFormat(f, value) {
554
+ if (f.tag === "amount" || f.tag === "timestamp" || f.tag === "duration") return typeof value === "number" ? BigInt(value) : value;
555
+ return value;
556
+ }
557
+
558
+ /**
559
+ * A candid-core value as JSON-safe data for a model and a person: a value-tagged position becomes `{ text, raw }`
560
+ * (`text` from the format library, `raw` the base units, ns or hex), bigints become decimal strings, blobs hex.
561
+ * A value the profile cannot display (a missing ref, a value out of range) keeps its raw form and says why.
562
+ */
563
+ export function displayValue(node, value, refs = {}, names = {}) {
564
+ if (value === null || value === undefined) return null;
565
+ const named = node?.tags?.named?.args;
566
+ const entries = named ? names[named.set] : undefined;
567
+ if (Array.isArray(entries)) {
568
+ // the name of the one entry this value belongs to, beside the value (spec 5.3: the value is always shown)
569
+ const shown = displayValue({ ...node, tags: { ...node.tags, named: undefined } }, value, refs);
570
+ const owner = ownerOf(entries, value, { by: named.by }); // compared as Candid values: an account by owner and subaccount
571
+ return owner ? { ...(shown !== null && typeof shown === "object" ? shown : { raw: shown }), name: owner.name } : shown;
572
+ }
573
+ if (node === undefined) return plain(value);
574
+ let f = null;
575
+ try {
576
+ f = formatterFor(node, { refs });
577
+ } catch (e) {
578
+ return { raw: plain(value), error: e.message };
579
+ }
580
+ if (f !== null) {
581
+ const r = f.safeDisplay(forFormat(f, value));
582
+ const bytes = asUint8Array(value); // from any realm
583
+ const raw = bytes !== null ? hex(bytes) : f.tag === "account" ? { owner: value.owner, subaccount: value.subaccount ? hex(value.subaccount) : null } : plain(value);
584
+ return r.ok ? { text: r.value, raw } : { raw, error: r.error.message };
585
+ }
586
+ switch (node.candid) {
587
+ case "record": {
588
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, displayValue(node.fields?.[k], v, refs, names)]));
589
+ }
590
+ case "variant":
591
+ return { tag: value.tag, value: displayValue(node.cases?.[value.tag], value.value, refs, names) };
592
+ case "vec":
593
+ return Array.isArray(value) ? value.map((v) => displayValue(node.element, v, refs, names)) : plain(value);
594
+ default:
595
+ return plain(value);
596
+ }
597
+ }
598
+
599
+ /** A prepared argument as `displayValue` shows it; for a method with more than one argument, each in turn. */
600
+ export function displayArguments(plan, value, refs = {}, names = {}) {
601
+ if (plan.argNodes?.length > 1) return plan.argNodes.map((node, i) => displayValue(node, value[i], refs, names));
602
+ return displayValue(plan.argNode, value, refs, names);
603
+ }
604
+
605
+ function plain(value) {
606
+ if (typeof value === "bigint") return value.toString();
607
+ const bytes = asUint8Array(value); // from any realm
608
+ if (bytes !== null) return hex(bytes);
609
+ if (Array.isArray(value)) return value.map(plain);
610
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plain(v)]));
611
+ return value;
612
+ }