@meantjs/tools 0.1.0 → 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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  The five Candid Profile library packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`, `/reconcile`) release together under one version and pin each other exactly; a version appears here even when this package did not change. Release steps: `docs/releasing.md` in the repository.
4
4
 
5
+ ## 0.2.0 — 2026-10-28
6
+
7
+ - One tool per case (D-053): a method whose single argument is a variant with an `intent` or an `effect` on any case
8
+ gets `<method>_<Case>` (a read) or `prepare_<method>_<Case>` for each case, instead of one tool for the method. The
9
+ plan carries `case`; `caseToolName` is exported. Resolved forms without case tags give the same tools as before.
10
+ - An EVM contract's resolved form (`"chain": "evm"`, spec §15) is refused with a `TypeError`: its tools need nonces
11
+ and receipts, a later phase.
12
+ - Types: `ToolDefinition.inputSchema.properties` is `Record<string, JsonSchemaInput>`, not strings only. Since
13
+ 0.1.0 (D-050) a record, variant or vec input is one JSON input whose schema is an object, a `oneOf` of
14
+ `{ tag, value }` cases or an array; the new exported types `JsonSchemaObject`, `JsonSchemaVariant`,
15
+ `JsonSchemaCase`, `JsonSchemaArray` and `JsonSchemaNull` say so. The generated schemas do not change.
16
+
5
17
  ## 0.1.0 — 2026-10-28
6
18
 
7
19
  First published version.
package/README.md CHANGED
@@ -46,6 +46,7 @@ await layer.callTool("commit", { preview_id: "p1" });
46
46
  - **Text and bool** (D-050). A `text` position is a text input taken as given (not trimmed), checked against its `text` tag: `max_len` and `range` lengths in code points (1024 without either), `pattern` (unanchored, with the `u` flag) and `format` (`url`: an http or https URL; `email`); lone surrogates are refused. A pattern the layer will not run skips the method with the reason: over 256 characters, not valid with the `u` flag, a back reference, a lookaround, or a group that repeats inside a group that repeats (exponential backtracking). A `bool` position is `"true"` or `"false"`.
47
47
  - **Nested arguments** (D-050). A record, variant or `vec` that no value tag reads (an account record is still one text input) is one input in JSON: an object for a record (unknown keys refused), `{ "tag": "Case", "value": … }` for a variant case (no `value` for a `null` case), an array for a `vec` (`null` for an element that is none), and each leaf read by its own rule above, as text. At most 256 elements in a `vec` and 4096 values in one input. An issue names its place (`$args[0].command.Follow.followees[1].id`). The input's JSON Schema mirrors the shape (`oneOf` per case, `maxItems`) with each leaf's own schema. A `null` or `reserved` field takes no input, and neither does a lone `null` or `reserved` argument. A recursive position, or a field or case named by a number, still skips the method.
48
48
  - **An outcome with `default` alone** (D-049): every reply of the method is that class, whatever its result; outcome keys still need a method with one result.
49
+ - **One tool per case** (D-053, rule 3 of B2). A method whose single argument is a variant with an `intent` or an `effect` on any of its cases gets one tool for each case instead of one for the method: `<method>_<Case>` when the case's effect is a read (every case of a query, or a read-only update case), else `prepare_<method>_<Case>`. Its inputs are the case's payload, read as a single argument is (a record's fields, one value-tagged input, none for a `null` case, else one JSON input), and it sends `{ tag: Case, value }` (a bare `{ tag }` for a `null` case). Its title is the case's intent, else its `describe`, else `<method> <Case>`; its effect the case's, else the method's; the method's `outcome` applies, and a prepare tool needs an intent, the case's or the method's. The case is part of the intent's identity, so two cases with the same inputs are two previews. A case without a rule, or without a name (`#3`), is skipped alone, with `case <Case>:` in front of the reason; a method with an idempotency stamp gets no case tools yet (the stamp would sit inside a case). A tagged variant deeper in the argument (inside a record) stays one JSON input.
49
50
 
50
51
  A method with no rule (including a query with more than one result) is skipped and reported in `buildToolPlans(resolved).skipped`, not thrown on; so is every method whose tool name is `commit` or is another method's tool name, every method with an argument field named `canister_id` (the canister selector's input), and every update whose stamp is the whole argument rather than a field (a required stamp field is filled like an optional one). The stamp and the outcome keys are relpaths, matched by the position they resolve to: `(0).Ok`, `.Ok` and `Ok` are one key.
