@aria-framework/ask 0.1.1 → 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 +278 -0
- package/README.md +97 -5
- package/assets/ask.js +19 -3
- package/index.js +15 -13
- package/lib/bench.js +146 -32
- package/lib/catalogue.js +188 -25
- package/lib/check.js +479 -25
- package/lib/eta.js +51 -0
- package/lib/history.js +66 -4
- package/lib/prompts.js +71 -14
- package/lib/routes.js +46 -9
- package/lib/run.js +64 -13
- package/lib/store.js +33 -3
- package/lib/text.js +118 -0
- package/package.json +1 -1
- package/views/ask/admin.ejs +2 -2
- package/views/ask/none.ejs +56 -0
- package/views/ask/review.ejs +1 -0
package/lib/catalogue.js
CHANGED
|
@@ -11,9 +11,19 @@
|
|
|
11
11
|
* request holds (`can`, the request's own) and that the platform has not switched off. A tool the person lacks is NOT IN
|
|
12
12
|
* THE SCHEMA, so it cannot be emitted, and a call to it that arrives anyway is refused by `validatePlan` — never run.
|
|
13
13
|
*
|
|
14
|
-
* THE SCHEMA IS STRICT AND CLOSED (`plannerSchema`): `intent` is lookup | write | none; `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* THE SCHEMA IS STRICT AND CLOSED (`plannerSchema`): `intent` is lookup | analyse | write | none; `reason` is null or,
|
|
15
|
+
* for none, no_tool | out_of_scope | needs_judgement; `calls` is at most MAX_CALLS of `{tool, args}` with exactly that
|
|
16
|
+
* tool's declared arguments (nullable where optional), enums for every closed value and a length bound on every string.
|
|
17
|
+
* THE REPLY IS VALIDATED AGAIN (`validatePlan`), whatever the provider claims.
|
|
18
|
+
*
|
|
19
|
+
* TWO ROUNDS (0.2.0). The planner still sees NO DATA. It may plan a second round in the same reply: a later call may take
|
|
20
|
+
* a text argument from an earlier call's result by REFERENCE — "@r1.customer_ref" — naming an OUTPUT the earlier tool
|
|
21
|
+
* declares (`outputs: { customer_ref: { path: 'facts.customer_ref', describe } }`). The PACKAGE reads the value at that
|
|
22
|
+
* path from the result after round 1 ran (`resolveCall`) and validates it with the argument's own spec, as if the model
|
|
23
|
+
* had typed it; the model never supplies the value. Rules: every round-1 call comes first; a reference names an earlier
|
|
24
|
+
* round-1 call, never a round-2 one (so at most two rounds); an output read from one of the tool's attackerFields must be
|
|
25
|
+
* marked `safe: true` by the app (assertToolShape refuses it otherwise); a snippet is never an output; the total stays at
|
|
26
|
+
* most MAX_CALLS (ask_tool_calls.position is 1–4).
|
|
17
27
|
*
|
|
18
28
|
* REMOTE TOOLS (D-0169 item 8, interface only): a tool may be declared `{ id, kind: 'remote', service, ... }` — another
|
|
19
29
|
* app's own Ask, answering for the same person. 0.1.0 VALIDATES the shape and NEVER OFFERS it (`catalogueFor` leaves it
|
|
@@ -26,8 +36,18 @@
|
|
|
26
36
|
const crypto = require('crypto');
|
|
27
37
|
|
|
28
38
|
const MAX_CALLS = 4;
|
|
29
|
-
|
|
39
|
+
// 0.2.0: `analyse` (the answer may group, compare and conclude across rows — the answer check holds it to the analyse
|
|
40
|
+
// rules) and a REASON on `none`.
|
|
41
|
+
const INTENTS = ['lookup', 'analyse', 'write', 'none'];
|
|
42
|
+
const NONE_REASONS = Object.freeze(['no_tool', 'out_of_scope', 'needs_judgement']);
|
|
30
43
|
const ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
|
44
|
+
// A ROUND-2 REFERENCE (0.2.0): "@r1.customer_ref" — result r1 (the FIRST call) and the output its tool declared.
|
|
45
|
+
const REF_ARG = /^@r([1-4])\.([a-z][a-z0-9_]{0,39})$/;
|
|
46
|
+
// Anything that starts like a reference and is not a valid one is refused, never passed on as text.
|
|
47
|
+
const REFISH = /^@r\d/i;
|
|
48
|
+
// Where an output may be read from: a fact, or a row's ref or field. A list path ('rows[].…') must hold ONE value.
|
|
49
|
+
const OUTPUT_PATH = /^(?:facts\.[a-z0-9_]+|rows\[\]\.(?:ref|fields\.[a-z0-9_]+))$/;
|
|
50
|
+
const SNIPPET_PATH = /(?:^|\.)snippet$/;
|
|
31
51
|
|
|
32
52
|
/** The permissions a tool's page is gated by, as a list — `permission: 'x'` is `['x']`. */
|
|
33
53
|
function gateOf(tool) {
|
|
@@ -60,6 +80,47 @@ function assertToolShape(tool) {
|
|
|
60
80
|
}
|
|
61
81
|
if (!Array.isArray(tool.attackerFields)) fail('has no attackerFields list (an empty list must be said)');
|
|
62
82
|
if (typeof tool.describe !== 'function') fail('has no describe(args)');
|
|
83
|
+
// STATE FIELDS (0.2.0): which row field holds a state an "all / none of them are <state>" answer may claim, and the
|
|
84
|
+
// values that mean it — { open: { field: 'is_open', values: [true] }, resolved: { field: 'status', values: ['resolved', 'closed'] } }.
|
|
85
|
+
// A closed list of values, so even a field other people can write only ever matches or does not.
|
|
86
|
+
if (tool.stateFields !== undefined) {
|
|
87
|
+
if (!tool.stateFields || typeof tool.stateFields !== 'object' || Array.isArray(tool.stateFields)) fail('stateFields is an object { state: { field, values } }');
|
|
88
|
+
for (const [state, def] of Object.entries(tool.stateFields)) {
|
|
89
|
+
if (!/^[a-z]+$/.test(state)) fail(`state ${state} is not a lower-case word`);
|
|
90
|
+
if (!def || typeof def.field !== 'string' || !/^[a-z0-9_]{1,40}$/.test(def.field)) fail(`state ${state}: field is a row field name`);
|
|
91
|
+
if (!Array.isArray(def.values) || !def.values.length || def.values.some((v) => !(typeof v === 'boolean' || (typeof v === 'string' && v)))) fail(`state ${state}: values is a list of strings or booleans`);
|
|
92
|
+
if (def.synonyms !== undefined && (!Array.isArray(def.synonyms) || def.synonyms.some((w) => typeof w !== 'string' || !/^[a-z]+$/.test(w)))) fail(`state ${state}: synonyms is a list of lower-case words`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ROW NAME (review round 3 of PR #14): the field(s) a sentence may name a row by, besides its ref — 'title' or a row field.
|
|
96
|
+
if (tool.rowName !== undefined) {
|
|
97
|
+
const names = [].concat(tool.rowName);
|
|
98
|
+
if (!names.length || names.some((n) => typeof n !== 'string' || !/^[a-z0-9_]{1,40}$/.test(n))) fail('rowName is \'title\' or a row field name (or a list of them)');
|
|
99
|
+
}
|
|
100
|
+
// GROUP BY (review round 3 of PR #14): a breakdown may mark an extra `enum: true` (its label app-written) only for a
|
|
101
|
+
// group-by key it declares here with the closed list of labels — never for a key a free-text argument chooses.
|
|
102
|
+
if (tool.groupBy !== undefined) {
|
|
103
|
+
if (!tool.groupBy || typeof tool.groupBy !== 'object' || Array.isArray(tool.groupBy)) fail('groupBy is an object { key: { enum: [labels] } }');
|
|
104
|
+
for (const [key, def] of Object.entries(tool.groupBy)) {
|
|
105
|
+
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) fail(`groupBy ${key} is not a lower-case name`);
|
|
106
|
+
if (!def || !Array.isArray(def.enum) || !def.enum.length || def.enum.some((v) => typeof v !== 'string' || !v)) fail(`groupBy ${key}: enum is the closed list of its group labels`);
|
|
107
|
+
if (tool.args[key] && tool.args[key].type !== 'enum') fail(`groupBy ${key} is a free-text argument; only an enum may group app-written labels`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (tool.outputs !== undefined) {
|
|
111
|
+
if (!tool.outputs || typeof tool.outputs !== 'object' || Array.isArray(tool.outputs)) fail('outputs is an object { name: { path, describe, safe? } }');
|
|
112
|
+
for (const [name, out] of Object.entries(tool.outputs)) {
|
|
113
|
+
if (!/^[a-z][a-z0-9_]{0,39}$/.test(name)) fail(`output ${name} is not a lower-case name`);
|
|
114
|
+
if (!out || typeof out !== 'object' || typeof out.path !== 'string' || !OUTPUT_PATH.test(out.path)) fail(`output ${name}: its path is 'facts.<key>', 'rows[].ref' or 'rows[].fields.<key>'`);
|
|
115
|
+
if (typeof out.describe !== 'string' || !out.describe) fail(`output ${name} has no describe text`);
|
|
116
|
+
if (out.safe !== undefined && out.safe !== true) fail(`output ${name}: safe is true or absent`);
|
|
117
|
+
if (out.kind !== undefined && (typeof out.kind !== 'string' || !/^[a-z][a-z_]{0,39}$/.test(out.kind))) fail(`output ${name}: kind is a lower-case id kind ('customer')`);
|
|
118
|
+
// ATTACKER TEXT NEVER STEERS A LOOK-UP unless the app says, for this one output, that it may (`safe: true`): a value
|
|
119
|
+
// other people typed would otherwise choose what round 2 reads. A snippet is attacker text whatever is declared.
|
|
120
|
+
if (SNIPPET_PATH.test(out.path)) fail(`output ${name} reads a snippet, which is attacker text and never an output`);
|
|
121
|
+
if (tool.attackerFields.includes(out.path) && out.safe !== true) fail(`output ${name} reads ${out.path}, an attacker field — mark it safe: true only if a value other people typed may choose what a later look-up reads`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
63
124
|
if (tool.kind === 'remote') {
|
|
64
125
|
if (typeof tool.service !== 'string' || !tool.service) fail('a remote tool names its service');
|
|
65
126
|
} else if (typeof tool.run !== 'function') fail('has no run(ctx, args)');
|
|
@@ -105,9 +166,10 @@ function plannerSchema(tools) {
|
|
|
105
166
|
schema: {
|
|
106
167
|
type: 'object',
|
|
107
168
|
additionalProperties: false,
|
|
108
|
-
required: ['intent', 'calls'],
|
|
169
|
+
required: ['intent', 'calls', 'reason'],
|
|
109
170
|
properties: {
|
|
110
171
|
intent: { type: 'string', enum: INTENTS },
|
|
172
|
+
reason: { type: ['string', 'null'], enum: [...NONE_REASONS, null] },
|
|
111
173
|
calls: variants.length
|
|
112
174
|
? { type: 'array', maxItems: MAX_CALLS, items: variants.length === 1 ? variants[0] : { anyOf: variants } }
|
|
113
175
|
: { type: 'array', maxItems: 0 }
|
|
@@ -136,25 +198,46 @@ function checkArg(name, spec, value) {
|
|
|
136
198
|
return v;
|
|
137
199
|
}
|
|
138
200
|
|
|
201
|
+
/** The round-2 references in a call's raw args: { argName: { from: index, output } }, or throws a reason. */
|
|
202
|
+
function refsOf(c, tool, i) {
|
|
203
|
+
const refs = {};
|
|
204
|
+
for (const [name, spec] of Object.entries(tool.args)) {
|
|
205
|
+
const v = c.args[name];
|
|
206
|
+
if (typeof v !== 'string' || !REFISH.test(v.trim())) continue;
|
|
207
|
+
const m = REF_ARG.exec(v.trim());
|
|
208
|
+
if (!m) throw new Error(`look-up ${i + 1}: ${name} looks like a reference to an earlier result and is not one`);
|
|
209
|
+
if (spec.type === 'enum') throw new Error(`look-up ${i + 1}: ${name} is a closed list and cannot take an earlier result`);
|
|
210
|
+
refs[name] = { from: Number(m[1]) - 1, output: m[2] };
|
|
211
|
+
}
|
|
212
|
+
return refs;
|
|
213
|
+
}
|
|
214
|
+
|
|
139
215
|
/**
|
|
140
|
-
* The planner's reply, validated against the tools THIS request may use. Returns { intent, calls: [{tool, args
|
|
141
|
-
*
|
|
142
|
-
*
|
|
216
|
+
* The planner's reply, validated against the tools THIS request may use. Returns { intent, reason, calls: [{tool, args,
|
|
217
|
+
* refs?}] } with every argument normalised — a round-2 argument keeps its reference ("@r1.customer_ref") and `refs`
|
|
218
|
+
* names it — or { error, code?, index? } naming the first thing wrong; nothing from a refused reply runs.
|
|
219
|
+
* A `write` or `none` intent carries no calls; `reason` is only a none's (absent reads as null — a 0.1 plan). A
|
|
220
|
+
* duplicate call is dropped, not run twice, and a reference to it follows to the call that is kept.
|
|
143
221
|
*/
|
|
144
222
|
function validatePlan(reply, tools) {
|
|
145
223
|
if (!isPlain(reply)) return { error: 'the plan is not an object' };
|
|
146
|
-
const extra = Object.keys(reply).filter((k) => !['intent', 'calls'].includes(k));
|
|
224
|
+
const extra = Object.keys(reply).filter((k) => !['intent', 'calls', 'reason'].includes(k));
|
|
147
225
|
if (extra.length) return { error: `the plan has unexpected fields (${extra.join(', ')})` };
|
|
148
226
|
if (!INTENTS.includes(reply.intent)) return { error: 'the plan has no valid intent' };
|
|
149
227
|
if (!Array.isArray(reply.calls)) return { error: 'the plan has no calls list' };
|
|
150
|
-
|
|
151
|
-
|
|
228
|
+
const reason = reply.reason === undefined ? null : reply.reason;
|
|
229
|
+
if (reason !== null && !NONE_REASONS.includes(reason)) return { error: 'the plan\'s reason is not one of its values' };
|
|
230
|
+
if (reason !== null && reply.intent !== 'none') return { error: `a ${reply.intent} plan carries no reason` };
|
|
231
|
+
if (reply.intent === 'write' || reply.intent === 'none') {
|
|
232
|
+
return reply.calls.length ? { error: `a ${reply.intent} plan may not name look-ups` } : { intent: reply.intent, reason, calls: [] };
|
|
152
233
|
}
|
|
153
234
|
if (!reply.calls.length) return { error: 'a look-up plan names no look-ups' };
|
|
154
235
|
if (reply.calls.length > MAX_CALLS) return { error: `the plan names more than ${MAX_CALLS} look-ups` };
|
|
155
236
|
const allowed = new Map(tools.map((t) => [t.id, t]));
|
|
156
237
|
const calls = [];
|
|
157
|
-
const seen = new
|
|
238
|
+
const seen = new Map();
|
|
239
|
+
const kept = []; // the reply's index → the kept call's index
|
|
240
|
+
let roundTwo = false;
|
|
158
241
|
for (const [i, c] of reply.calls.entries()) {
|
|
159
242
|
if (!isPlain(c)) return { error: `look-up ${i + 1} is not an object` };
|
|
160
243
|
const keys = Object.keys(c).filter((k) => !['tool', 'args'].includes(k));
|
|
@@ -166,25 +249,89 @@ function validatePlan(reply, tools) {
|
|
|
166
249
|
if (!isPlain(c.args)) return { error: `look-up ${i + 1} has no arguments object` };
|
|
167
250
|
const unknown = Object.keys(c.args).filter((k) => !Object.prototype.hasOwnProperty.call(tool.args, k));
|
|
168
251
|
if (unknown.length) return { error: `look-up ${i + 1} has arguments its tool does not take` };
|
|
252
|
+
let refs;
|
|
253
|
+
try { refs = refsOf(c, tool, i); } catch (e) { return { error: e.message }; }
|
|
169
254
|
const args = {};
|
|
170
255
|
try {
|
|
171
|
-
for (const [name, spec] of Object.entries(tool.args))
|
|
256
|
+
for (const [name, spec] of Object.entries(tool.args)) {
|
|
257
|
+
if (refs[name]) {
|
|
258
|
+
const r = refs[name];
|
|
259
|
+
// EARLIER, ROUND 1, AND A DECLARED OUTPUT: the reference names a call before this one that takes no reference
|
|
260
|
+
// itself (so there are never three rounds) and an output its tool declares.
|
|
261
|
+
if (r.from >= i) throw new Error(`${name} names a result that is not an earlier look-up`);
|
|
262
|
+
const src = reply.calls[r.from];
|
|
263
|
+
const srcTool = allowed.get(src && src.tool);
|
|
264
|
+
if (!calls[kept[r.from]] || calls[kept[r.from]].refs) throw new Error(`${name} names a result of the second round (at most two rounds)`);
|
|
265
|
+
if (!srcTool.outputs || !Object.prototype.hasOwnProperty.call(srcTool.outputs, r.output)) throw new Error(`${name} names an output ${srcTool.id} does not give`);
|
|
266
|
+
r.from = kept[r.from];
|
|
267
|
+
args[name] = `@r${r.from + 1}.${r.output}`;
|
|
268
|
+
} else args[name] = checkArg(name, spec, c.args[name]);
|
|
269
|
+
}
|
|
172
270
|
} catch (e) {
|
|
173
271
|
return { error: `look-up ${i + 1}: ${e.message}` };
|
|
174
272
|
}
|
|
273
|
+
const two = Object.keys(refs).length > 0;
|
|
274
|
+
// A round-2 call takes its context from round 1, never from the page as well (fix round of PR #15).
|
|
275
|
+
if (two && Object.values(args).includes('@context')) return { error: `look-up ${i + 1} mixes "@context" with an earlier result` };
|
|
276
|
+
if (!two && roundTwo) return { error: `look-up ${i + 1} takes no earlier result and comes after one that does (round-1 look-ups come first)` };
|
|
277
|
+
roundTwo = roundTwo || two;
|
|
175
278
|
const key = JSON.stringify([tool.id, args]);
|
|
176
|
-
if (seen.has(key)) continue;
|
|
177
|
-
seen.
|
|
178
|
-
|
|
279
|
+
if (seen.has(key)) { kept[i] = seen.get(key); continue; }
|
|
280
|
+
seen.set(key, calls.length);
|
|
281
|
+
kept[i] = calls.length;
|
|
282
|
+
calls.push(two ? { tool: tool.id, args, refs } : { tool: tool.id, args });
|
|
179
283
|
}
|
|
180
|
-
return { intent:
|
|
284
|
+
return { intent: reply.intent, reason: null, calls };
|
|
181
285
|
}
|
|
182
286
|
|
|
183
|
-
/**
|
|
287
|
+
/** Every string value at an output path of a result ('facts.x', 'rows[].ref', 'rows[].fields.x'). */
|
|
288
|
+
function valuesAt(result, path) {
|
|
289
|
+
const out = [];
|
|
290
|
+
const take = (v) => { if (typeof v === 'string') out.push(v); else if (typeof v === 'number' && Number.isFinite(v)) out.push(String(v)); };
|
|
291
|
+
const [head, ...rest] = path.split('.');
|
|
292
|
+
if (head === 'facts') take((result.facts || {})[rest[0]]);
|
|
293
|
+
else for (const row of result.rows || []) take(rest[0] === 'ref' ? row.ref : (row.fields || {})[rest[1]]);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A ROUND-2 CALL'S ARGUMENTS, RESOLVED BY THE PACKAGE: each reference's value read from its result at the output's
|
|
299
|
+
* declared path — exactly ONE distinct value, from a result whose outcome is ok — then validated by the argument's own spec
|
|
300
|
+
* (control characters, length, nullability) as a typed value would be. A value shaped like a reference or the context
|
|
301
|
+
* token is refused: it is never substituted again. Returns { args } or { error }.
|
|
302
|
+
*/
|
|
303
|
+
function resolveCall(call, results, tools, o = {}) {
|
|
304
|
+
const isPublicId = typeof o.isPublicId === 'function' ? o.isPublicId : () => false;
|
|
305
|
+
const tool = tools.find((t) => t.id === call.tool);
|
|
306
|
+
if (!tool) return { error: 'the tool is not offered' };
|
|
307
|
+
const args = { ...call.args };
|
|
308
|
+
for (const [name, r] of Object.entries(call.refs || {})) {
|
|
309
|
+
const res = results[r.from];
|
|
310
|
+
if (!res || res.outcome !== 'ok') return { error: `${name}: result r${r.from + 1} is not an ok result` };
|
|
311
|
+
const srcTool = tools.find((t) => t.id === res.tool);
|
|
312
|
+
const out = srcTool && srcTool.outputs && srcTool.outputs[r.output];
|
|
313
|
+
if (!out) return { error: `${name}: ${res.tool} gives no output ${r.output}` };
|
|
314
|
+
const values = [...new Set(valuesAt(res, out.path).map((v) => v.trim()).filter(Boolean))];
|
|
315
|
+
// SEVERAL VALUES is a choice the person has to make (ambiguous), not an absence (fix round of PR #15).
|
|
316
|
+
if (values.length > 1) return { error: `${name}: r${r.from + 1}.${r.output} holds ${values.length} values, not one`, ambiguous: true };
|
|
317
|
+
if (values.length !== 1) return { error: `${name}: r${r.from + 1}.${r.output} holds no value` };
|
|
318
|
+
if (REFISH.test(values[0]) || values[0] === '@context') return { error: `${name}: the value read is shaped like a reference` };
|
|
319
|
+
// ONLY THIS APP'S OWN IDS PASS unless the app marked the output safe (fix round of PR #15): an attacker field the tool
|
|
320
|
+
// forgot to list cannot steer round 2 with free text.
|
|
321
|
+
// OF ITS KIND (re-review of PR #15): an output declaring `kind` passes only an id of that kind — a usr_ id never reaches
|
|
322
|
+
// a customer argument.
|
|
323
|
+
if (out.safe !== true && !isPublicId(values[0], out.kind)) return { error: `${name}: r${r.from + 1}.${r.output} is not ${out.kind ? `a ${out.kind} id` : 'an id'} of this app (mark the output safe to pass other text)` };
|
|
324
|
+
try { args[name] = checkArg(name, tool.args[name], values[0]); } catch (e) { return { error: e.message }; }
|
|
325
|
+
}
|
|
326
|
+
return { args };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** What the planner reads about each tool: its id, what it does, its arguments in words, and what a later look-up may take from it. */
|
|
184
330
|
function describeTools(tools) {
|
|
185
331
|
return tools.map((t) => {
|
|
186
332
|
const args = Object.entries(t.args).map(([k, spec]) => ` - ${k}: ${spec.describe}${spec.type === 'enum' ? ` (one of: ${spec.values.join(', ')}${spec.nullable ? ', or null' : ''})` : ''}`);
|
|
187
|
-
|
|
333
|
+
const outs = Object.entries(t.outputs || {}).map(([k, o]) => ` - gives ${k}: ${o.describe}`);
|
|
334
|
+
return `- ${t.id}: ${t.description}\n${[...args, ...outs].join('\n')}`;
|
|
188
335
|
}).join('\n');
|
|
189
336
|
}
|
|
190
337
|
|
|
@@ -195,18 +342,33 @@ function describeTools(tools) {
|
|
|
195
342
|
* BYTE-IDENTICAL TO BOTH APPS' COPIES: the hub serialised `{id, permission, description, args, attackerFields}`, Soc
|
|
196
343
|
* `{id, permissions, …}`. Both keys are written here in that order; JSON.stringify drops the one a tool does not declare,
|
|
197
344
|
* so each app's string — and so its prompt hash — is unchanged by adopting the package (test/hashes.js proves it on
|
|
198
|
-
* copies of both apps' documents). A REMOTE tool adds `kind` and `service` after the rest (no app has one yet)
|
|
345
|
+
* copies of both apps' documents). A REMOTE tool adds `kind` and `service` after the rest (no app has one yet); a tool
|
|
346
|
+
* with STATE FIELDS or OUTPUTS (0.2.0) adds `stateFields` / `outputs` last, so a tool without them hashes as before.
|
|
199
347
|
*/
|
|
200
348
|
function catalogueHashOf(tools) {
|
|
201
349
|
const doc = tools.map((t) => ({ id: t.id, permission: t.permission, permissions: t.permissions, description: t.description, args: t.args,
|
|
202
|
-
attackerFields: t.attackerFields, kind: t.kind === 'remote' ? 'remote' : undefined, service: t.kind === 'remote' ? t.service : undefined
|
|
350
|
+
attackerFields: t.attackerFields, kind: t.kind === 'remote' ? 'remote' : undefined, service: t.kind === 'remote' ? t.service : undefined,
|
|
351
|
+
stateFields: t.stateFields, rowName: t.rowName, groupBy: t.groupBy, outputs: t.outputs }));
|
|
203
352
|
return crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex');
|
|
204
353
|
}
|
|
205
354
|
|
|
206
355
|
/** A catalogue over an app's tools, in the order registered. */
|
|
207
|
-
|
|
356
|
+
/**
|
|
357
|
+
* @param {object[]} tools
|
|
358
|
+
* @param {{idKinds?: string[]}} [o] the id kinds this app's isPublicId(v, kind) knows — required when an output declares
|
|
359
|
+
* a kind, and every declared kind must be one of them (checked at boot).
|
|
360
|
+
*/
|
|
361
|
+
function createCatalogue(tools, o = {}) {
|
|
208
362
|
if (!Array.isArray(tools) || !tools.length) throw new Error('ask: createAsk needs a non-empty tools list');
|
|
209
363
|
const TOOLS = Object.freeze(tools.map(assertToolShape));
|
|
364
|
+
const kinds = Array.isArray(o.idKinds) ? new Set(o.idKinds) : null;
|
|
365
|
+
for (const t of TOOLS) {
|
|
366
|
+
for (const [name, out] of Object.entries(t.outputs || {})) {
|
|
367
|
+
if (out.kind === undefined) continue;
|
|
368
|
+
if (!kinds) throw new Error(`ask tool ${JSON.stringify(t.id)}: output ${name} declares kind ${out.kind}; pass createAsk idKinds (the kinds isPublicId knows)`);
|
|
369
|
+
if (!kinds.has(out.kind)) throw new Error(`ask tool ${JSON.stringify(t.id)}: output ${name} declares kind ${out.kind}, which is not one of idKinds (${[...kinds].join(', ')})`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
210
372
|
const BY_ID = new Map();
|
|
211
373
|
for (const t of TOOLS) {
|
|
212
374
|
if (BY_ID.has(t.id)) throw new Error(`ask tool ${JSON.stringify(t.id)} is registered twice`);
|
|
@@ -214,7 +376,7 @@ function createCatalogue(tools) {
|
|
|
214
376
|
}
|
|
215
377
|
const TOOL_IDS = Object.freeze(TOOLS.map((t) => t.id));
|
|
216
378
|
return {
|
|
217
|
-
TOOLS, TOOL_IDS, MAX_CALLS, INTENTS,
|
|
379
|
+
TOOLS, TOOL_IDS, MAX_CALLS, INTENTS, NONE_REASONS,
|
|
218
380
|
byId: (id) => BY_ID.get(String(id)) || null,
|
|
219
381
|
mayUse,
|
|
220
382
|
/** The tools this request may plan: every permission held, not switched off, and not remote (0.1.0). */
|
|
@@ -222,9 +384,10 @@ function createCatalogue(tools) {
|
|
|
222
384
|
const off = new Set(Array.isArray(toolsOff) ? toolsOff : []);
|
|
223
385
|
return TOOLS.filter((t) => t.kind !== 'remote' && !off.has(t.id) && mayUse(can, t));
|
|
224
386
|
},
|
|
225
|
-
plannerSchema, validatePlan, describeTools,
|
|
387
|
+
plannerSchema, validatePlan, resolveCall, describeTools,
|
|
226
388
|
catalogueHash: () => catalogueHashOf(TOOLS)
|
|
227
389
|
};
|
|
228
390
|
}
|
|
229
391
|
|
|
230
|
-
module.exports = { createCatalogue, assertToolShape, mayUse, gateOf, plannerSchema, validatePlan,
|
|
392
|
+
module.exports = { createCatalogue, assertToolShape, mayUse, gateOf, plannerSchema, validatePlan, resolveCall, valuesAt, describeTools, catalogueHashOf,
|
|
393
|
+
MAX_CALLS, INTENTS, NONE_REASONS, REF_ARG };
|