@bevel-software/platform-mcp-core 0.25.2 → 0.27.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/dist/call-guards.d.ts +47 -0
- package/dist/call-guards.d.ts.map +1 -0
- package/dist/call-guards.js +215 -0
- package/dist/call-guards.js.map +1 -0
- package/dist/dispatch.d.ts +5 -1
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +19 -2
- package/dist/dispatch.js.map +1 -1
- package/dist/get-has-no-body.d.ts +49 -0
- package/dist/get-has-no-body.d.ts.map +1 -0
- package/dist/get-has-no-body.js +104 -0
- package/dist/get-has-no-body.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-app.d.ts +118 -0
- package/dist/mcp-app.d.ts.map +1 -0
- package/dist/mcp-app.js +126 -0
- package/dist/mcp-app.js.map +1 -0
- package/dist/meta-tools.d.ts +1 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +28 -6
- package/dist/meta-tools.js.map +1 -1
- package/dist/proxied-tool.d.ts +33 -3
- package/dist/proxied-tool.d.ts.map +1 -1
- package/dist/proxied-tool.js +234 -21
- package/dist/proxied-tool.js.map +1 -1
- package/dist/results.d.ts +20 -1
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js +117 -3
- package/dist/results.js.map +1 -1
- package/dist/schema-validity.d.ts +50 -0
- package/dist/schema-validity.d.ts.map +1 -0
- package/dist/schema-validity.js +254 -0
- package/dist/schema-validity.js.map +1 -0
- package/dist/tool-interface.d.ts +137 -0
- package/dist/tool-interface.d.ts.map +1 -0
- package/dist/tool-interface.js +640 -0
- package/dist/tool-interface.js.map +1 -0
- package/dist/utcp-namespace.d.ts +14 -0
- package/dist/utcp-namespace.d.ts.map +1 -1
- package/dist/utcp-namespace.js +7 -2
- package/dist/utcp-namespace.js.map +1 -1
- package/package.json +2 -1
- package/src/call-guards.ts +237 -0
- package/src/dispatch.ts +18 -1
- package/src/get-has-no-body.ts +110 -0
- package/src/index.ts +45 -0
- package/src/mcp-app.ts +194 -0
- package/src/meta-tools.ts +31 -6
- package/src/proxied-tool.ts +237 -19
- package/src/results.ts +129 -3
- package/src/schema-validity.ts +264 -0
- package/src/tool-interface.ts +673 -0
- package/src/utcp-namespace.ts +7 -2
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { utcpNameToTsInterfaceName } from './code-mode-names.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A tool's INTERFACE, in the two forms an agent needs it: the one-line call
|
|
5
|
+
* example that opens every description, and the argument list a refusal shows
|
|
6
|
+
* when a call did not match.
|
|
7
|
+
*
|
|
8
|
+
* Both are derived from the tool's own input schema and nothing else. A
|
|
9
|
+
* hand-written example drifts from the schema the moment either changes, and a
|
|
10
|
+
* connected server's tools would have none at all — so there is one generator
|
|
11
|
+
* here, used by the platform's own tools, by a deployment's, and by every
|
|
12
|
+
* connected server's alike.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no IO, no clock, no client. The surfaces that list tools call
|
|
15
|
+
* {@link withCallExample}; the argument check beside it (`call-guards.ts`)
|
|
16
|
+
* calls {@link argumentsDoNotMatchMessage}.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What every tool description starts with, on a line of its own. */
|
|
20
|
+
export const CALL_LINE_PREFIX = 'Call: ';
|
|
21
|
+
|
|
22
|
+
/** The `kind` an arguments-do-not-match refusal carries, for a client that branches on it. */
|
|
23
|
+
export const ARGUMENTS_DO_NOT_MATCH_KIND = 'arguments-do-not-match';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The sentence that leads the mismatches when every argument was wrapped in a
|
|
27
|
+
* `body` the tool does not have — the single most common wrong call, because
|
|
28
|
+
* the platform's own tools DO take their arguments that way and a connector
|
|
29
|
+
* tool's arguments are flat.
|
|
30
|
+
*/
|
|
31
|
+
export const BODY_AT_TOP_LEVEL_LINE = 'This tool takes its arguments at the top level, not under "body".';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The mirror of {@link BODY_AT_TOP_LEVEL_LINE}: the same mistake the other way
|
|
35
|
+
* round, by an agent that learned the flat shape and used it on a tool whose
|
|
36
|
+
* arguments ride a `body` envelope. The platform's route-hosted tools take that
|
|
37
|
+
* envelope, and the arguments of a flat call reach their route as query
|
|
38
|
+
* parameters instead of a body — which is how the route tells this apart from a
|
|
39
|
+
* call that simply left everything out, and can say which it was.
|
|
40
|
+
*/
|
|
41
|
+
export const ARGS_UNDER_BODY_LINE = 'This tool takes its arguments under "body", not at the top level.';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How deep the interface and the check go: the top level and one level below
|
|
45
|
+
* it. That is exactly far enough for the `{ body: { ... } }` envelope every
|
|
46
|
+
* platform tool wears, and keeps a refusal readable for a connected server's
|
|
47
|
+
* deeply nested schema instead of printing its whole tree.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_DEPTH = 2;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How many placeholders an array example holds at most. `minItems` comes from
|
|
53
|
+
* a schema someone else wrote; one declaring a million would otherwise have
|
|
54
|
+
* the listing allocate a million placeholders for one line of description.
|
|
55
|
+
* An example cut short is still a call that shows the shape, which is its job.
|
|
56
|
+
*/
|
|
57
|
+
const EXAMPLE_ITEMS_MAX = 8;
|
|
58
|
+
|
|
59
|
+
/** An argument description is cut to this, so one verbose argument can't crowd out the rest. */
|
|
60
|
+
const DESCRIPTION_MAX = 160;
|
|
61
|
+
|
|
62
|
+
type Dict = Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
function isDict(value: unknown): value is Dict {
|
|
65
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The JSON-Schema keywords this module cannot reason about; their presence switches checking off. */
|
|
69
|
+
const UNSUPPORTED_KEYWORDS = ['anyOf', 'oneOf', 'allOf', 'not', '$ref', 'if', 'then', 'else'] as const;
|
|
70
|
+
|
|
71
|
+
function unsupportedKeyword(schema: Dict): string | undefined {
|
|
72
|
+
return UNSUPPORTED_KEYWORDS.find((k) => schema[k] !== undefined);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The declared types of a (sub)schema as a list, or undefined when it declares none we know. */
|
|
76
|
+
function declaredTypes(schema: Dict): string[] | undefined {
|
|
77
|
+
const raw = schema.type;
|
|
78
|
+
const list = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw.filter((t) => typeof t === 'string') : [];
|
|
79
|
+
const known = (list as string[]).filter((t) =>
|
|
80
|
+
['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'].includes(t),
|
|
81
|
+
);
|
|
82
|
+
return known.length > 0 ? known : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The placeholder value a type takes in the call example. */
|
|
86
|
+
function placeholder(schema: Dict, depth: number): unknown {
|
|
87
|
+
// A value the schema fixes is shown as that value: `"..."` would be a call
|
|
88
|
+
// the check refuses.
|
|
89
|
+
if (schema.const !== undefined) return schema.const;
|
|
90
|
+
// The first member the rest of the schema admits, so a sibling constraint
|
|
91
|
+
// (`minLength`, a range) cannot make the example one the check refuses.
|
|
92
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
93
|
+
return schema.enum.find((option) => valueMismatch('', schema, option) === null) ?? schema.enum[0];
|
|
94
|
+
}
|
|
95
|
+
const type = declaredTypes(schema)?.[0];
|
|
96
|
+
if (type === 'number' || type === 'integer') return numberPlaceholder(schema, type === 'integer');
|
|
97
|
+
if (type === 'boolean') return true;
|
|
98
|
+
if (type === 'array') {
|
|
99
|
+
// An array that must not be empty is shown with one element, so the
|
|
100
|
+
// example satisfies its own schema (`tools_info` takes `tool_names`
|
|
101
|
+
// with at least one name). Otherwise empty, the shortest array that works.
|
|
102
|
+
const minItems = typeof schema.minItems === 'number' ? schema.minItems : 0;
|
|
103
|
+
if (minItems < 1) return [];
|
|
104
|
+
const items = isDict(schema.items) ? schema.items : {};
|
|
105
|
+
return Array.from({ length: Math.min(minItems, EXAMPLE_ITEMS_MAX) }, () => placeholder(items, depth));
|
|
106
|
+
}
|
|
107
|
+
if (type === 'object') {
|
|
108
|
+
// An object whose own required arguments are known is shown with them, so
|
|
109
|
+
// the `{ body: { branch, path } }` envelope is spelled out rather than
|
|
110
|
+
// handed over as an empty `{}` the agent has to guess the inside of. The
|
|
111
|
+
// check stops at the same depth (see `compileCheck`), so an example cut
|
|
112
|
+
// off here is still one the check accepts.
|
|
113
|
+
return depth < MAX_DEPTH ? exampleArguments(schema, depth + 1) : {};
|
|
114
|
+
}
|
|
115
|
+
// A value that must be null is shown as `null`: `"..."` would be a call the
|
|
116
|
+
// check refuses.
|
|
117
|
+
if (type === 'null') return null;
|
|
118
|
+
// A string long enough for its `minLength`: `"..."` fails a schema that
|
|
119
|
+
// wants more. (A `pattern` is not synthesised — a placeholder cannot be
|
|
120
|
+
// made to match an arbitrary expression — so the example stays `"..."`.)
|
|
121
|
+
if (type === 'string') return stringPlaceholder(schema);
|
|
122
|
+
// No declared type is shown as a string placeholder: the overwhelming
|
|
123
|
+
// majority of such arguments are strings, and a `"..."` reads as "put a
|
|
124
|
+
// value here" in a way `null` does not.
|
|
125
|
+
return '...';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `"..."`, padded with dots to the schema's `minLength` (capped: a bound is a bound, not a size). */
|
|
129
|
+
function stringPlaceholder(schema: Dict): string {
|
|
130
|
+
const minLength = typeof schema.minLength === 'number' && Number.isFinite(schema.minLength) ? schema.minLength : 0;
|
|
131
|
+
return '.'.repeat(Math.min(64, Math.max(3, Math.ceil(minLength))));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A number the schema's range admits: `0`, unless a bound rules it out —
|
|
136
|
+
* then the nearest value at the lower bound, the upper bound, or between the
|
|
137
|
+
* two, whichever satisfies EVERY bound (`exclusiveMinimum: 1, maximum: 1.5`
|
|
138
|
+
* gives `1.5` and `exclusiveMaximum: 1.5` in its place
|
|
139
|
+
* gives `1.25`, not a `2` the check would refuse). A range nothing satisfies
|
|
140
|
+
* keeps `0`: the schema is at fault, and no example can fix it.
|
|
141
|
+
*/
|
|
142
|
+
function numberPlaceholder(schema: Dict, integer: boolean): number {
|
|
143
|
+
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
144
|
+
const minimum = num(schema.minimum);
|
|
145
|
+
const maximum = num(schema.maximum);
|
|
146
|
+
const exclusiveMinimum = num(schema.exclusiveMinimum);
|
|
147
|
+
const exclusiveMaximum = num(schema.exclusiveMaximum);
|
|
148
|
+
const lows = [minimum, exclusiveMinimum].filter((v): v is number => v !== undefined);
|
|
149
|
+
const highs = [maximum, exclusiveMaximum].filter((v): v is number => v !== undefined);
|
|
150
|
+
const low = lows.length > 0 ? Math.max(...lows) : undefined;
|
|
151
|
+
const high = highs.length > 0 ? Math.min(...highs) : undefined;
|
|
152
|
+
const candidates = [
|
|
153
|
+
0,
|
|
154
|
+
minimum,
|
|
155
|
+
exclusiveMinimum === undefined ? undefined : exclusiveMinimum + 1,
|
|
156
|
+
maximum,
|
|
157
|
+
exclusiveMaximum === undefined ? undefined : exclusiveMaximum - 1,
|
|
158
|
+
low !== undefined && high !== undefined ? (low + high) / 2 : undefined,
|
|
159
|
+
].flatMap((c) => (c === undefined ? [] : integer ? [Math.ceil(c), Math.floor(c)] : [c]));
|
|
160
|
+
return candidates.find((c) => numericBoundBroken(schema, c) === null) ?? 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The arguments the call example passes: one placeholder per REQUIRED
|
|
165
|
+
* argument, in the order the schema requires them, and nothing else.
|
|
166
|
+
*
|
|
167
|
+
* This is the example as a VALUE, which is what makes the example testable —
|
|
168
|
+
* every tool the platform declares is checked with it against its own schema,
|
|
169
|
+
* so an example an agent copies is one the check accepts.
|
|
170
|
+
*/
|
|
171
|
+
export function exampleArguments(inputs: unknown, depth = 1): Dict {
|
|
172
|
+
const schema = isDict(inputs) ? inputs : {};
|
|
173
|
+
const properties = isDict(schema.properties) ? schema.properties : {};
|
|
174
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === 'string') : [];
|
|
175
|
+
const args: Dict = {};
|
|
176
|
+
for (const name of required as string[]) {
|
|
177
|
+
const prop = properties[name];
|
|
178
|
+
// An own property by definition: a bare `args.__proto__ = …` would set
|
|
179
|
+
// the prototype and lose the argument.
|
|
180
|
+
Object.defineProperty(args, name, {
|
|
181
|
+
value: placeholder(isDict(prop) ? prop : {}, depth),
|
|
182
|
+
enumerable: true,
|
|
183
|
+
writable: true,
|
|
184
|
+
configurable: true,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return args;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A key as JavaScript source: bare when it is an identifier, quoted (and
|
|
192
|
+
* escaped) when it is not, e.g. `"odd-key"`. `__proto__` is computed
|
|
193
|
+
* (`["__proto__"]`): bare or quoted in an object literal it sets the prototype.
|
|
194
|
+
*/
|
|
195
|
+
function renderKey(key: string): string {
|
|
196
|
+
if (key === '__proto__') return '["__proto__"]';
|
|
197
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The placeholder tree as source an agent can paste: object keys bare, strings
|
|
202
|
+
* double-quoted, one space inside the braces. Every value here was produced by
|
|
203
|
+
* {@link placeholder}; strings and keys that are not identifiers are quoted as
|
|
204
|
+
* JSON, so a value taken from an `enum` is escaped like any other.
|
|
205
|
+
*/
|
|
206
|
+
function render(value: unknown): string {
|
|
207
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
208
|
+
if (Array.isArray(value)) return value.length === 0 ? '[]' : `[${value.map(render).join(', ')}]`;
|
|
209
|
+
if (isDict(value)) {
|
|
210
|
+
const entries = Object.entries(value).map(([k, v]) => `${renderKey(k)}: ${render(v)}`);
|
|
211
|
+
return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`;
|
|
212
|
+
}
|
|
213
|
+
return String(value);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The call example for one tool: the namespace this connection exposes, the
|
|
218
|
+
* tool's name, and its required arguments with a placeholder each, in the shape
|
|
219
|
+
* the tool really takes.
|
|
220
|
+
*
|
|
221
|
+
* `utcpName` is the tool's registered UTCP name (`<manual>.<tool>`); the
|
|
222
|
+
* namespace and name come from the same mapping the chain runtime uses, so the
|
|
223
|
+
* example is literally callable inside `call_tool_chain`. Optional arguments
|
|
224
|
+
* are left out — the example is the shortest call that can work, not a catalog.
|
|
225
|
+
*/
|
|
226
|
+
export function callExample(utcpName: string, inputs: unknown): string {
|
|
227
|
+
return `${utcpNameToTsInterfaceName(utcpName)}(${render(exampleArguments(inputs))})`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The `Call:` line as it appears at the top of a description. */
|
|
231
|
+
export function callLine(utcpName: string, inputs: unknown): string {
|
|
232
|
+
return `${CALL_LINE_PREFIX}${callExample(utcpName, inputs)}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A description with its call example ahead of it. Idempotent: a description
|
|
237
|
+
* that already opens with a `Call:` line keeps the one it has, so a surface
|
|
238
|
+
* that lists the same tool through two layers cannot stack two examples.
|
|
239
|
+
*/
|
|
240
|
+
export function withCallExample(description: string | undefined, utcpName: string, inputs: unknown): string {
|
|
241
|
+
const body = description ?? '';
|
|
242
|
+
if (body.startsWith(CALL_LINE_PREFIX)) return body;
|
|
243
|
+
return body === '' ? callLine(utcpName, inputs) : `${callLine(utcpName, inputs)}\n\n${body}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Split a description into its `Call:` line and the rest, so a caller that
|
|
248
|
+
* prepends text of its own (the knowledge-base tools' purpose prefix) can keep
|
|
249
|
+
* the example first — the line only does its job if it is the first thing read.
|
|
250
|
+
*/
|
|
251
|
+
export function splitCallLine(description: string): { call: string | null; rest: string } {
|
|
252
|
+
if (!description.startsWith(CALL_LINE_PREFIX)) return { call: null, rest: description };
|
|
253
|
+
const end = description.indexOf('\n');
|
|
254
|
+
if (end < 0) return { call: description, rest: '' };
|
|
255
|
+
return { call: description.slice(0, end), rest: description.slice(end + 1).replace(/^\n+/, '') };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function shortDescription(schema: Dict): string {
|
|
259
|
+
const raw = typeof schema.description === 'string' ? schema.description.replace(/\s+/g, ' ').trim() : '';
|
|
260
|
+
if (raw.length <= DESCRIPTION_MAX) return raw;
|
|
261
|
+
return `${raw.slice(0, DESCRIPTION_MAX - 1)}…`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function typeLabel(schema: Dict): string {
|
|
265
|
+
return declaredTypes(schema)?.join(' or ') ?? 'any';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The tool's interface as lines: every argument with its type, whether it is
|
|
270
|
+
* required, and its description — the top level, then one level below it,
|
|
271
|
+
* indented. This is what an agent needs in order to correct its call, and it
|
|
272
|
+
* is the schema's own content, never prose about it.
|
|
273
|
+
*/
|
|
274
|
+
export function describeInterface(inputs: unknown, depth = 1, indent = ''): string[] {
|
|
275
|
+
const schema = isDict(inputs) ? inputs : {};
|
|
276
|
+
const properties = isDict(schema.properties) ? schema.properties : {};
|
|
277
|
+
const required = new Set(
|
|
278
|
+
(Array.isArray(schema.required) ? schema.required : []).filter((r): r is string => typeof r === 'string'),
|
|
279
|
+
);
|
|
280
|
+
const lines: string[] = [];
|
|
281
|
+
for (const [name, raw] of Object.entries(properties)) {
|
|
282
|
+
const prop = isDict(raw) ? raw : {};
|
|
283
|
+
const description = shortDescription(prop);
|
|
284
|
+
lines.push(
|
|
285
|
+
`${indent}${name} (${typeLabel(prop)}, ${required.has(name) ? 'required' : 'optional'})` +
|
|
286
|
+
(description === '' ? '' : ` — ${description}`),
|
|
287
|
+
);
|
|
288
|
+
if (depth < MAX_DEPTH && declaredTypes(prop)?.includes('object') && isDict(prop.properties)) {
|
|
289
|
+
lines.push(...describeInterface(prop, depth + 1, `${indent} `));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return lines;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The whole refusal: one sentence that the arguments do not match, the
|
|
297
|
+
* mismatches one per line, the interface, and the call example last — the order
|
|
298
|
+
* an agent reads it in, ending with the line it can copy.
|
|
299
|
+
*/
|
|
300
|
+
export function argumentsDoNotMatchMessage(
|
|
301
|
+
toolName: string,
|
|
302
|
+
utcpName: string,
|
|
303
|
+
inputs: unknown,
|
|
304
|
+
mismatches: string[],
|
|
305
|
+
options: {
|
|
306
|
+
/**
|
|
307
|
+
* The schema the CALL EXAMPLE is generated from, when that is not the
|
|
308
|
+
* schema the arguments were checked against. A route-hosted tool is checked
|
|
309
|
+
* against its FLAT arguments — the ones its handler receives, and so the
|
|
310
|
+
* ones the mismatch lines and the interface name — while the example still
|
|
311
|
+
* has to show the `{ body: { … } }` envelope an agent actually types.
|
|
312
|
+
* Default: one schema for both.
|
|
313
|
+
*/
|
|
314
|
+
exampleInputs?: unknown;
|
|
315
|
+
} = {},
|
|
316
|
+
): string {
|
|
317
|
+
const interfaceLines = describeInterface(inputs);
|
|
318
|
+
return [
|
|
319
|
+
`The arguments do not match the "${toolName}" tool.`,
|
|
320
|
+
...mismatches,
|
|
321
|
+
`Interface of "${toolName}":`,
|
|
322
|
+
...(interfaceLines.length > 0 ? interfaceLines : ['(this tool takes no arguments)']),
|
|
323
|
+
callLine(utcpName, options.exampleInputs ?? inputs),
|
|
324
|
+
].join('\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A compiled check for one input schema: either the rules to check a call
|
|
329
|
+
* against, or the reason this schema cannot be used for checking.
|
|
330
|
+
*
|
|
331
|
+
* Compiled once per distinct schema and kept (see {@link checkFor}), so the
|
|
332
|
+
* check costs a walk of the arguments and nothing else per call.
|
|
333
|
+
*/
|
|
334
|
+
export type CompiledCheck =
|
|
335
|
+
| { checkable: false; reason: string }
|
|
336
|
+
| { checkable: true; check: (args: Dict) => string[] };
|
|
337
|
+
|
|
338
|
+
/** A value's JSON type, as the schema's vocabulary names it. */
|
|
339
|
+
function jsonTypeOf(value: unknown): string {
|
|
340
|
+
if (value === null) return 'null';
|
|
341
|
+
if (Array.isArray(value)) return 'array';
|
|
342
|
+
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
|
|
343
|
+
if (typeof value === 'object') return 'object';
|
|
344
|
+
return typeof value;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function typeMatches(types: string[], value: unknown): boolean {
|
|
348
|
+
const actual = jsonTypeOf(value);
|
|
349
|
+
// An integer satisfies `number`, and any number satisfies `integer` only
|
|
350
|
+
// when it has no fractional part — which `jsonTypeOf` has already decided.
|
|
351
|
+
return types.some((t) => t === actual || (t === 'number' && actual === 'integer'));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Compile one input schema into a check, or decide it cannot be checked.
|
|
356
|
+
*
|
|
357
|
+
* Deliberately conservative: anything this module cannot reason about with
|
|
358
|
+
* certainty — a combinator, a `$ref`, a schema that is not an object — switches
|
|
359
|
+
* the check OFF rather than guessing. A checker that refuses valid calls would
|
|
360
|
+
* take tools away from every agent at once, so every rule here is one a call
|
|
361
|
+
* cannot satisfy by any reading of the schema.
|
|
362
|
+
*/
|
|
363
|
+
export function compileCheck(inputs: unknown, depth = 1): CompiledCheck {
|
|
364
|
+
if (!isDict(inputs)) return { checkable: false, reason: 'the input schema is not an object' };
|
|
365
|
+
const unsupported = unsupportedKeyword(inputs);
|
|
366
|
+
if (unsupported) return { checkable: false, reason: `the input schema uses "${unsupported}"` };
|
|
367
|
+
// A boolean subschema (`false`: nothing is valid; `true`: anything is) is a
|
|
368
|
+
// rule this module does not apply — read as `{}` it would pass what the
|
|
369
|
+
// schema forbids — so it switches the check off like a combinator does.
|
|
370
|
+
if (hasBooleanSubschema(inputs)) return { checkable: false, reason: 'the input schema uses a boolean subschema' };
|
|
371
|
+
const types = declaredTypes(inputs);
|
|
372
|
+
if (types && !types.includes('object')) {
|
|
373
|
+
return { checkable: false, reason: `the input schema declares type "${types.join(' or ')}", not an object` };
|
|
374
|
+
}
|
|
375
|
+
if (inputs.properties !== undefined && !isDict(inputs.properties)) {
|
|
376
|
+
return { checkable: false, reason: 'the input schema\'s "properties" is not an object' };
|
|
377
|
+
}
|
|
378
|
+
const properties = isDict(inputs.properties) ? inputs.properties : {};
|
|
379
|
+
const required = (Array.isArray(inputs.required) ? inputs.required : []).filter(
|
|
380
|
+
(r): r is string => typeof r === 'string',
|
|
381
|
+
);
|
|
382
|
+
const closed = inputs.additionalProperties === false;
|
|
383
|
+
const hasBodyProperty = Object.prototype.hasOwnProperty.call(properties, 'body');
|
|
384
|
+
|
|
385
|
+
// Sub-checks for the one level below the top: compiled here, with the rest,
|
|
386
|
+
// so a call pays nothing for them. No deeper than the call example goes
|
|
387
|
+
// (`MAX_DEPTH`): an example cut off at `{}` must be a call the check accepts.
|
|
388
|
+
const nested = new Map<string, CompiledCheck>();
|
|
389
|
+
if (depth < MAX_DEPTH) {
|
|
390
|
+
for (const [name, raw] of Object.entries(properties)) {
|
|
391
|
+
if (!isDict(raw) || !declaredTypes(raw)?.includes('object') || !isDict(raw.properties)) continue;
|
|
392
|
+
const inner = compileCheck(raw, depth + 1);
|
|
393
|
+
if (inner.checkable) nested.set(name, inner);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const check = (args: Dict): string[] => {
|
|
398
|
+
const mismatches: string[] = [];
|
|
399
|
+
// The `body` wrapper first: when every argument sits under a `body` key the
|
|
400
|
+
// tool does not have, THAT is what went wrong, and the lines below (a
|
|
401
|
+
// missing required argument, an argument the tool lacks) are its symptoms.
|
|
402
|
+
// Only when the wrapper IS wrong: an open schema with nothing required
|
|
403
|
+
// accepts `{ body: {} }` as it accepts any extra key.
|
|
404
|
+
const keys = Object.keys(args);
|
|
405
|
+
if (
|
|
406
|
+
!hasBodyProperty &&
|
|
407
|
+
(closed || required.length > 0) &&
|
|
408
|
+
keys.length === 1 &&
|
|
409
|
+
keys[0] === 'body' &&
|
|
410
|
+
isDict(args.body)
|
|
411
|
+
) {
|
|
412
|
+
mismatches.push(BODY_AT_TOP_LEVEL_LINE);
|
|
413
|
+
}
|
|
414
|
+
// Own properties only, here and below: `constructor` or `toString` read
|
|
415
|
+
// through the prototype would otherwise count as given — or be checked
|
|
416
|
+
// as a value the caller never sent.
|
|
417
|
+
const given = (name: string): boolean => Object.prototype.hasOwnProperty.call(args, name);
|
|
418
|
+
for (const name of required) {
|
|
419
|
+
if (!given(name)) mismatches.push(`"${name}" is required, and was not given.`);
|
|
420
|
+
}
|
|
421
|
+
if (closed) {
|
|
422
|
+
for (const key of keys) {
|
|
423
|
+
if (!Object.prototype.hasOwnProperty.call(properties, key)) {
|
|
424
|
+
mismatches.push(`"${key}" is not an argument of this tool.`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const [name, raw] of Object.entries(properties)) {
|
|
429
|
+
if (!given(name)) continue;
|
|
430
|
+
const value = args[name];
|
|
431
|
+
if (value === undefined) continue;
|
|
432
|
+
const prop = isDict(raw) ? raw : {};
|
|
433
|
+
const wrong = valueMismatch(name, prop, value);
|
|
434
|
+
if (wrong) {
|
|
435
|
+
mismatches.push(wrong);
|
|
436
|
+
continue; // a wrong value cannot also be walked for its own arguments
|
|
437
|
+
}
|
|
438
|
+
const inner = nested.get(name);
|
|
439
|
+
if (inner?.checkable && isDict(value)) {
|
|
440
|
+
mismatches.push(...inner.check(value).map((m) => qualify(name, m)));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return mismatches;
|
|
444
|
+
};
|
|
445
|
+
return { checkable: true, check };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* What is wrong with one argument's VALUE, or `null`: its type first, then
|
|
450
|
+
* the constraints the schema puts on a value of that type — `enum`/`const`,
|
|
451
|
+
* a string's length and `pattern`, a number's range, an array's length and
|
|
452
|
+
* each of its `items`. A call that breaks a declared constraint does not match
|
|
453
|
+
* the schema any more than one of the wrong type does, and is refused the same
|
|
454
|
+
* way rather than reaching the tool.
|
|
455
|
+
*
|
|
456
|
+
* `format` is an annotation (JSON Schema does not require it to be asserted)
|
|
457
|
+
* and is not checked; neither is a keyword this module does not know. A
|
|
458
|
+
* subschema using a combinator is not ours to judge and passes as it is.
|
|
459
|
+
*/
|
|
460
|
+
function valueMismatch(name: string, schema: Dict, value: unknown): string | null {
|
|
461
|
+
if (unsupportedKeyword(schema)) return null;
|
|
462
|
+
const expected = declaredTypes(schema);
|
|
463
|
+
if (expected && !typeMatches(expected, value)) {
|
|
464
|
+
return `"${name}" must be ${expected.join(' or ')}, but ${jsonTypeOf(value)} was given.`;
|
|
465
|
+
}
|
|
466
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((option) => sameJson(option, value))) {
|
|
467
|
+
return `"${name}" must be one of ${schema.enum.map((o) => JSON.stringify(o)).join(', ')}, but ${shortJson(value)} was given.`;
|
|
468
|
+
}
|
|
469
|
+
if (schema.const !== undefined && !sameJson(schema.const, value)) {
|
|
470
|
+
return `"${name}" must be ${JSON.stringify(schema.const)}, but ${shortJson(value)} was given.`;
|
|
471
|
+
}
|
|
472
|
+
if (typeof value === 'string') {
|
|
473
|
+
// Length in code points, as JSON Schema counts it, not UTF-16 units.
|
|
474
|
+
const length = [...value].length;
|
|
475
|
+
if (typeof schema.minLength === 'number' && length < schema.minLength) {
|
|
476
|
+
return `"${name}" must be at least ${schema.minLength} character(s) long, but ${length} was given.`;
|
|
477
|
+
}
|
|
478
|
+
if (typeof schema.maxLength === 'number' && length > schema.maxLength) {
|
|
479
|
+
return `"${name}" must be at most ${schema.maxLength} character(s) long, but ${length} was given.`;
|
|
480
|
+
}
|
|
481
|
+
const pattern = typeof schema.pattern === 'string' ? compiledPattern(schema.pattern) : null;
|
|
482
|
+
if (pattern && !pattern.test(value)) {
|
|
483
|
+
return `"${name}" must match the pattern ${schema.pattern as string}, but ${shortJson(value)} was given.`;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (typeof value === 'number') {
|
|
487
|
+
const bound = numericBoundBroken(schema, value);
|
|
488
|
+
if (bound) return `"${name}" must be ${bound}, but ${value} was given.`;
|
|
489
|
+
}
|
|
490
|
+
if (Array.isArray(value)) {
|
|
491
|
+
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
|
|
492
|
+
return `"${name}" must hold at least ${schema.minItems} item(s), but ${value.length} was given.`;
|
|
493
|
+
}
|
|
494
|
+
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
|
|
495
|
+
return `"${name}" must hold at most ${schema.maxItems} item(s), but ${value.length} was given.`;
|
|
496
|
+
}
|
|
497
|
+
if (isDict(schema.items)) {
|
|
498
|
+
for (let i = 0; i < value.length; i++) {
|
|
499
|
+
const wrong = valueMismatch(`${name}[${i}]`, schema.items, value[i]);
|
|
500
|
+
if (wrong) return wrong; // the first bad item is enough to correct the call
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Whether a property or item schema anywhere the check would look (`MAX_DEPTH`
|
|
509
|
+
* levels, items included) is a boolean rather than an object.
|
|
510
|
+
*/
|
|
511
|
+
function hasBooleanSubschema(schema: Dict, depth = 1): boolean {
|
|
512
|
+
// `additionalProperties: false` is the ordinary way to close an object and
|
|
513
|
+
// is applied as such; a boolean PROPERTY or ITEM schema is the case here.
|
|
514
|
+
const subschemas: unknown[] = isDict(schema.properties) ? Object.values(schema.properties) : [];
|
|
515
|
+
if (schema.items !== undefined) subschemas.push(schema.items);
|
|
516
|
+
for (const sub of subschemas) {
|
|
517
|
+
if (typeof sub === 'boolean') return true;
|
|
518
|
+
if (isDict(sub) && depth < MAX_DEPTH && hasBooleanSubschema(sub, depth + 1)) return true;
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The numeric bound `value` breaks, phrased for a refusal, or `null`. */
|
|
524
|
+
function numericBoundBroken(schema: Dict, value: number): string | null {
|
|
525
|
+
const { minimum, maximum, exclusiveMinimum, exclusiveMaximum } = schema;
|
|
526
|
+
if (typeof minimum === 'number' && value < minimum) return `at least ${minimum}`;
|
|
527
|
+
if (typeof maximum === 'number' && value > maximum) return `at most ${maximum}`;
|
|
528
|
+
if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) return `greater than ${exclusiveMinimum}`;
|
|
529
|
+
if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) return `less than ${exclusiveMaximum}`;
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Equality as JSON sees it, for `enum` and `const`: arrays in order, objects
|
|
535
|
+
* by their properties whatever order their keys were written in.
|
|
536
|
+
*/
|
|
537
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
538
|
+
if (a === b) return true;
|
|
539
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
540
|
+
return (
|
|
541
|
+
Array.isArray(a) &&
|
|
542
|
+
Array.isArray(b) &&
|
|
543
|
+
a.length === b.length &&
|
|
544
|
+
a.every((value, index) => sameJson(value, b[index]))
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (!isDict(a) || !isDict(b)) return false;
|
|
548
|
+
const keys = Object.keys(a);
|
|
549
|
+
return (
|
|
550
|
+
keys.length === Object.keys(b).length &&
|
|
551
|
+
keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameJson(a[key], b[key]))
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** A value as it appears in a refusal: JSON, cut short so one long argument cannot fill the message. */
|
|
556
|
+
function shortJson(value: unknown): string {
|
|
557
|
+
const text = JSON.stringify(value) ?? String(value);
|
|
558
|
+
return text.length <= 60 ? text : `${text.slice(0, 59)}…`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Could this pattern backtrack catastrophically? True for a group that repeats
|
|
563
|
+
* (`*`, `+`, `{…}`) and itself contains a quantifier or an
|
|
564
|
+
* alternation — `(a+)+`, `(a|a)*`, `(\w+\s?)*` — and for a backreference.
|
|
565
|
+
*
|
|
566
|
+
* JavaScript's matcher backtracks, so such a pattern, which comes from a
|
|
567
|
+
* schema someone else wrote, could hold the event loop for seconds on one
|
|
568
|
+
* caller's string. The test is a conservative over-approximation (it flags
|
|
569
|
+
* some patterns that would in fact be fast); a flagged pattern is simply not
|
|
570
|
+
* asserted, which is what this module does with any keyword it cannot judge
|
|
571
|
+
* safely.
|
|
572
|
+
*/
|
|
573
|
+
export function patternMayBacktrack(source: string): boolean {
|
|
574
|
+
if (/\\[1-9]|\\k</.test(source)) return true;
|
|
575
|
+
// One frame per open group: did anything inside it repeat or branch?
|
|
576
|
+
const stack: boolean[] = [];
|
|
577
|
+
let risky = false; // what the group that just closed held
|
|
578
|
+
let previousClosedGroup = false;
|
|
579
|
+
for (let i = 0; i < source.length; i++) {
|
|
580
|
+
const ch = source[i];
|
|
581
|
+
const afterGroup = previousClosedGroup;
|
|
582
|
+
previousClosedGroup = false;
|
|
583
|
+
if (ch === '\\') {
|
|
584
|
+
i++;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (ch === '[') {
|
|
588
|
+
// A character class is one atom: skip to its closing bracket.
|
|
589
|
+
for (i++; i < source.length && source[i] !== ']'; i++) if (source[i] === '\\') i++;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (ch === '(') {
|
|
593
|
+
stack.push(false);
|
|
594
|
+
if (source[i + 1] === '?') i++; // `(?:`, `(?=`, `(?<name>`: the `?` is syntax, not a quantifier
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (ch === ')') {
|
|
598
|
+
risky = stack.pop() ?? false;
|
|
599
|
+
// What a group holds, the group around it holds too.
|
|
600
|
+
if (risky && stack.length > 0) stack[stack.length - 1] = true;
|
|
601
|
+
previousClosedGroup = true;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const quantifier = ch === '*' || ch === '+' || ch === '?' || ch === '{';
|
|
605
|
+
if (quantifier || ch === '|') {
|
|
606
|
+
if (stack.length > 0) stack[stack.length - 1] = true;
|
|
607
|
+
// `?` repeats nothing; any `{…}` count is treated as a repeat, a long
|
|
608
|
+
// fixed count compounding the backtracking just as `+` does.
|
|
609
|
+
const repeats = ch === '*' || ch === '+' || ch === '{';
|
|
610
|
+
if (afterGroup && repeats && risky) return true;
|
|
611
|
+
}
|
|
612
|
+
if (ch === '{') {
|
|
613
|
+
const close = source.indexOf('}', i);
|
|
614
|
+
if (close > i) i = close;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* A `pattern` compiled once. One this runtime cannot compile, or one that may
|
|
622
|
+
* backtrack catastrophically (see {@link patternMayBacktrack}), is not checked
|
|
623
|
+
* rather than refused.
|
|
624
|
+
*/
|
|
625
|
+
const patterns = new Map<string, RegExp | null>();
|
|
626
|
+
/** How many compiled patterns are kept: past this the cache starts over, so a process listing ever-new schemas does not grow without bound. */
|
|
627
|
+
const PATTERN_CACHE_MAX = 512;
|
|
628
|
+
function compiledPattern(source: string): RegExp | null {
|
|
629
|
+
if (!patterns.has(source)) {
|
|
630
|
+
if (patterns.size >= PATTERN_CACHE_MAX) patterns.clear();
|
|
631
|
+
let re: RegExp | null = null;
|
|
632
|
+
if (patternMayBacktrack(source)) {
|
|
633
|
+
patterns.set(source, null);
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
re = new RegExp(source, 'u');
|
|
638
|
+
} catch {
|
|
639
|
+
try {
|
|
640
|
+
re = new RegExp(source);
|
|
641
|
+
} catch {
|
|
642
|
+
re = null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
patterns.set(source, re);
|
|
646
|
+
}
|
|
647
|
+
return patterns.get(source) ?? null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** A nested mismatch, named by its path (`"body.path" is required…`). */
|
|
651
|
+
function qualify(parent: string, mismatch: string): string {
|
|
652
|
+
return mismatch.replace(/^"([^"]+)"/, (_m, name: string) => `"${parent}.${name}"`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* One compiled check per distinct input schema, kept for as long as the schema
|
|
657
|
+
* object lives. Both surfaces that check a call hand in the SAME schema object
|
|
658
|
+
* every time — the tool repository hands out tools whose `inputs` is the stored
|
|
659
|
+
* object, and a route's schema is the one its `toolDef` was given — so the
|
|
660
|
+
* check is compiled once per tool rather than once per call, and a call pays
|
|
661
|
+
* for a walk of its arguments and nothing else.
|
|
662
|
+
*/
|
|
663
|
+
const compiled = new WeakMap<object, CompiledCheck>();
|
|
664
|
+
|
|
665
|
+
/** The compiled check for one input schema, compiled at most once per schema. */
|
|
666
|
+
export function checkFor(inputs: unknown): CompiledCheck {
|
|
667
|
+
if (typeof inputs !== 'object' || inputs === null) return compileCheck(inputs);
|
|
668
|
+
const hit = compiled.get(inputs);
|
|
669
|
+
if (hit) return hit;
|
|
670
|
+
const built = compileCheck(inputs);
|
|
671
|
+
compiled.set(inputs, built);
|
|
672
|
+
return built;
|
|
673
|
+
}
|