51
52
  - **Input words.** Each input's description is the format library's `Formatter.describe()`, with the canisters' decimals and symbol filled in when they agree (and its regular-expression `pattern` for amounts and subaccounts). When the canisters disagree the words give the grammar without the numbers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent tools from any Candid Profile resolved form: tool definitions, exact input reading, and a tool layer with a confirmation hook, an injected encoder, call and clock. Browser-safe.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,7 +44,7 @@
44
44
  "test": "node --test \"test/*.test.mjs\""
45
45
  },
46
46
  "dependencies": {
47
- "@meantjs/format": "0.1.0"
47
+ "@meantjs/format": "0.2.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@candid-core/schema": "0.5.0",
package/src/index.d.mts CHANGED
@@ -26,13 +26,50 @@ export interface JsonSchemaString {
26
26
  pattern?: string;
27
27
  }
28
28
 
29
+ /** The schema of one input: text read by its own rules, or the JSON shape of a record, variant or vec (D-050). */
30
+ export type JsonSchemaInput = JsonSchemaString | JsonSchemaObject | JsonSchemaVariant | JsonSchemaArray;
31
+
32
+ /** A record: one property per field that is an input. */
33
+ export interface JsonSchemaObject {
34
+ type: "object";
35
+ description: string;
36
+ properties: Record<string, JsonSchemaInput>;
37
+ required: string[];
38
+ additionalProperties: false;
39
+ }
40
+
41
+ /** A variant: one `{ tag, value }` object per case; a case without a value has `tag` alone. */
42
+ export interface JsonSchemaVariant {
43
+ oneOf: JsonSchemaCase[];
44
+ description: string;
45
+ }
46
+
47
+ export interface JsonSchemaCase {
48
+ type: "object";
49
+ properties: { tag: { const: string }; value?: JsonSchemaInput };
50
+ required: ["tag"] | ["tag", "value"];
51
+ additionalProperties: false;
52
+ }
53
+
54
+ export interface JsonSchemaNull {
55
+ type: "null";
56
+ }
57
+
58
+ /** A vec: an element that is none is a JSON null, so an opt element is its schema or null. */
59
+ export interface JsonSchemaArray {
60
+ type: "array";
61
+ description: string;
62
+ items: JsonSchemaInput | JsonSchemaNull | { anyOf: [JsonSchemaInput, JsonSchemaNull] };
63
+ maxItems: number;
64
+ }
65
+
29
66
  export interface ToolDefinition {
30
67
  name: string;
31
68
  description: string;
32
69
  inputSchema: {
33
70
  $schema: string;
34
71
  type: "object";
35
- properties: Record<string, JsonSchemaString>;
72
+ properties: Record<string, JsonSchemaInput>;
36
73
  required: string[];
37
74
  additionalProperties: false;
38
75
  };
@@ -59,6 +96,8 @@ export interface ToolPlan {
59
96
  replicated: boolean;
60
97
  title: string;
61
98
  effect: { kind: string; irreversible: boolean; replicated?: boolean };
99
+ /** For a tool per case of the method's variant argument (D-053): the case it sends. */
100
+ case?: string;
62
101
  /** The method's arguments, in order. */
63
102
  argNodes: unknown[];
64
103
  params: ToolParam[];
@@ -79,6 +118,8 @@ export interface InputIssue {
79
118
 
80
119
  export const COMMIT_TOOL: "commit";
81
120
  export function prepareToolName(method: string): string;
121
+ /** The tool of one case of a method's variant argument (D-053): `<method>_<Case>` for a read, else `prepare_<method>_<Case>`. */
122
+ export function caseToolName(method: string, label: string, read: boolean): string;
82
123
  export function resolvedMethods(resolved: ResolvedForm | ResolvedForm[]): [string, unknown][];
83
124
  export function buildToolPlans(resolved: ResolvedForm | ResolvedForm[]): { plans: Map<string, ToolPlan>; skipped: { method: string; reason: string }[] };
84
125
  export function toolDefinitions(resolved: ResolvedForm | ResolvedForm[], options?: { canisters?: Canister[] }): ToolDefinition[];
package/src/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // @meantjs/tools: agent tools from any resolved form, browser-safe (#16).
2
2
  // Arm C (packages/mcp-adapters) stays the eval arm with its frozen texts; this is the product tool layer.
3
3
 
4
- export { COMMIT_TOOL, buildToolPlans, prepareToolName, resolvedMethods, toolDefinitions } from "./plan.mjs";
4
+ export { COMMIT_TOOL, buildToolPlans, caseToolName, prepareToolName, resolvedMethods, toolDefinitions } from "./plan.mjs";
5
5
  export { displayValue, readToolInput, toCandid } from "./input.mjs";
6
6
  export { classifyReply, createToolLayer, nameEntries } from "./layer.mjs";
package/src/input.mjs CHANGED
@@ -468,7 +468,7 @@ function readInput(node, input, path, param, ctx) {
468
468
  * input may use (spec 5.3, D-045), read when the call is prepared
469
469
  * @returns {{ value: unknown, identity: [string, unknown][] } | { issues: { param: string, path: string, code: string, message: string }[] }}
470
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
471
+ * method with more than one; for a tool per case, the case `{ tag, value }` around its payload), with the stamp left null; `identity` is what the inputs add to the intent's
472
472
  * identity. Issue codes are the format library's, plus E_INPUT (not an object), E_UNKNOWN_PARAM, E_MISSING and
473
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
474
  * `named` input E_NAMES_UNREAD (its set's names were not read), E_NAME_NOT_FOUND and E_NAME_AMBIGUOUS (several
@@ -510,10 +510,16 @@ export function readToolInput(plan, input, { refs = {}, now, names = {} } = {})
510
510
  if (issues.length > 0) return { issues };
511
511
  if (plan.argNodes?.length > 1) return { value: plan.fields.map((field) => (field.role === "param" ? values.get(field.name) : null)), identity };
512
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 };
513
+ const value = plan.rootParam
514
+ ? values.get(plan.params[0].name)
515
+ : plan.rootNull
516
+ ? null
517
+ : Object.fromEntries(plan.fields.map((field) => [field.name, field.role === "param" ? values.get(field.name) : null]));
518
+ if (plan.case === undefined) return { value, identity };
519
+ // a tool per case (D-053): the payload goes into its case, a null payload as candid-core's bare { tag }; the case is
520
+ // part of the intent, so two cases with the same inputs are two intents
521
+ const bare = plan.caseNode.candid === "null" && plan.caseNode.optional !== true;
522
+ return { value: bare ? { tag: plan.case } : { tag: plan.case, value }, identity: [[plan.case, identity]] };
517
523
  }
518
524
 
519
525
  /**
package/src/plan.mjs CHANGED
@@ -26,6 +26,12 @@
26
26
  // rules (input.mjs, `inputWithoutRule`); a single argument that is a variant or a vec is the input `arg0`;
27
27
  // - outcome keys may start with a record field (`result.Ok` over `record { result : opt variant { Ok; Err } }`);
28
28
  // - an outcome with `default` alone classes every reply of a method whose result has no variant to select.
29
+ // And one tool per case (B2 rule 3 of proposal w9-b3wallet-gaps.md, D-053): a method whose single argument is a
30
+ // variant with an `intent` or `effect` on any of its cases gets a tool for each case, `<method>_<Case>` for a read and
31
+ // `prepare_<method>_<Case>` otherwise. Its inputs are the case's payload, read as a single argument is; its title is
32
+ // the case's intent, else its `describe`, else `<method> <Case>`; its effect is the case's, else the method's; and the
33
+ // method's `idempotency` and `outcome` apply. A method with an idempotency stamp has no case tools yet (the stamp would
34
+ // sit inside a case), and a tagged variant deeper in the argument stays one JSON input.
29
35
  //
30
36
  // The input words come from the format library (`Formatter.describe()`), so a form hint and a tool schema read
31
37
  // one grammar. Arm C keeps its own frozen words (D-015, D-030, D-031).
@@ -34,6 +40,8 @@ import { MAX_VEC_ITEMS, inputReader, inputWithoutRule, nestedRole, readAs } from
34
40
 
35
41
  export const COMMIT_TOOL = "commit";
36
42
  export const prepareToolName = (method) => `prepare_${method}`;
43
+ /** The tool of one case of a method's variant argument (D-053): `<method>_<Case>` for a read, else `prepare_<method>_<Case>`. */
44
+ export const caseToolName = (method, label, read) => (read ? `${method}_${label}` : prepareToolName(`${method}_${label}`));
37
45
  const byName = ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0);
38
46
 
39
47
  /** The Candid id of a label: `#n` is n, a name is its idl hash (profile spec 3.2). */
@@ -68,6 +76,8 @@ export function resolvedMethods(resolved) {
68
76
  if (doc === null || typeof doc !== "object" || doc.format !== "candid-profile-resolved" || doc.format_version !== 0) {
69
77
  throw new TypeError("resolved: not a candid-profile-resolved v0 document");
70
78
  }
79
+ // An EVM contract's form (profile spec section 15) has ABI positions, nonces and receipts: its tools are phase 2.
80
+ if (doc.chain !== undefined) throw new TypeError(`resolved: a resolved form of chain ${JSON.stringify(doc.chain)} has no tools yet; this layer calls Internet Computer canisters`);
71
81
  for (const [name, method] of Object.entries(doc.methods ?? {})) {
72
82
  const seen = methods.get(name);
73
83
  if (seen === undefined) methods.set(name, method);
@@ -128,25 +138,69 @@ function outcomeKeyWithoutRule(classes, result) {
128
138
  return undefined;
129
139
  }
130
140
 
131
- /** @returns {object | { skip: string }} */
132
- function planMethod(name, m) {
141
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
142
+ const hasCaseTag = (node) => isObject(node?.tags) && (isObject(node.tags.intent) || isObject(node.tags.effect));
143
+
144
+ /**
145
+ * The cases of a method's single variant argument when any of them has an `intent` or an `effect` (D-053), else
146
+ * undefined: such a method gets one tool per case.
147
+ */
148
+ function taggedCases(m) {
149
+ const root = Array.isArray(m.args) && m.args.length === 1 ? m.args[0] : undefined;
150
+ if (root?.candid !== "variant" || readAs(root)) return undefined;
151
+ const cases = Object.entries(root.cases ?? {});
152
+ return cases.some(([, node]) => hasCaseTag(node)) ? cases : undefined;
153
+ }
154
+
155
+ /**
156
+ * A method's tools: one, or one per case of its variant argument (D-053).
157
+ * @returns {{ plans: object[], skipped: string[] }} the plans, and why each tool it would have is not made
158
+ */
159
+ function planTools(name, m) {
160
+ const cases = taggedCases(m);
161
+ if (cases === undefined) {
162
+ const p = planMethod(name, m);
163
+ return p.skip ? { plans: [], skipped: [p.skip] } : { plans: [p], skipped: [] };
164
+ }
165
+ if (m.tags?.idempotency) return { plans: [], skipped: ["tools per case of a method with an idempotency stamp have no rule yet (the stamp would sit inside a case)"] };
166
+ const plans = [];
167
+ const skipped = [];
168
+ for (const kase of cases) {
169
+ const p = /^[A-Za-z_][A-Za-z0-9_]*$/.test(kase[0]) ? planMethod(name, m, kase) : { skip: "a case without a name has no tool name" };
170
+ if (p.skip) skipped.push(`case ${kase[0]}: ${p.skip}`);
171
+ else plans.push(p);
172
+ }
173
+ return { plans, skipped };
174
+ }
175
+
176
+ /**
177
+ * @param {[string, object]} [kase] for a tool per case (D-053): the case's label and node in the variant argument
178
+ * @returns {object | { skip: string }}
179
+ */
180
+ function planMethod(name, m, kase) {
133
181
  const tags = m.tags ?? {};
134
- const effect = tags.effect?.args;
182
+ const own = kase === undefined ? tags : isObject(kase[1].tags) ? kase[1].tags : {}; // the case's tags, for a case tool
183
+ // the call's effect and intent: the case's, else the method's (spec 5.2 and 5.3)
184
+ const effect = own.effect?.args ?? tags.effect?.args;
185
+ const intent = own.intent?.args ?? tags.intent?.args;
135
186
  // a read-only update (D-038) is a read for consumers, and must be sent as an update so that its answer is certified
136
187
  const replicated = m.mode === "update" && effect?.kind === "read" && effect.irreversible === false && effect.replicated === true;
137
188
  // a composite query is a read too (arm C has none to meet: the ICRC-1/2 packs declare only queries and updates)
138
189
  const read = m.mode === "query" || m.mode === "composite_query" || replicated;
139
190
  const args = Array.isArray(m.args) ? m.args : [];
140
191
  const plan = {
141
- name: read ? name : prepareToolName(name),
192
+ name: kase !== undefined ? caseToolName(name, kase[0], read) : read ? name : prepareToolName(name),
142
193
  kind: read ? "read" : "prepare",
143
194
  method: name,
195
+ /** for a tool per case (D-053): the case of the variant argument it sends, and that case's node */
196
+ case: kase?.[0],
197
+ caseNode: kase?.[1],
144
198
  mode: m.mode,
145
199
  /** a read sent as an update call */
146
200
  replicated,
147
201
  effect: effect ?? (read ? { kind: "read", irreversible: false } : { kind: "write", irreversible: true }),
148
- describe: tags.describe?.args,
149
- intent: tags.intent?.args,
202
+ describe: own.describe?.args,
203
+ intent,
150
204
  idempotency: tags.idempotency?.args,
151
205
  outcome: tags.outcome?.args,
152
206
  /** the method's single argument, or undefined (none, or more than one) */
@@ -164,12 +218,13 @@ function planMethod(name, m) {
164
218
  rootNull: false,
165
219
  results: m.results ?? [],
166
220
  };
167
- plan.title = plan.intent?.title ?? plan.describe?.label ?? name;
221
+ plan.title = kase !== undefined ? (own.intent?.args?.title ?? plan.describe?.label ?? `${name} ${kase[0]}`) : (plan.intent?.title ?? plan.describe?.label ?? name);
168
222
  if (read && plan.results.length > 1) return { skip: "a query with more than one result has no rule (only the first would be shown)" };
169
223
  // the stamp would be an input, or left unset: the stamp rules are for writes
170
224
  if (replicated && plan.idempotency) return { skip: "a read-only update with an idempotency stamp has no rule" };
171
225
  if (!read) {
172
- for (const need of ["intent", "outcome"]) if (!tags[need]) return { skip: `an update method needs the ${need} tag` };
226
+ if (!intent) return { skip: "an update method needs the intent tag" };
227
+ if (!tags.outcome) return { skip: "an update method needs the outcome tag" };
173
228
  if (plan.idempotency && plan.idempotency.mode !== "replay_same_payload") return { skip: `idempotency mode ${plan.idempotency.mode} has no rule` };
174
229
  if (!plan.idempotency && [...Object.values(plan.outcome.classes ?? {}), plan.outcome.default].includes("retry_new")) {
175
230
  return { skip: "the outcome class retry_new renews an idempotency stamp, and this method has none" };
@@ -192,49 +247,64 @@ function planMethod(name, m) {
192
247
  if (c.role === "param") plan.params.push({ name: `arg${i}`, node, optional, path: `$args[${i}]` });
193
248
  }
194
249
  } else if (args.length === 1) {
195
- const root = args[0];
196
- const tag = readAs(root);
197
- if (tag) {
198
- const why = inputWithoutRule(root, "(0)"); // a text pattern the layer does not run
199
- if (why) return { skip: why };
200
- // the stamp is filled by the tool layer, so it must be a field beside the inputs, never the whole argument
201
- if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
202
- plan.rootParam = true;
203
- plan.params.push({ name: tag, node: root, optional: root.optional === true, path: "$args[0]" });
204
- } else if (root.candid === "record") {
205
- let stampId;
206
- if (plan.idempotency) {
207
- const ids = relpathIds(plan.idempotency.stamp);
208
- if (ids === null || ids.length !== 1) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
209
- stampId = ids[0];
210
- }
211
- for (const [fname, fnode] of Object.entries(root.fields ?? {})) {
212
- // the canister selector of every tool (toolDefinitions, the tool layer) owns this input name
213
- if (fname === "canister_id") return { skip: "an argument field named canister_id would collide with the canister selector input" };
214
- const c = classifyField(fname, fnode, stampId);
215
- if (c.skip) return c;
216
- if (c.role === "stamp") plan.stampField = fname;
217
- const optional = fnode.optional === true;
218
- plan.fields.push({ name: fname, node: fnode, role: c.role, optional });
219
- if (c.role === "param") plan.params.push({ name: fname, node: fnode, optional, path: `$args[0].${fname}` });
220
- }
221
- if (plan.idempotency && !plan.fields.some((f) => f.role === "stamp")) return { skip: `the stamp field ${plan.idempotency.stamp} is not an argument field` };
222
- } else if (root.candid === "null" || root.candid === "reserved") {
223
- // one value only, so nothing to ask for: the tool takes no input and sends null
224
- if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
225
- plan.rootNull = true;
226
- } else {
227
- // a variant or a vec (or a record a value tag does not cover) is one JSON input
228
- const why = inputWithoutRule(root, "(0)");
229
- if (why) return { skip: why };
230
- if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
231
- plan.rootParam = true;
232
- plan.params.push({ name: "arg0", node: root, optional: root.optional === true, path: "$args[0]" });
233
- }
250
+ // a case tool reads its case's payload as a single argument is read (D-053)
251
+ const why = kase !== undefined ? planArgument(plan, kase[1], `(0).${kase[0]}`, `$args[0].${kase[0]}`) : planArgument(plan, args[0], "(0)", "$args[0]");
252
+ if (why) return why;
234
253
  }
235
254
  return plan;
236
255
  }
237
256
 
257
+ /**
258
+ * The inputs of a single argument, or of a case's payload: a position `readAs` names is one input named by its tag, a
259
+ * record's fields are inputs of their own (the stamp and filled ones aside), null takes none, and any other position
260
+ * is one JSON input. `at` names the position in skip reasons, `path` in input issues.
261
+ * @returns {{ skip: string } | undefined}
262
+ */
263
+ function planArgument(plan, root, at, path) {
264
+ const tag = readAs(root);
265
+ if (tag) {
266
+ const why = inputWithoutRule(root, at); // a text pattern the layer does not run
267
+ if (why) return { skip: why };
268
+ // the stamp is filled by the tool layer, so it must be a field beside the inputs, never the whole argument
269
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
270
+ plan.rootParam = true;
271
+ plan.params.push({ name: tag, node: root, optional: root.optional === true, path });
272
+ } else if (root.candid === "record") {
273
+ let stampId;
274
+ if (plan.idempotency) {
275
+ const ids = relpathIds(plan.idempotency.stamp);
276
+ if (ids === null || ids.length !== 1) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
277
+ stampId = ids[0];
278
+ }
279
+ for (const [fname, fnode] of Object.entries(root.fields ?? {})) {
280
+ // the canister selector of every tool (toolDefinitions, the tool layer) owns this input name
281
+ if (fname === "canister_id") return { skip: "an argument field named canister_id would collide with the canister selector input" };
282
+ const c = classifyField(fname, fnode, stampId);
283
+ if (c.skip) return c;
284
+ if (c.role === "stamp") plan.stampField = fname;
285
+ const optional = fnode.optional === true;
286
+ plan.fields.push({ name: fname, node: fnode, role: c.role, optional });
287
+ if (c.role === "param") plan.params.push({ name: fname, node: fnode, optional, path: `${path}.${fname}` });
288
+ }
289
+ if (plan.idempotency && !plan.fields.some((f) => f.role === "stamp")) return { skip: `the stamp field ${plan.idempotency.stamp} is not an argument field` };
290
+ } else if (root.candid === "null" || root.candid === "reserved") {
291
+ // one value only, so nothing to ask for: the tool takes no input and sends null
292
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
293
+ plan.rootNull = true;
294
+ } else {
295
+ // a variant or a vec (or a record a value tag does not cover) is one JSON input
296
+ const why = inputWithoutRule(root, at);
297
+ if (why) return { skip: why };
298
+ if (plan.idempotency) return { skip: `the stamp ${plan.idempotency.stamp} must name one field of the argument record` };
299
+ plan.rootParam = true;
300
+ plan.params.push({ name: "arg0", node: root, optional: root.optional === true, path });
301
+ }
302
+ return undefined;
303
+ }
304
+
305
+ /** What a tool calls, for messages: the method, or the method and the case it sends. */
306
+ const toolOf = (p) => (p.case === undefined ? p.method : `${p.method}'s case ${p.case}`);
307
+
238
308
  /**
239
309
  * @param {object | object[]} resolved
240
310
  * @returns {{ plans: Map<string, any>, skipped: { method: string, reason: string }[] }} plans keyed by tool name, sorted
@@ -244,15 +314,15 @@ export function buildToolPlans(resolved) {
244
314
  const skipped = [];
245
315
  const byTool = new Map();
246
316
  for (const [name, m] of resolvedMethods(resolved)) {
247
- const p = planMethod(name, m);
248
- if (p.skip) skipped.push({ method: name, reason: p.skip });
249
- else byTool.set(p.name, [...(byTool.get(p.name) ?? []), p]);
317
+ const tools = planTools(name, m);
318
+ for (const reason of tools.skipped) skipped.push({ method: name, reason });
319
+ for (const p of tools.plans) byTool.set(p.name, [...(byTool.get(p.name) ?? []), p]);
250
320
  }
251
321
  // Two methods whose tools share a name (a query named prepare_x beside an update x), or one named like the commit
252
322
  // tool: neither is dropped silently, and neither is preferred, so all of them are skipped.
253
323
  for (const [tool, ps] of byTool) {
254
324
  if (tool === COMMIT_TOOL) for (const p of ps) skipped.push({ method: p.method, reason: `its tool name ${tool} is the commit tool's` });
255
- else if (ps.length > 1) for (const p of ps) skipped.push({ method: p.method, reason: `its tool name ${tool} is also ${ps.filter((q) => q !== p).map((q) => q.method).join(", ")}'s` });
325
+ else if (ps.length > 1) for (const p of ps) skipped.push({ method: p.method, reason: `its tool name ${tool} is also ${ps.filter((q) => q !== p).map(toolOf).join(", ")}'s` });
256
326
  else plans.set(tool, ps[0]);
257
327
  }
258
328
  return { plans: new Map([...plans.entries()].sort(byName)), skipped };
@@ -397,12 +467,13 @@ function definitionOf(plan, canisters, refsList, sets) {
397
467
  const schema = inputSchema(plan, canisters, refsList, sets);
398
468
  if (plan.kind === "read") {
399
469
  const label = plan.describe?.label;
400
- const what = plan.replicated ? `Read-only call of \`${plan.method}\`, sent as an update call so that the answer is certified; it changes nothing.` : `Read-only query of \`${plan.method}\`.`;
470
+ const call = `\`${plan.method}\`${plan.case === undefined ? "" : ` with the case \`${plan.case}\``}`;
471
+ const what = plan.replicated ? `Read-only call of ${call}, sent as an update call so that the answer is certified; it changes nothing.` : `Read-only query of ${call}.`;
401
472
  return {
402
473
  name: plan.name,
403
474
  description: `${label ? `${label}. ` : ""}${what} The answer gives each value formatted for people and raw (base units for amounts, the tagged unit for times).`,
404
475
  inputSchema: schema,
405
- annotations: { destructiveHint: false, openWorldHint: true, readOnlyHint: true, title: label ?? plan.method },
476
+ annotations: { destructiveHint: false, openWorldHint: true, readOnlyHint: true, title: label ?? (plan.case === undefined ? plan.method : plan.title) },
406
477
  };
407
478
  }
408
479
  const e = plan.effect;
@@ -410,7 +481,7 @@ function definitionOf(plan, canisters, refsList, sets) {
410
481
  return {
411
482
  name: plan.name,
412
483
  description:
413
- `Prepare the call "${plan.title}" (\`${plan.method}\`, effect: ${e.kind}${e.irreversible ? ", irreversible" : ""}). Nothing is sent yet. ` +
484
+ `Prepare the call "${plan.title}" (\`${plan.method}\`${plan.case === undefined ? "" : ` with the case \`${plan.case}\``}, effect: ${e.kind}${e.irreversible ? ", irreversible" : ""}). Nothing is sent yet. ` +
414
485
  "Inputs are in human units and are checked exactly (nothing is rounded). " +
415
486
  (plan.idempotency ? "" : "This call has no idempotency stamp, so the canister cannot tell a repeat from a new call: once an attempt's outcome is unknown, it is never sent again (commit answers reconcile). ") +
416
487
  (filled ? `The tool layer fills in: ${filled}. ` : "") +