@modelprofile.com/flexharness 10.12.0 → 10.13.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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +7 -0
- package/dist_ts/classes.flexharness.js +91 -7
- package/dist_ts/classes.toolcatalog.d.ts +80 -0
- package/dist_ts/classes.toolcatalog.js +1336 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.d.ts +150 -2
- package/dist_ts/interfaces.js +29 -1
- package/dist_ts/plugins.d.ts +4 -0
- package/dist_ts/plugins.js +5 -1
- package/dist_ts/utils.harnessoptions.d.ts +2 -1
- package/dist_ts/utils.harnessoptions.js +19 -1
- package/dist_ts/utils.schemadeclarations.d.ts +53 -0
- package/dist_ts/utils.schemadeclarations.js +315 -0
- package/package.json +4 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +106 -5
- package/ts/classes.toolcatalog.ts +1431 -0
- package/ts/index.ts +1 -0
- package/ts/interfaces.ts +167 -2
- package/ts/plugins.ts +7 -0
- package/ts/utils.harnessoptions.ts +22 -0
- package/ts/utils.schemadeclarations.ts +322 -0
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { FlexHarnessValidationError } from './errors.js';
|
|
3
|
+
import {
|
|
4
|
+
FLEX_TOOL_CATALOG_LIMITS as limits,
|
|
5
|
+
FLEX_TOOL_CATALOG_TOOL_NAMES as toolNames,
|
|
6
|
+
type IFlexCatalogDescribeInput,
|
|
7
|
+
type IFlexCatalogDescribeResult,
|
|
8
|
+
type IFlexCatalogMethod,
|
|
9
|
+
type IFlexCatalogParamIssue,
|
|
10
|
+
type IFlexCatalogRunContext,
|
|
11
|
+
type IFlexCatalogSearchInput,
|
|
12
|
+
type IFlexCatalogToolDefinition,
|
|
13
|
+
type IFlexToolCatalog,
|
|
14
|
+
type IFlexToolCatalogOptions,
|
|
15
|
+
type TFlexCatalogCallError,
|
|
16
|
+
type TJsonValue,
|
|
17
|
+
} from './interfaces.js';
|
|
18
|
+
import {
|
|
19
|
+
SchemaDeclarationWriter,
|
|
20
|
+
countOf,
|
|
21
|
+
docBlock,
|
|
22
|
+
indentLines,
|
|
23
|
+
isIdentifier,
|
|
24
|
+
isSchemaRecord,
|
|
25
|
+
type IRenderedDeclaration,
|
|
26
|
+
type TJsonSchemaNode,
|
|
27
|
+
} from './utils.schemadeclarations.js';
|
|
28
|
+
import { validateUtf8String } from './utils.validation.js';
|
|
29
|
+
|
|
30
|
+
type TValidationResult = { success: true; value: unknown } | { success: false; error: Error };
|
|
31
|
+
type TValidate = (value: unknown) => TValidationResult | PromiseLike<TValidationResult>;
|
|
32
|
+
type TDeclarationMode = NonNullable<IFlexToolCatalogOptions['declarations']>;
|
|
33
|
+
type TSchemaDraft = '4' | '7' | '2019-09' | '2020-12';
|
|
34
|
+
|
|
35
|
+
interface ICatalogSchema {
|
|
36
|
+
readonly json: TJsonSchemaNode;
|
|
37
|
+
/** The schema's own validator, such as zod's, run after JSON Schema validation. */
|
|
38
|
+
readonly validate?: TValidate;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ISchemaScope {
|
|
42
|
+
readonly root: TJsonSchemaNode;
|
|
43
|
+
/** The named type of each identifier-keyed local definition, by `<container>/<key>`. */
|
|
44
|
+
readonly localNames: Map<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface IMethodEntry {
|
|
48
|
+
readonly path: string;
|
|
49
|
+
readonly namespace: string;
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly summary: string;
|
|
52
|
+
readonly description?: string;
|
|
53
|
+
readonly tags: readonly string[];
|
|
54
|
+
readonly examples: readonly string[];
|
|
55
|
+
readonly input: ICatalogSchema;
|
|
56
|
+
readonly inputScope: ISchemaScope;
|
|
57
|
+
readonly output?: ICatalogSchema;
|
|
58
|
+
readonly outputScope?: ISchemaScope;
|
|
59
|
+
readonly declaration?: string;
|
|
60
|
+
readonly run: IFlexCatalogMethod['run'];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ITypeEntry {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly description?: string;
|
|
66
|
+
readonly declaration?: string;
|
|
67
|
+
/** A catalog type's schema. */
|
|
68
|
+
readonly schema?: ICatalogSchema;
|
|
69
|
+
/** The scope the type's schema or local definition renders in. */
|
|
70
|
+
readonly scope?: ISchemaScope;
|
|
71
|
+
/** A local definition's schema, registered under `name`. */
|
|
72
|
+
readonly definition?: TJsonSchemaNode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface IPreparedCall {
|
|
76
|
+
readonly success: true;
|
|
77
|
+
readonly method: string;
|
|
78
|
+
readonly params: unknown;
|
|
79
|
+
run(context: IFlexCatalogRunContext<any>): ReturnType<IFlexCatalogMethod['run']>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface IFailedCall {
|
|
83
|
+
readonly success: false;
|
|
84
|
+
readonly error: TFlexCatalogCallError;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const aiSchemaSymbol = Symbol.for('vercel.ai.schema');
|
|
88
|
+
const methodPathPattern = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
89
|
+
const typeNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
90
|
+
/** Words a declaration cannot use as a function, namespace or type name. */
|
|
91
|
+
const reservedWords = new Set([
|
|
92
|
+
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
|
93
|
+
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import',
|
|
94
|
+
'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public',
|
|
95
|
+
'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while',
|
|
96
|
+
'with', 'yield',
|
|
97
|
+
]);
|
|
98
|
+
const controlPattern = /[\u0000-\u001f\u007f]/;
|
|
99
|
+
const definitionRefPattern = /^#\/(\$defs|definitions)\/([^/]+)$/;
|
|
100
|
+
const declaredTypesHeader = '// Declares types: ';
|
|
101
|
+
const identifierScanPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
102
|
+
const describeFormats = new Set(['typescript', 'json-schema']);
|
|
103
|
+
const methodKeys = new Set([
|
|
104
|
+
'method', 'summary', 'description', 'tags', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run',
|
|
105
|
+
]);
|
|
106
|
+
const typeKeys = new Set(['name', 'description', 'schema', 'declaration']);
|
|
107
|
+
const stopWords = new Set([
|
|
108
|
+
'a', 'an', 'and', 'are', 'all', 'as', 'be', 'by', 'for', 'from', 'get', 'i', 'in', 'is', 'it', 'me', 'my',
|
|
109
|
+
'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with',
|
|
110
|
+
]);
|
|
111
|
+
const fieldWeights = { name: 3, namespace: 1.5, summary: 2, tags: 2, description: 1 } as const;
|
|
112
|
+
type TSearchField = keyof typeof fieldWeights;
|
|
113
|
+
const bm25K1 = 1.2;
|
|
114
|
+
const bm25B = 0.75;
|
|
115
|
+
const coreCache = new WeakMap<object, Map<TDeclarationMode, Promise<FlexToolCatalogCore>>>();
|
|
116
|
+
|
|
117
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
118
|
+
typeof value === 'object'
|
|
119
|
+
&& value !== null
|
|
120
|
+
&& !Array.isArray(value)
|
|
121
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
122
|
+
|
|
123
|
+
const compareCodePoints = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
|
|
124
|
+
|
|
125
|
+
const stableStringify = (value: unknown): string => {
|
|
126
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
127
|
+
if (isSchemaRecord(value)) {
|
|
128
|
+
return `{${Object.keys(value).sort(compareCodePoints)
|
|
129
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
130
|
+
}
|
|
131
|
+
return JSON.stringify(value) ?? 'null';
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const deepFreeze = <T>(value: T): T => {
|
|
135
|
+
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
|
|
136
|
+
Object.freeze(value);
|
|
137
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const decodePointerToken = (token: string): string => {
|
|
143
|
+
let decoded = token;
|
|
144
|
+
try {
|
|
145
|
+
decoded = decodeURI(token);
|
|
146
|
+
} catch {
|
|
147
|
+
decoded = token;
|
|
148
|
+
}
|
|
149
|
+
return decoded.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const pointerTokens = (pointer: string): string[] =>
|
|
153
|
+
pointer.replace(/^#/, '').split('/').slice(1).map(decodePointerToken);
|
|
154
|
+
|
|
155
|
+
/** Resolves a local JSON pointer against `root`, following `$ref` tokens of keyword locations. */
|
|
156
|
+
const resolvePointer = (root: TJsonSchemaNode, pointer: string): unknown => {
|
|
157
|
+
let node: unknown = root;
|
|
158
|
+
for (const token of pointerTokens(pointer)) {
|
|
159
|
+
if (token === '$ref' && isSchemaRecord(node) && typeof node.$ref === 'string') {
|
|
160
|
+
node = node.$ref.startsWith('#') ? resolvePointer(root, node.$ref) : undefined;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(node)) node = node[Number(token)];
|
|
164
|
+
else if (isSchemaRecord(node)) node = Object.prototype.hasOwnProperty.call(node, token) ? node[token] : undefined;
|
|
165
|
+
else return undefined;
|
|
166
|
+
}
|
|
167
|
+
return node;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const collectRefs = (node: unknown, refs: Set<string>): void => {
|
|
171
|
+
if (Array.isArray(node)) {
|
|
172
|
+
for (const child of node) collectRefs(child, refs);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!isSchemaRecord(node)) return;
|
|
176
|
+
for (const [key, child] of Object.entries(node)) {
|
|
177
|
+
if (key === '$ref' && typeof child === 'string') refs.add(child);
|
|
178
|
+
else if (key !== 'const' && key !== 'enum' && key !== 'default' && key !== 'examples') collectRefs(child, refs);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const schemaDraft = (root: TJsonSchemaNode): TSchemaDraft => {
|
|
183
|
+
const declared = isSchemaRecord(root) && typeof root.$schema === 'string' ? root.$schema : '';
|
|
184
|
+
if (declared.includes('draft-04')) return '4';
|
|
185
|
+
if (declared.includes('draft-06') || declared.includes('draft-07')) return '7';
|
|
186
|
+
if (declared.includes('2019-09')) return '2019-09';
|
|
187
|
+
return '2020-12';
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const withoutDraft = (schema: TJsonSchemaNode): TJsonSchemaNode => {
|
|
191
|
+
if (!isSchemaRecord(schema) || schema.$schema === undefined) return schema;
|
|
192
|
+
const { $schema: _draft, ...rest } = schema;
|
|
193
|
+
return rest;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const stem = (word: string): string => {
|
|
197
|
+
if (word.length > 4 && word.endsWith('ies')) return `${word.slice(0, -3)}y`;
|
|
198
|
+
if (word.length > 3 && word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1);
|
|
199
|
+
return word;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const searchTerms = (text: string): string[] => text
|
|
203
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
204
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
205
|
+
.toLowerCase()
|
|
206
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.map(stem);
|
|
209
|
+
|
|
210
|
+
const editDistance = (left: string, right: string, limit: number): number => {
|
|
211
|
+
if (Math.abs(left.length - right.length) > limit) return limit + 1;
|
|
212
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
213
|
+
for (let row = 1; row <= left.length; row++) {
|
|
214
|
+
const current = [row];
|
|
215
|
+
let best = row;
|
|
216
|
+
for (let column = 1; column <= right.length; column++) {
|
|
217
|
+
const cost = left[row - 1] === right[column - 1] ? 0 : 1;
|
|
218
|
+
current[column] = Math.min(previous[column] + 1, current[column - 1] + 1, previous[column - 1] + cost);
|
|
219
|
+
best = Math.min(best, current[column]);
|
|
220
|
+
}
|
|
221
|
+
if (best > limit) return limit + 1;
|
|
222
|
+
previous = current;
|
|
223
|
+
}
|
|
224
|
+
return previous[right.length];
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const closeMatches = (wanted: string, candidates: Iterable<string>, count = 3): string[] => {
|
|
228
|
+
const normalized = wanted.toLowerCase();
|
|
229
|
+
const limit = Math.max(2, Math.floor(normalized.length / 3));
|
|
230
|
+
const scored: Array<{ name: string; distance: number }> = [];
|
|
231
|
+
for (const candidate of candidates) {
|
|
232
|
+
const lower = candidate.toLowerCase();
|
|
233
|
+
const lastSegment = lower.slice(lower.lastIndexOf('.') + 1);
|
|
234
|
+
const distance = Math.min(
|
|
235
|
+
editDistance(normalized, lower, limit),
|
|
236
|
+
lower.includes(normalized) || normalized.includes(lastSegment) ? 1 : limit + 1,
|
|
237
|
+
);
|
|
238
|
+
if (distance <= limit) scored.push({ name: candidate, distance });
|
|
239
|
+
}
|
|
240
|
+
return scored
|
|
241
|
+
.sort((left, right) => left.distance - right.distance || compareCodePoints(left.name, right.name))
|
|
242
|
+
.slice(0, count)
|
|
243
|
+
.map((entry) => entry.name);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const preview = (value: unknown): string => {
|
|
247
|
+
if (value === undefined) return 'missing';
|
|
248
|
+
let text: string;
|
|
249
|
+
try {
|
|
250
|
+
text = JSON.stringify(value) ?? String(value);
|
|
251
|
+
} catch {
|
|
252
|
+
text = String(value);
|
|
253
|
+
}
|
|
254
|
+
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const paramsPath = (tokens: readonly (string | number)[]): string => tokens.reduce<string>((path, token) => {
|
|
258
|
+
if (typeof token === 'number' || /^\d+$/.test(token)) return `${path}[${token}]`;
|
|
259
|
+
return isIdentifier(token) ? `${path}.${token}` : `${path}[${JSON.stringify(token)}]`;
|
|
260
|
+
}, 'params');
|
|
261
|
+
|
|
262
|
+
const valueAt = (value: unknown, tokens: readonly (string | number)[]): unknown => {
|
|
263
|
+
let node = value;
|
|
264
|
+
for (const token of tokens) {
|
|
265
|
+
if (Array.isArray(node)) node = node[Number(token)];
|
|
266
|
+
else if (isSchemaRecord(node)) node = Object.prototype.hasOwnProperty.call(node, token) ? node[String(token)] : undefined;
|
|
267
|
+
else return undefined;
|
|
268
|
+
}
|
|
269
|
+
return node;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const compactType = (text: string): string => {
|
|
273
|
+
const compact = text.replace(/\/\*\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
|
|
274
|
+
return compact.length > 200 ? `${compact.slice(0, 197)}...` : compact;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const textOf = (
|
|
278
|
+
value: unknown,
|
|
279
|
+
label: string,
|
|
280
|
+
options: { maxCharacters?: number; maxBytes?: number; singleLine?: boolean },
|
|
281
|
+
): string => {
|
|
282
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
283
|
+
throw new FlexHarnessValidationError(`${label} must be a non-empty string.`);
|
|
284
|
+
}
|
|
285
|
+
if (options.maxBytes !== undefined) validateUtf8String(value, label, options.maxBytes, true);
|
|
286
|
+
if (options.maxCharacters !== undefined && [...value].length > options.maxCharacters) {
|
|
287
|
+
throw new FlexHarnessValidationError(`${label} must be at most ${options.maxCharacters} characters.`);
|
|
288
|
+
}
|
|
289
|
+
if (options.singleLine && controlPattern.test(value)) {
|
|
290
|
+
throw new FlexHarnessValidationError(`${label} must be one line without control characters.`);
|
|
291
|
+
}
|
|
292
|
+
return value;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const textListOf = (
|
|
296
|
+
value: unknown,
|
|
297
|
+
label: string,
|
|
298
|
+
maxItems: number,
|
|
299
|
+
options: { maxCharacters?: number; maxBytes?: number; singleLine?: boolean },
|
|
300
|
+
): string[] => {
|
|
301
|
+
if (value === undefined) return [];
|
|
302
|
+
if (!Array.isArray(value) || value.length > maxItems) {
|
|
303
|
+
throw new FlexHarnessValidationError(`${label} must be an array with at most ${maxItems} entries.`);
|
|
304
|
+
}
|
|
305
|
+
return value.map((entry, index) => textOf(entry, `${label}[${index}]`, options));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
async function resolveCatalogSchema(schema: unknown, label: string): Promise<ICatalogSchema> {
|
|
309
|
+
let json: unknown;
|
|
310
|
+
let validate: TValidate | undefined;
|
|
311
|
+
if (
|
|
312
|
+
typeof schema === 'function'
|
|
313
|
+
|| (isSchemaRecord(schema) && (aiSchemaSymbol in schema || '~standard' in schema))
|
|
314
|
+
) {
|
|
315
|
+
let resolved: plugins.Schema<unknown>;
|
|
316
|
+
try {
|
|
317
|
+
resolved = plugins.asSchema(schema as plugins.FlexibleSchema<unknown>);
|
|
318
|
+
json = await resolved.jsonSchema;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw new FlexHarnessValidationError(`${label} cannot be converted to JSON Schema.`, { cause: error });
|
|
321
|
+
}
|
|
322
|
+
if (resolved.validate) {
|
|
323
|
+
const validator = resolved.validate;
|
|
324
|
+
validate = (value) => validator(value);
|
|
325
|
+
}
|
|
326
|
+
} else if (isPlainObject(schema) || typeof schema === 'boolean') {
|
|
327
|
+
json = schema;
|
|
328
|
+
} else {
|
|
329
|
+
throw new FlexHarnessValidationError(
|
|
330
|
+
`${label} must be a JSON Schema object, or a zod, Standard Schema or AI SDK schema.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
let copy: unknown;
|
|
334
|
+
try {
|
|
335
|
+
copy = structuredClone(json);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw new FlexHarnessValidationError(`${label} must be plain JSON.`, { cause: error });
|
|
338
|
+
}
|
|
339
|
+
if (!isSchemaRecord(copy) && typeof copy !== 'boolean') {
|
|
340
|
+
throw new FlexHarnessValidationError(`${label} must be a JSON Schema object.`);
|
|
341
|
+
}
|
|
342
|
+
validateUtf8String(JSON.stringify(copy), label, limits.maxSchemaBytes, true);
|
|
343
|
+
return { json: deepFreeze(copy), ...(validate ? { validate } : {}) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
class CatalogSearchIndex {
|
|
347
|
+
private readonly postings = new Map<string, Array<{ index: number; weight: number }>>();
|
|
348
|
+
private readonly idf = new Map<string, number>();
|
|
349
|
+
|
|
350
|
+
constructor(private readonly entries: readonly IMethodEntry[]) {
|
|
351
|
+
const fieldTexts = (entry: IMethodEntry): Record<TSearchField, string[]> => ({
|
|
352
|
+
name: searchTerms(entry.name),
|
|
353
|
+
namespace: searchTerms(entry.namespace),
|
|
354
|
+
summary: searchTerms(entry.summary),
|
|
355
|
+
tags: entry.tags.flatMap(searchTerms),
|
|
356
|
+
description: searchTerms(entry.description ?? ''),
|
|
357
|
+
});
|
|
358
|
+
const documents = entries.map(fieldTexts);
|
|
359
|
+
const fields = Object.keys(fieldWeights) as TSearchField[];
|
|
360
|
+
const averageLength = Object.fromEntries(fields.map((field) => [
|
|
361
|
+
field,
|
|
362
|
+
Math.max(1, documents.reduce((sum, document) => sum + document[field].length, 0) / Math.max(1, documents.length)),
|
|
363
|
+
])) as Record<TSearchField, number>;
|
|
364
|
+
const documentFrequency = new Map<string, number>();
|
|
365
|
+
documents.forEach((document, index) => {
|
|
366
|
+
const weights = new Map<string, number>();
|
|
367
|
+
for (const field of fields) {
|
|
368
|
+
const frequencies = new Map<string, number>();
|
|
369
|
+
for (const term of document[field]) frequencies.set(term, (frequencies.get(term) ?? 0) + 1);
|
|
370
|
+
const norm = 1 - bm25B + bm25B * (document[field].length / averageLength[field]);
|
|
371
|
+
for (const [term, frequency] of frequencies) {
|
|
372
|
+
weights.set(term, (weights.get(term) ?? 0) + fieldWeights[field] * frequency / norm);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const [term, weight] of weights) {
|
|
376
|
+
documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
|
|
377
|
+
const postings = this.postings.get(term) ?? [];
|
|
378
|
+
postings.push({ index, weight });
|
|
379
|
+
this.postings.set(term, postings);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
for (const [term, frequency] of documentFrequency) {
|
|
383
|
+
this.idf.set(term, Math.log(1 + (entries.length - frequency + 0.5) / (frequency + 0.5)));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Methods ranked by BM25 over name, namespace, summary, tags and description. */
|
|
388
|
+
public search(query: string, accept: (entry: IMethodEntry) => boolean): IMethodEntry[] {
|
|
389
|
+
const allTerms = [...new Set(searchTerms(query))];
|
|
390
|
+
const meaningful = allTerms.filter((term) => !stopWords.has(term));
|
|
391
|
+
const terms = meaningful.length > 0 ? meaningful : allTerms;
|
|
392
|
+
const scores = new Map<number, number>();
|
|
393
|
+
for (const term of terms) {
|
|
394
|
+
const idf = this.idf.get(term);
|
|
395
|
+
if (idf === undefined) continue;
|
|
396
|
+
for (const { index, weight } of this.postings.get(term)!) {
|
|
397
|
+
scores.set(index, (scores.get(index) ?? 0) + idf * weight * (bm25K1 + 1) / (weight + bm25K1));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const exact = query.trim().toLowerCase();
|
|
401
|
+
return [...scores.entries()]
|
|
402
|
+
.map(([index, score]) => {
|
|
403
|
+
const entry = this.entries[index];
|
|
404
|
+
const bonus = entry.path.toLowerCase() === exact || entry.name.toLowerCase() === exact ? 1000 : 0;
|
|
405
|
+
return { entry, score: score + bonus };
|
|
406
|
+
})
|
|
407
|
+
.filter(({ entry }) => accept(entry))
|
|
408
|
+
.sort((left, right) => right.score - left.score || compareCodePoints(left.entry.path, right.entry.path))
|
|
409
|
+
.map(({ entry }) => entry);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
class FlexToolCatalogCore {
|
|
414
|
+
public readonly renderedMethods = new Map<string, IRenderedDeclaration>();
|
|
415
|
+
public readonly renderedTypes = new Map<string, IRenderedDeclaration>();
|
|
416
|
+
public readonly validators = new Map<string, plugins.JsonSchemaValidator>();
|
|
417
|
+
public readonly selfContainedInputs = new Map<string, TJsonSchemaNode>();
|
|
418
|
+
public readonly index: CatalogSearchIndex;
|
|
419
|
+
|
|
420
|
+
constructor(
|
|
421
|
+
public readonly mode: TDeclarationMode,
|
|
422
|
+
public readonly methods: ReadonlyMap<string, IMethodEntry>,
|
|
423
|
+
public readonly types: ReadonlyMap<string, ITypeEntry>,
|
|
424
|
+
) {
|
|
425
|
+
this.index = new CatalogSearchIndex([...methods.values()]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
public static async build(catalog: unknown, mode: TDeclarationMode): Promise<FlexToolCatalogCore> {
|
|
429
|
+
if (!isPlainObject(catalog)) throw new FlexHarnessValidationError('A tool catalog must be a plain object.');
|
|
430
|
+
const unsupported = Object.keys(catalog).find((key) => key !== 'methods' && key !== 'types');
|
|
431
|
+
if (unsupported) throw new FlexHarnessValidationError(`A tool catalog does not support "${unsupported}".`);
|
|
432
|
+
if (!Array.isArray(catalog.methods) || catalog.methods.length > limits.maxMethods) {
|
|
433
|
+
throw new FlexHarnessValidationError(`Tool catalog methods must be an array with at most ${limits.maxMethods} entries.`);
|
|
434
|
+
}
|
|
435
|
+
if (catalog.types !== undefined && (!Array.isArray(catalog.types) || catalog.types.length > limits.maxTypes)) {
|
|
436
|
+
throw new FlexHarnessValidationError(`Tool catalog types must be an array with at most ${limits.maxTypes} entries.`);
|
|
437
|
+
}
|
|
438
|
+
const catalogTypes = new Map<string, ITypeEntry>();
|
|
439
|
+
for (const [index, type] of ((catalog.types ?? []) as unknown[]).entries()) {
|
|
440
|
+
const entry = await FlexToolCatalogCore.normalizeType(type, `Tool catalog type ${index}`);
|
|
441
|
+
if (catalogTypes.has(entry.name)) {
|
|
442
|
+
throw new FlexHarnessValidationError(`Tool catalog type "${entry.name}" is defined twice.`);
|
|
443
|
+
}
|
|
444
|
+
catalogTypes.set(entry.name, entry);
|
|
445
|
+
}
|
|
446
|
+
const methodInputs: Array<Omit<IMethodEntry, 'inputScope' | 'outputScope'>> = [];
|
|
447
|
+
for (const [index, method] of (catalog.methods as unknown[]).entries()) {
|
|
448
|
+
methodInputs.push(await FlexToolCatalogCore.normalizeMethod(method, `Tool catalog method ${index}`));
|
|
449
|
+
}
|
|
450
|
+
methodInputs.sort((left, right) => compareCodePoints(left.path, right.path));
|
|
451
|
+
const paths = new Set<string>();
|
|
452
|
+
for (const method of methodInputs) {
|
|
453
|
+
if (paths.has(method.path)) throw new FlexHarnessValidationError(`Tool catalog method "${method.path}" is defined twice.`);
|
|
454
|
+
paths.add(method.path);
|
|
455
|
+
}
|
|
456
|
+
for (const method of methodInputs) {
|
|
457
|
+
for (let end = method.path.indexOf('.'); end !== -1; end = method.path.indexOf('.', end + 1)) {
|
|
458
|
+
const namespace = method.path.slice(0, end);
|
|
459
|
+
if (paths.has(namespace)) {
|
|
460
|
+
throw new FlexHarnessValidationError(`Tool catalog path "${namespace}" is both a method and a namespace.`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const registry = new Map<string, ITypeEntry>(catalogTypes);
|
|
466
|
+
const canonicalDefinitions = new Map<string, string>();
|
|
467
|
+
const scopeOf = (root: TJsonSchemaNode, label: string, requireSchemas: boolean): ISchemaScope => {
|
|
468
|
+
const scope: ISchemaScope = { root, localNames: new Map() };
|
|
469
|
+
FlexToolCatalogCore.checkRefs(root, label, catalogTypes, requireSchemas);
|
|
470
|
+
if (!isSchemaRecord(root)) return scope;
|
|
471
|
+
for (const container of ['$defs', 'definitions']) {
|
|
472
|
+
const definitions = root[container];
|
|
473
|
+
if (!isSchemaRecord(definitions)) continue;
|
|
474
|
+
for (const key of Object.keys(definitions).sort(compareCodePoints)) {
|
|
475
|
+
if (!typeNamePattern.test(key) || reservedWords.has(key) || key.length > limits.maxIdentifierCharacters) continue;
|
|
476
|
+
const definition = definitions[key] as TJsonSchemaNode;
|
|
477
|
+
const canonical = stableStringify(definition);
|
|
478
|
+
let name = key;
|
|
479
|
+
for (let suffix = 2; ; suffix++) {
|
|
480
|
+
const existing = registry.get(name);
|
|
481
|
+
if (!existing) {
|
|
482
|
+
registry.set(name, { name, definition, scope });
|
|
483
|
+
canonicalDefinitions.set(name, canonical);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
if (existing.definition === undefined || canonicalDefinitions.get(name) === canonical) break;
|
|
487
|
+
name = `${key}_${suffix}`;
|
|
488
|
+
}
|
|
489
|
+
scope.localNames.set(`${container}/${key}`, name);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return scope;
|
|
493
|
+
};
|
|
494
|
+
for (const type of catalogTypes.values()) {
|
|
495
|
+
if (!type.schema) continue;
|
|
496
|
+
registry.set(type.name, { ...type, scope: scopeOf(type.schema.json, `Tool catalog type "${type.name}"`, true) });
|
|
497
|
+
}
|
|
498
|
+
const methods = new Map<string, IMethodEntry>();
|
|
499
|
+
for (const method of methodInputs) {
|
|
500
|
+
methods.set(method.path, {
|
|
501
|
+
...method,
|
|
502
|
+
inputScope: scopeOf(method.input.json, `Tool catalog method "${method.path}" inputSchema`, true),
|
|
503
|
+
...(method.output
|
|
504
|
+
? { outputScope: scopeOf(method.output.json, `Tool catalog method "${method.path}" outputSchema`, false) }
|
|
505
|
+
: {}),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
const types = new Map([...registry.entries()].sort(([left], [right]) => compareCodePoints(left, right)));
|
|
509
|
+
return new FlexToolCatalogCore(mode, methods, types);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private static async normalizeType(value: unknown, label: string): Promise<ITypeEntry> {
|
|
513
|
+
if (!isPlainObject(value)) throw new FlexHarnessValidationError(`${label} must be a plain object.`);
|
|
514
|
+
const unsupported = Object.keys(value).find((key) => !typeKeys.has(key));
|
|
515
|
+
if (unsupported) throw new FlexHarnessValidationError(`${label} does not support "${unsupported}".`);
|
|
516
|
+
const name = textOf(value.name, `${label} name`, { maxCharacters: limits.maxIdentifierCharacters, singleLine: true });
|
|
517
|
+
if (!typeNamePattern.test(name) || reservedWords.has(name)) {
|
|
518
|
+
throw new FlexHarnessValidationError(`${label} name must be an identifier that is not a reserved word.`);
|
|
519
|
+
}
|
|
520
|
+
if (value.schema === undefined && value.declaration === undefined) {
|
|
521
|
+
throw new FlexHarnessValidationError(`Tool catalog type "${name}" needs a schema or a declaration.`);
|
|
522
|
+
}
|
|
523
|
+
const description = value.description === undefined
|
|
524
|
+
? undefined
|
|
525
|
+
: textOf(value.description, `Tool catalog type "${name}" description`, { maxBytes: limits.maxDescriptionBytes });
|
|
526
|
+
const declaration = value.declaration === undefined
|
|
527
|
+
? undefined
|
|
528
|
+
: textOf(value.declaration, `Tool catalog type "${name}" declaration`, { maxBytes: limits.maxDeclarationBytes });
|
|
529
|
+
if (declaration !== undefined && !new RegExp(`\\b(?:interface|type|enum|class)\\s+${name}\\b`).test(declaration)) {
|
|
530
|
+
throw new FlexHarnessValidationError(`Tool catalog type "${name}" declaration must declare "${name}".`);
|
|
531
|
+
}
|
|
532
|
+
const schema = value.schema === undefined
|
|
533
|
+
? undefined
|
|
534
|
+
: await resolveCatalogSchema(value.schema, `Tool catalog type "${name}" schema`);
|
|
535
|
+
if (schema && isSchemaRecord(schema.json) && (schema.json.$defs !== undefined || schema.json.definitions !== undefined)) {
|
|
536
|
+
throw new FlexHarnessValidationError(
|
|
537
|
+
`Tool catalog type "${name}" schema must refer to other catalog types instead of defining $defs.`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
name,
|
|
542
|
+
...(description === undefined ? {} : { description }),
|
|
543
|
+
...(declaration === undefined ? {} : { declaration }),
|
|
544
|
+
...(schema === undefined ? {} : { schema }),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private static async normalizeMethod(
|
|
549
|
+
value: unknown,
|
|
550
|
+
label: string,
|
|
551
|
+
): Promise<Omit<IMethodEntry, 'inputScope' | 'outputScope'>> {
|
|
552
|
+
if (!isPlainObject(value)) throw new FlexHarnessValidationError(`${label} must be a plain object.`);
|
|
553
|
+
const unsupported = Object.keys(value).find((key) => !methodKeys.has(key));
|
|
554
|
+
if (unsupported) throw new FlexHarnessValidationError(`${label} does not support "${unsupported}".`);
|
|
555
|
+
const path = textOf(value.method, `${label} method`, { maxCharacters: limits.maxMethodPathCharacters, singleLine: true });
|
|
556
|
+
const segments = path.split('.');
|
|
557
|
+
if (
|
|
558
|
+
!methodPathPattern.test(path)
|
|
559
|
+
|| segments.length > limits.maxMethodPathSegments
|
|
560
|
+
|| segments.some((segment) => segment.length > limits.maxIdentifierCharacters)
|
|
561
|
+
) {
|
|
562
|
+
throw new FlexHarnessValidationError(
|
|
563
|
+
`${label} method "${path}" must be a dotted path of at most ${limits.maxMethodPathSegments} identifiers.`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const reserved = segments.find((segment) => reservedWords.has(segment));
|
|
567
|
+
if (reserved) {
|
|
568
|
+
throw new FlexHarnessValidationError(
|
|
569
|
+
`${label} method "${path}" uses the reserved word "${reserved}", which a declaration cannot name.`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const methodLabel = `Tool catalog method "${path}"`;
|
|
573
|
+
const summary = textOf(value.summary, `${methodLabel} summary`, {
|
|
574
|
+
maxCharacters: limits.maxSummaryCharacters, singleLine: true,
|
|
575
|
+
}).trim();
|
|
576
|
+
const description = value.description === undefined
|
|
577
|
+
? undefined
|
|
578
|
+
: textOf(value.description, `${methodLabel} description`, { maxBytes: limits.maxDescriptionBytes }).trim();
|
|
579
|
+
const tags = textListOf(value.tags, `${methodLabel} tags`, limits.maxTags, {
|
|
580
|
+
maxCharacters: limits.maxTagCharacters, singleLine: true,
|
|
581
|
+
});
|
|
582
|
+
const examples = textListOf(value.examples, `${methodLabel} examples`, limits.maxExamples, {
|
|
583
|
+
maxBytes: limits.maxExampleBytes,
|
|
584
|
+
});
|
|
585
|
+
const name = segments[segments.length - 1];
|
|
586
|
+
const declaration = value.declaration === undefined
|
|
587
|
+
? undefined
|
|
588
|
+
: textOf(value.declaration, `${methodLabel} declaration`, { maxBytes: limits.maxDeclarationBytes });
|
|
589
|
+
if (declaration !== undefined && !new RegExp(`\\bfunction\\s+${name}\\s*[<(]`).test(declaration)) {
|
|
590
|
+
throw new FlexHarnessValidationError(`${methodLabel} declaration must declare "function ${name}(".`);
|
|
591
|
+
}
|
|
592
|
+
if (typeof value.run !== 'function') throw new FlexHarnessValidationError(`${methodLabel} run must be a function.`);
|
|
593
|
+
if (value.inputSchema === undefined) throw new FlexHarnessValidationError(`${methodLabel} needs an inputSchema.`);
|
|
594
|
+
const input = await resolveCatalogSchema(value.inputSchema, `${methodLabel} inputSchema`);
|
|
595
|
+
const inputJson = input.json;
|
|
596
|
+
const describesObject = isSchemaRecord(inputJson) && (
|
|
597
|
+
inputJson.type === 'object'
|
|
598
|
+
|| (Array.isArray(inputJson.type) && inputJson.type.includes('object'))
|
|
599
|
+
|| (inputJson.type === undefined && (inputJson.properties !== undefined || inputJson.$ref !== undefined
|
|
600
|
+
|| inputJson.anyOf !== undefined || inputJson.oneOf !== undefined || inputJson.allOf !== undefined))
|
|
601
|
+
);
|
|
602
|
+
if (!describesObject) throw new FlexHarnessValidationError(`${methodLabel} inputSchema must describe an object.`);
|
|
603
|
+
const output = value.outputSchema === undefined
|
|
604
|
+
? undefined
|
|
605
|
+
: await resolveCatalogSchema(value.outputSchema, `${methodLabel} outputSchema`);
|
|
606
|
+
const run = value.run as IFlexCatalogMethod['run'];
|
|
607
|
+
return {
|
|
608
|
+
path,
|
|
609
|
+
namespace: segments.slice(0, -1).join('.'),
|
|
610
|
+
name,
|
|
611
|
+
summary,
|
|
612
|
+
...(description ? { description } : {}),
|
|
613
|
+
tags: Object.freeze(tags),
|
|
614
|
+
examples: Object.freeze(examples),
|
|
615
|
+
input,
|
|
616
|
+
...(output ? { output } : {}),
|
|
617
|
+
...(declaration === undefined ? {} : { declaration }),
|
|
618
|
+
run: (params, context) => run.call(value, params, context),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Every `$ref` must be local and resolve here or, as a definition reference, to a catalog type. */
|
|
623
|
+
private static checkRefs(
|
|
624
|
+
root: TJsonSchemaNode,
|
|
625
|
+
label: string,
|
|
626
|
+
catalogTypes: ReadonlyMap<string, ITypeEntry>,
|
|
627
|
+
requireSchemas: boolean,
|
|
628
|
+
): void {
|
|
629
|
+
const refs = new Set<string>();
|
|
630
|
+
collectRefs(root, refs);
|
|
631
|
+
const pending = [...refs];
|
|
632
|
+
const visited = new Set<string>();
|
|
633
|
+
while (pending.length > 0) {
|
|
634
|
+
const ref = pending.pop()!;
|
|
635
|
+
if (visited.has(ref)) continue;
|
|
636
|
+
visited.add(ref);
|
|
637
|
+
if (!ref.startsWith('#')) throw new FlexHarnessValidationError(`${label} has the non-local $ref "${ref}".`);
|
|
638
|
+
if (resolvePointer(root, ref) !== undefined) continue;
|
|
639
|
+
const match = definitionRefPattern.exec(ref);
|
|
640
|
+
const type = match ? catalogTypes.get(decodePointerToken(match[2])) : undefined;
|
|
641
|
+
if (!type) throw new FlexHarnessValidationError(`${label} has the unresolved $ref "${ref}".`);
|
|
642
|
+
if (!requireSchemas) continue;
|
|
643
|
+
if (!type.schema) {
|
|
644
|
+
throw new FlexHarnessValidationError(
|
|
645
|
+
`${label} refers to type "${type.name}", which has no schema to validate with.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const nested = new Set<string>();
|
|
649
|
+
collectRefs(type.schema.json, nested);
|
|
650
|
+
pending.push(...nested);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
public scopeContext(scope: ISchemaScope) {
|
|
655
|
+
return {
|
|
656
|
+
namedType: (ref: string): string | undefined => {
|
|
657
|
+
const match = definitionRefPattern.exec(ref);
|
|
658
|
+
if (!match) return undefined;
|
|
659
|
+
const key = decodePointerToken(match[2]);
|
|
660
|
+
const local = scope.localNames.get(`${match[1]}/${key}`);
|
|
661
|
+
if (local) return local;
|
|
662
|
+
if (resolvePointer(scope.root, ref) !== undefined) return undefined;
|
|
663
|
+
return this.types.has(key) ? key : undefined;
|
|
664
|
+
},
|
|
665
|
+
resolve: (ref: string): TJsonSchemaNode | undefined => {
|
|
666
|
+
const target = resolvePointer(scope.root, ref);
|
|
667
|
+
return isSchemaRecord(target) || typeof target === 'boolean' ? target : undefined;
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
public renderType(name: string): IRenderedDeclaration {
|
|
673
|
+
const cached = this.renderedTypes.get(name);
|
|
674
|
+
if (cached) return cached;
|
|
675
|
+
const type = this.types.get(name)!;
|
|
676
|
+
let rendered: IRenderedDeclaration;
|
|
677
|
+
if (type.declaration !== undefined) {
|
|
678
|
+
rendered = { text: type.declaration.trim(), types: this.scanTypes(type.declaration, name) };
|
|
679
|
+
} else {
|
|
680
|
+
const schema = type.definition ?? type.schema!.json;
|
|
681
|
+
const writer = new SchemaDeclarationWriter(this.scopeContext(type.scope!));
|
|
682
|
+
const text = writer.namedDeclaration(name, schema, type.description);
|
|
683
|
+
rendered = { text, types: writer.usedTypes.filter((used) => used !== name) };
|
|
684
|
+
}
|
|
685
|
+
this.renderedTypes.set(name, rendered);
|
|
686
|
+
return rendered;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
public renderMethod(entry: IMethodEntry): IRenderedDeclaration {
|
|
690
|
+
const cached = this.renderedMethods.get(entry.path);
|
|
691
|
+
if (cached) return cached;
|
|
692
|
+
let rendered: IRenderedDeclaration;
|
|
693
|
+
if (entry.declaration !== undefined) {
|
|
694
|
+
rendered = { text: entry.declaration.trim(), types: this.scanTypes(entry.declaration) };
|
|
695
|
+
} else {
|
|
696
|
+
const inputWriter = new SchemaDeclarationWriter(this.scopeContext(entry.inputScope));
|
|
697
|
+
const members = inputWriter.objectMembers(entry.input.json);
|
|
698
|
+
const paramsType = inputWriter.type(entry.input.json, '', 0, members !== undefined);
|
|
699
|
+
const optional = inputWriter.hasNoRequiredProperties(entry.input.json) ? '?' : '';
|
|
700
|
+
const outputWriter = entry.output && entry.outputScope
|
|
701
|
+
? new SchemaDeclarationWriter(this.scopeContext(entry.outputScope))
|
|
702
|
+
: undefined;
|
|
703
|
+
const resultType = outputWriter ? outputWriter.type(entry.output!.json) : 'unknown';
|
|
704
|
+
const lines = [entry.summary];
|
|
705
|
+
if (entry.description && entry.description !== entry.summary) lines.push('', ...entry.description.split('\n'));
|
|
706
|
+
for (const member of members ?? []) {
|
|
707
|
+
if (member.doc === undefined) continue;
|
|
708
|
+
const key = isIdentifier(member.name) ? `.${member.name}` : `[${JSON.stringify(member.name)}]`;
|
|
709
|
+
lines.push(`@param params${key} ${member.doc.replace(/\s*\n\s*/g, ' ')}`);
|
|
710
|
+
}
|
|
711
|
+
for (const example of entry.examples) {
|
|
712
|
+
lines.push(...(example.includes('\n') ? ['@example', ...example.split('\n')] : [`@example ${example}`]));
|
|
713
|
+
}
|
|
714
|
+
const signature = `function ${entry.name}(params${optional}: ${paramsType}): Promise<${resultType}>;`;
|
|
715
|
+
rendered = {
|
|
716
|
+
text: `${docBlock(lines.join('\n'), '')}${signature}`,
|
|
717
|
+
types: [...new Set([...inputWriter.usedTypes, ...(outputWriter?.usedTypes ?? [])])].sort(compareCodePoints),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
this.renderedMethods.set(entry.path, rendered);
|
|
721
|
+
return rendered;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private scanTypes(text: string, self?: string): string[] {
|
|
725
|
+
const found = new Set<string>();
|
|
726
|
+
for (const match of text.matchAll(identifierScanPattern)) {
|
|
727
|
+
if (match[0] !== self && this.types.has(match[0])) found.add(match[0]);
|
|
728
|
+
}
|
|
729
|
+
return [...found].sort(compareCodePoints);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** `names` and every type they use, transitively, sorted. */
|
|
733
|
+
public typeClosure(names: Iterable<string>, stop: ReadonlySet<string> = new Set()): string[] {
|
|
734
|
+
const result = new Set<string>();
|
|
735
|
+
const pending = [...names];
|
|
736
|
+
while (pending.length > 0) {
|
|
737
|
+
const name = pending.pop()!;
|
|
738
|
+
if (result.has(name) || stop.has(name) || !this.types.has(name)) continue;
|
|
739
|
+
result.add(name);
|
|
740
|
+
pending.push(...this.renderType(name).types);
|
|
741
|
+
}
|
|
742
|
+
return [...result].sort(compareCodePoints);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** The method's input schema with the catalog types it refers to added as definitions. */
|
|
746
|
+
public selfContainedInput(entry: IMethodEntry): TJsonSchemaNode {
|
|
747
|
+
const cached = this.selfContainedInputs.get(entry.path);
|
|
748
|
+
if (cached !== undefined) return cached;
|
|
749
|
+
const root = structuredClone(entry.input.json) as TJsonSchemaNode;
|
|
750
|
+
if (isSchemaRecord(root)) {
|
|
751
|
+
const pending = new Set<string>();
|
|
752
|
+
collectRefs(root, pending);
|
|
753
|
+
const queue = [...pending];
|
|
754
|
+
while (queue.length > 0) {
|
|
755
|
+
const ref = queue.pop()!;
|
|
756
|
+
const match = definitionRefPattern.exec(ref);
|
|
757
|
+
if (!match || resolvePointer(root, ref) !== undefined) continue;
|
|
758
|
+
const type = this.types.get(decodePointerToken(match[2]));
|
|
759
|
+
if (!type?.schema) continue;
|
|
760
|
+
const container = (isSchemaRecord(root[match[1]]) ? root[match[1]] : (root[match[1]] = {})) as Record<string, unknown>;
|
|
761
|
+
container[decodePointerToken(match[2])] = withoutDraft(structuredClone(type.schema.json) as TJsonSchemaNode);
|
|
762
|
+
const nested = new Set<string>();
|
|
763
|
+
collectRefs(type.schema.json, nested);
|
|
764
|
+
queue.push(...nested);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
deepFreeze(root);
|
|
768
|
+
this.selfContainedInputs.set(entry.path, root);
|
|
769
|
+
return root;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
public validator(entry: IMethodEntry): plugins.JsonSchemaValidator {
|
|
773
|
+
let validator = this.validators.get(entry.path);
|
|
774
|
+
if (!validator) {
|
|
775
|
+
const root = structuredClone(this.selfContainedInput(entry));
|
|
776
|
+
validator = new plugins.JsonSchemaValidator(root as never, schemaDraft(root), false);
|
|
777
|
+
this.validators.set(entry.path, validator);
|
|
778
|
+
}
|
|
779
|
+
return validator;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* A tool catalog prepared for the model: search, TypeScript declarations, parameter validation and
|
|
785
|
+
* dispatch. FlexHarness builds one for every catalog a tool provider returns; it is public so a
|
|
786
|
+
* host can check what its catalog shows the model, and measure it, without running a session.
|
|
787
|
+
*/
|
|
788
|
+
export class FlexToolCatalog<TScope = unknown> {
|
|
789
|
+
private namespaceTree?: Map<string, { methods: IMethodEntry[]; children: Map<string, number> }>;
|
|
790
|
+
|
|
791
|
+
private constructor(
|
|
792
|
+
private readonly core: FlexToolCatalogCore,
|
|
793
|
+
private readonly admitted: ReadonlySet<string> | undefined,
|
|
794
|
+
) {}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Prepares `catalog`. Preparing the same object again returns the cached preparation, so a host
|
|
798
|
+
* that returns one catalog object for every run pays for it once.
|
|
799
|
+
*/
|
|
800
|
+
public static async create<TScope = unknown>(
|
|
801
|
+
catalog: IFlexToolCatalog<TScope>,
|
|
802
|
+
options: IFlexToolCatalogOptions = {},
|
|
803
|
+
): Promise<FlexToolCatalog<TScope>> {
|
|
804
|
+
const mode = options.declarations ?? 'typescript';
|
|
805
|
+
if (!describeFormats.has(mode)) {
|
|
806
|
+
throw new FlexHarnessValidationError('toolCatalog.declarations must be "typescript" or "json-schema".');
|
|
807
|
+
}
|
|
808
|
+
if (typeof catalog !== 'object' || catalog === null) {
|
|
809
|
+
throw new FlexHarnessValidationError('A tool catalog must be a plain object.');
|
|
810
|
+
}
|
|
811
|
+
let byMode = coreCache.get(catalog);
|
|
812
|
+
if (!byMode) {
|
|
813
|
+
byMode = new Map();
|
|
814
|
+
coreCache.set(catalog, byMode);
|
|
815
|
+
}
|
|
816
|
+
let core = byMode.get(mode);
|
|
817
|
+
if (!core) {
|
|
818
|
+
core = FlexToolCatalogCore.build(catalog, mode);
|
|
819
|
+
byMode.set(mode, core);
|
|
820
|
+
}
|
|
821
|
+
return new FlexToolCatalog<TScope>(await core, undefined);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Every method path this catalog offers, sorted. */
|
|
825
|
+
public get methods(): string[] {
|
|
826
|
+
return [...this.core.methods.keys()].filter((path) => this.isAdmitted(path));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Every named type, sorted. */
|
|
830
|
+
public get types(): string[] {
|
|
831
|
+
return [...this.core.types.keys()];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The same catalog offering only `paths`. */
|
|
835
|
+
public restrict(paths: Iterable<string>): FlexToolCatalog<TScope> {
|
|
836
|
+
const allowed = new Set(paths);
|
|
837
|
+
return new FlexToolCatalog<TScope>(
|
|
838
|
+
this.core,
|
|
839
|
+
new Set([...this.core.methods.keys()].filter((path) => allowed.has(path) && this.isAdmitted(path))),
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
public has(path: string): boolean {
|
|
844
|
+
return this.core.methods.has(path) && this.isAdmitted(path);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** The three built-in tools as the model sees them. Their text depends only on the catalog. */
|
|
848
|
+
public toolDefinitions(): Record<string, IFlexCatalogToolDefinition> {
|
|
849
|
+
const typescript = this.core.mode === 'typescript';
|
|
850
|
+
const methodPath = { type: 'string', maxLength: limits.maxMethodPathCharacters } as const;
|
|
851
|
+
return {
|
|
852
|
+
[toolNames.search]: {
|
|
853
|
+
description: [
|
|
854
|
+
'Search the API catalog. Its methods are called with execute, not as tools. `query` ranks methods by',
|
|
855
|
+
'name, summary and description; `namespace` (a dotted path) narrows the search or, without `query`, lists',
|
|
856
|
+
`the namespace like ls. Read a method's ${typescript ? 'TypeScript declaration' : 'description and JSON Schema'}`,
|
|
857
|
+
'with describe_tools before calling it.',
|
|
858
|
+
this.topLevelLine(),
|
|
859
|
+
].join(' '),
|
|
860
|
+
inputSchema: {
|
|
861
|
+
type: 'object',
|
|
862
|
+
properties: {
|
|
863
|
+
query: { type: 'string', maxLength: limits.maxQueryCharacters },
|
|
864
|
+
namespace: methodPath,
|
|
865
|
+
limit: { type: 'integer', minimum: 1, maximum: limits.maxSearchResults },
|
|
866
|
+
offset: { type: 'integer', minimum: 0 },
|
|
867
|
+
},
|
|
868
|
+
additionalProperties: false,
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
[toolNames.describe]: {
|
|
872
|
+
description: typescript
|
|
873
|
+
? 'Return the TypeScript declarations of catalog methods (`methods`: dotted paths) and named types (`types`). '
|
|
874
|
+
+ 'A declaration comes with the named types it uses that this conversation has not declared yet.'
|
|
875
|
+
: 'Return the description and JSON Schema of catalog methods (`methods`: dotted paths) and named types (`types`).',
|
|
876
|
+
inputSchema: {
|
|
877
|
+
type: 'object',
|
|
878
|
+
properties: {
|
|
879
|
+
methods: { type: 'array', items: methodPath, maxItems: limits.maxDescribeMethods },
|
|
880
|
+
types: {
|
|
881
|
+
type: 'array',
|
|
882
|
+
items: { type: 'string', maxLength: limits.maxIdentifierCharacters },
|
|
883
|
+
maxItems: limits.maxDescribeTypes,
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
additionalProperties: false,
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
[toolNames.execute]: {
|
|
890
|
+
description: 'Call a catalog method: `method` is its dotted path, `params` the parameter object of its '
|
|
891
|
+
+ 'declaration. Invalid params return an error naming each path, what was expected and what was given.',
|
|
892
|
+
inputSchema: {
|
|
893
|
+
type: 'object',
|
|
894
|
+
properties: {
|
|
895
|
+
method: { ...methodPath, minLength: 1 },
|
|
896
|
+
params: { type: 'object' },
|
|
897
|
+
},
|
|
898
|
+
required: ['method'],
|
|
899
|
+
additionalProperties: false,
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Search results or a namespace listing as text. */
|
|
906
|
+
public search(input: IFlexCatalogSearchInput = {}): string {
|
|
907
|
+
const limit = input.limit ?? limits.defaultSearchResults;
|
|
908
|
+
const offset = input.offset ?? 0;
|
|
909
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > limits.maxSearchResults) {
|
|
910
|
+
throw new FlexHarnessValidationError(`limit must be an integer from 1 through ${limits.maxSearchResults}.`);
|
|
911
|
+
}
|
|
912
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new FlexHarnessValidationError('offset must be a non-negative integer.');
|
|
913
|
+
const namespace = (input.namespace ?? '').trim().replace(/\.\*?$/, '');
|
|
914
|
+
const tree = this.tree();
|
|
915
|
+
if (namespace && !tree.has(namespace)) {
|
|
916
|
+
if (this.has(namespace)) {
|
|
917
|
+
return `${namespace} is a method, not a namespace; read it with describe_tools.`;
|
|
918
|
+
}
|
|
919
|
+
const suggestions = closeMatches(namespace, [...tree.keys()].filter(Boolean));
|
|
920
|
+
return [
|
|
921
|
+
`There is no namespace ${namespace}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ''}`,
|
|
922
|
+
this.topLevelLine(),
|
|
923
|
+
].join('\n');
|
|
924
|
+
}
|
|
925
|
+
const query = (input.query ?? '').trim();
|
|
926
|
+
if (query) {
|
|
927
|
+
const prefix = namespace ? `${namespace}.` : '';
|
|
928
|
+
const matches = this.core.index.search(query, (entry) => this.isAdmitted(entry.path) && entry.path.startsWith(prefix));
|
|
929
|
+
if (matches.length === 0) {
|
|
930
|
+
return [`No methods match "${query}"${namespace ? ` in ${namespace}` : ''}.`, this.topLevelLine()].join('\n');
|
|
931
|
+
}
|
|
932
|
+
return this.page(matches.map((entry) => `${entry.path} - ${entry.summary}`), offset, limit, 'matches');
|
|
933
|
+
}
|
|
934
|
+
const node = tree.get(namespace)!;
|
|
935
|
+
const lines = [...node.children.entries()].map(([child, count]) => `${child}.* (${count} method${count === 1 ? '' : 's'})`);
|
|
936
|
+
const methodLines = node.methods.map((entry) => `${entry.path} - ${entry.summary}`);
|
|
937
|
+
if (methodLines.length === 0) return lines.join('\n') || 'The catalog has no methods.';
|
|
938
|
+
return [...lines, this.page(methodLines, offset, limit, 'methods')].filter(Boolean).join('\n');
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* The declarations of `input.methods` and `input.types`. Method declarations bring the named
|
|
943
|
+
* types they use, minus `declaredTypes`, which a session already has.
|
|
944
|
+
*/
|
|
945
|
+
public describe(
|
|
946
|
+
input: IFlexCatalogDescribeInput,
|
|
947
|
+
options: { declaredTypes?: Iterable<string>; maxBytes?: number } = {},
|
|
948
|
+
): IFlexCatalogDescribeResult {
|
|
949
|
+
const requestedMethods = [...new Set(input.methods ?? [])];
|
|
950
|
+
const requestedTypes = [...new Set(input.types ?? [])];
|
|
951
|
+
if (requestedMethods.length > limits.maxDescribeMethods || requestedTypes.length > limits.maxDescribeTypes) {
|
|
952
|
+
throw new FlexHarnessValidationError(
|
|
953
|
+
`describe_tools takes at most ${limits.maxDescribeMethods} methods and ${limits.maxDescribeTypes} types.`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
const maxBytes = Math.min(options.maxBytes ?? limits.maxDescribeBytes, limits.maxDescribeBytes);
|
|
957
|
+
const entries = requestedMethods.filter((path) => this.has(path)).sort(compareCodePoints)
|
|
958
|
+
.map((path) => this.core.methods.get(path)!);
|
|
959
|
+
const unknownMethods = requestedMethods.filter((path) => !this.has(path)).sort(compareCodePoints);
|
|
960
|
+
const knownTypes = requestedTypes.filter((name) => this.core.types.has(name)).sort(compareCodePoints);
|
|
961
|
+
const unknownTypes = requestedTypes.filter((name) => !this.core.types.has(name)).sort(compareCodePoints);
|
|
962
|
+
const notes = [
|
|
963
|
+
...unknownMethods.map((path) => {
|
|
964
|
+
const suggestions = closeMatches(path, this.methods);
|
|
965
|
+
return `Unknown method ${path}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ' Find methods with search_tools.'}`;
|
|
966
|
+
}),
|
|
967
|
+
...unknownTypes.map((name) => {
|
|
968
|
+
const suggestions = closeMatches(name, this.core.types.keys());
|
|
969
|
+
return `Unknown type ${name}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ''}`;
|
|
970
|
+
}),
|
|
971
|
+
];
|
|
972
|
+
if (requestedMethods.length === 0 && requestedTypes.length === 0) {
|
|
973
|
+
notes.push('Pass methods (dotted paths) or types to describe.');
|
|
974
|
+
}
|
|
975
|
+
return this.core.mode === 'json-schema'
|
|
976
|
+
? this.describeJson(entries, knownTypes, notes, maxBytes)
|
|
977
|
+
: this.describeTypescript(entries, knownTypes, notes, new Set(options.declaredTypes ?? []), maxBytes);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The named types `describe_tools` results in `messages` declared. */
|
|
981
|
+
public declaredTypesIn(messages: readonly plugins.ModelMessage[] | undefined): Set<string> {
|
|
982
|
+
const declared = new Set<string>();
|
|
983
|
+
for (const message of messages ?? []) {
|
|
984
|
+
if (message.role !== 'tool' || !Array.isArray(message.content)) continue;
|
|
985
|
+
for (const part of message.content) {
|
|
986
|
+
if (part.type !== 'tool-result' || part.toolName !== toolNames.describe) continue;
|
|
987
|
+
const output = part.output as { type?: string; value?: unknown };
|
|
988
|
+
if (output.type !== 'text' || typeof output.value !== 'string' || !output.value.startsWith(declaredTypesHeader)) continue;
|
|
989
|
+
const lineEnd = output.value.indexOf('\n');
|
|
990
|
+
const header = output.value.slice(declaredTypesHeader.length, lineEnd === -1 ? undefined : lineEnd);
|
|
991
|
+
for (const name of header.split(', ')) if (this.core.types.has(name)) declared.add(name);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return declared;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Resolves and validates a call. A failure is the error `execute` reports to the model; success
|
|
999
|
+
* returns the run with the validated parameters. A schema validator that throws rejects.
|
|
1000
|
+
*/
|
|
1001
|
+
public async prepareCall(method: unknown, params: unknown): Promise<IPreparedCall | IFailedCall> {
|
|
1002
|
+
const path = typeof method === 'string' ? method : '';
|
|
1003
|
+
if (!this.has(path)) {
|
|
1004
|
+
const isNamespace = path !== '' && this.tree().has(path);
|
|
1005
|
+
return {
|
|
1006
|
+
success: false,
|
|
1007
|
+
error: {
|
|
1008
|
+
error: 'unknown_method',
|
|
1009
|
+
method: path,
|
|
1010
|
+
didYouMean: isNamespace ? [] : closeMatches(path, this.methods),
|
|
1011
|
+
hint: isNamespace
|
|
1012
|
+
? `${path} is a namespace; list it with search_tools({"namespace":"${path}"}).`
|
|
1013
|
+
: 'Find methods with search_tools.',
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
const entry = this.core.methods.get(path)!;
|
|
1018
|
+
const value = params === undefined ? {} : params;
|
|
1019
|
+
const issues = this.validateStructure(entry, value);
|
|
1020
|
+
let validated: unknown = value;
|
|
1021
|
+
if (issues.length === 0 && entry.input.validate) {
|
|
1022
|
+
// A validator that throws failed itself; only a failure it reports is the model's to repair.
|
|
1023
|
+
const result = await entry.input.validate(value);
|
|
1024
|
+
if (result.success) validated = result.value;
|
|
1025
|
+
else issues.push(...this.foreignIssues(result.error, value));
|
|
1026
|
+
}
|
|
1027
|
+
if (issues.length > 0) {
|
|
1028
|
+
const listed = issues.slice(0, limits.maxParamIssues);
|
|
1029
|
+
return {
|
|
1030
|
+
success: false,
|
|
1031
|
+
error: {
|
|
1032
|
+
error: 'invalid_params',
|
|
1033
|
+
method: path,
|
|
1034
|
+
issues: listed,
|
|
1035
|
+
...(issues.length > listed.length ? { more: issues.length - listed.length } : {}),
|
|
1036
|
+
see: `describe_tools({"methods":["${path}"]})`,
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
return {
|
|
1041
|
+
success: true,
|
|
1042
|
+
method: path,
|
|
1043
|
+
params: validated,
|
|
1044
|
+
run: (context) => entry.run(validated, context),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** The real method and params of an `execute` input, when it names a method of this catalog. */
|
|
1049
|
+
public callTarget(input: unknown): { method: string; params: unknown } | undefined {
|
|
1050
|
+
if (!isSchemaRecord(input) || typeof input.method !== 'string' || !this.has(input.method)) return undefined;
|
|
1051
|
+
return { method: input.method, params: input.params === undefined ? {} : input.params };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
private isAdmitted(path: string): boolean {
|
|
1055
|
+
return this.admitted === undefined || this.admitted.has(path);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private tree(): Map<string, { methods: IMethodEntry[]; children: Map<string, number> }> {
|
|
1059
|
+
if (this.namespaceTree) return this.namespaceTree;
|
|
1060
|
+
const tree = new Map<string, { methods: IMethodEntry[]; children: Map<string, number> }>();
|
|
1061
|
+
const node = (namespace: string) => {
|
|
1062
|
+
let entry = tree.get(namespace);
|
|
1063
|
+
if (!entry) {
|
|
1064
|
+
entry = { methods: [], children: new Map() };
|
|
1065
|
+
tree.set(namespace, entry);
|
|
1066
|
+
}
|
|
1067
|
+
return entry;
|
|
1068
|
+
};
|
|
1069
|
+
node('');
|
|
1070
|
+
for (const entry of this.core.methods.values()) {
|
|
1071
|
+
if (!this.isAdmitted(entry.path)) continue;
|
|
1072
|
+
node(entry.namespace).methods.push(entry);
|
|
1073
|
+
const segments = entry.path.split('.');
|
|
1074
|
+
for (let depth = 1; depth < segments.length; depth++) {
|
|
1075
|
+
const parent = segments.slice(0, depth - 1).join('.');
|
|
1076
|
+
const child = segments.slice(0, depth).join('.');
|
|
1077
|
+
const children = node(parent).children;
|
|
1078
|
+
children.set(child, (children.get(child) ?? 0) + 1);
|
|
1079
|
+
node(child);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
for (const entry of tree.values()) {
|
|
1083
|
+
entry.children = new Map([...entry.children.entries()].sort(([left], [right]) => compareCodePoints(left, right)));
|
|
1084
|
+
}
|
|
1085
|
+
this.namespaceTree = tree;
|
|
1086
|
+
return tree;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
private topLevelLine(): string {
|
|
1090
|
+
const root = this.tree().get('')!;
|
|
1091
|
+
const namespaces = [...root.children.entries()];
|
|
1092
|
+
const shown = namespaces.slice(0, 40).map(([name, count]) => `${name}.* (${count})`);
|
|
1093
|
+
if (namespaces.length > shown.length) shown.push(`${namespaces.length - shown.length} more namespaces`);
|
|
1094
|
+
if (root.methods.length > 0) {
|
|
1095
|
+
shown.push(namespaces.length > 0
|
|
1096
|
+
? `${root.methods.length} top-level method${root.methods.length === 1 ? '' : 's'}`
|
|
1097
|
+
: `${root.methods.length} method${root.methods.length === 1 ? '' : 's'}`);
|
|
1098
|
+
}
|
|
1099
|
+
return `Top level: ${shown.join(', ') || 'no methods'}.`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private page(lines: string[], offset: number, limit: number, noun: string): string {
|
|
1103
|
+
const shown = lines.slice(offset, offset + limit);
|
|
1104
|
+
if (shown.length === 0) return `No more ${noun}: there are ${lines.length}.`;
|
|
1105
|
+
if (offset === 0 && shown.length === lines.length) return shown.join('\n');
|
|
1106
|
+
const end = offset + shown.length;
|
|
1107
|
+
const more = end < lines.length ? `; pass offset ${end} for more` : '';
|
|
1108
|
+
return [...shown, `Showing ${offset + 1}-${end} of ${lines.length} ${noun}${more}.`].join('\n');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
private describeTypescript(
|
|
1112
|
+
entries: IMethodEntry[],
|
|
1113
|
+
requestedTypes: string[],
|
|
1114
|
+
notes: string[],
|
|
1115
|
+
declared: ReadonlySet<string>,
|
|
1116
|
+
maxBytes: number,
|
|
1117
|
+
): IFlexCatalogDescribeResult {
|
|
1118
|
+
const included: IMethodEntry[] = [];
|
|
1119
|
+
const omitted: string[] = [];
|
|
1120
|
+
const alreadyDeclared = new Set([...declared].filter((name) => !requestedTypes.includes(name)));
|
|
1121
|
+
let types = this.core.typeClosure(requestedTypes, alreadyDeclared);
|
|
1122
|
+
const assemble = (methods: IMethodEntry[], typeNames: string[], skipped: string[]): string => {
|
|
1123
|
+
const used = new Set(methods.flatMap((entry) => this.core.typeClosure(this.core.renderMethod(entry).types)));
|
|
1124
|
+
const earlier = [...used].filter((name) => declared.has(name) && !typeNames.includes(name)).sort(compareCodePoints);
|
|
1125
|
+
const blocks: string[] = [];
|
|
1126
|
+
if (typeNames.length) blocks.push(`${declaredTypesHeader}${typeNames.join(', ')}`);
|
|
1127
|
+
for (const name of typeNames) blocks.push(this.core.renderType(name).text);
|
|
1128
|
+
const byNamespace = new Map<string, IMethodEntry[]>();
|
|
1129
|
+
for (const entry of methods) byNamespace.set(entry.namespace, [...(byNamespace.get(entry.namespace) ?? []), entry]);
|
|
1130
|
+
for (const [namespace, members] of byNamespace) {
|
|
1131
|
+
const texts = members.map((entry) => this.core.renderMethod(entry).text);
|
|
1132
|
+
if (namespace) blocks.push(`declare namespace ${namespace} {\n${indentLines(texts.join('\n'), ' ')}\n}`);
|
|
1133
|
+
else blocks.push(...texts.map(withDeclare));
|
|
1134
|
+
}
|
|
1135
|
+
const trailing = [
|
|
1136
|
+
...(earlier.length ? [`Declared earlier: ${earlier.join(', ')}; describe_tools({"types":[...]}) declares them again.`] : []),
|
|
1137
|
+
...notes,
|
|
1138
|
+
...(skipped.length ? [`Not included to stay within the size limit: ${skipped.join(', ')}; describe them in another call.`] : []),
|
|
1139
|
+
];
|
|
1140
|
+
if (trailing.length) blocks.push(trailing.map((note) => `// ${note}`).join('\n'));
|
|
1141
|
+
return blocks.join('\n\n');
|
|
1142
|
+
};
|
|
1143
|
+
for (const entry of entries) {
|
|
1144
|
+
const candidate = [...included, entry];
|
|
1145
|
+
const candidateTypes = this.core.typeClosure(
|
|
1146
|
+
[...requestedTypes, ...candidate.flatMap((method) => this.core.renderMethod(method).types)],
|
|
1147
|
+
alreadyDeclared,
|
|
1148
|
+
);
|
|
1149
|
+
const text = assemble(candidate, candidateTypes, entries.slice(candidate.length).map((method) => method.path));
|
|
1150
|
+
if (Buffer.byteLength(JSON.stringify(text), 'utf8') > maxBytes && included.length > 0) {
|
|
1151
|
+
omitted.push(...entries.slice(included.length).map((method) => method.path));
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
included.push(entry);
|
|
1155
|
+
types = candidateTypes;
|
|
1156
|
+
}
|
|
1157
|
+
const text = assemble(included, types, omitted);
|
|
1158
|
+
return { output: text, declaredTypes: types };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private describeJson(
|
|
1162
|
+
entries: IMethodEntry[],
|
|
1163
|
+
requestedTypes: string[],
|
|
1164
|
+
notes: string[],
|
|
1165
|
+
maxBytes: number,
|
|
1166
|
+
): IFlexCatalogDescribeResult {
|
|
1167
|
+
const methods: TJsonValue[] = [];
|
|
1168
|
+
const omitted: string[] = [];
|
|
1169
|
+
const typeSchemas = requestedTypes.flatMap((name) => {
|
|
1170
|
+
const type = this.core.types.get(name)!;
|
|
1171
|
+
const schema = type.definition ?? type.schema?.json;
|
|
1172
|
+
return [{
|
|
1173
|
+
name,
|
|
1174
|
+
...(type.description ? { description: type.description } : {}),
|
|
1175
|
+
...(schema === undefined ? { declaration: type.declaration! } : { schema: withoutDraft(schema) }),
|
|
1176
|
+
}];
|
|
1177
|
+
});
|
|
1178
|
+
let size = Buffer.byteLength(JSON.stringify({ types: typeSchemas, notes }), 'utf8');
|
|
1179
|
+
for (const entry of entries) {
|
|
1180
|
+
const method = {
|
|
1181
|
+
method: entry.path,
|
|
1182
|
+
summary: entry.summary,
|
|
1183
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
1184
|
+
inputSchema: withoutDraft(this.core.selfContainedInput(entry)),
|
|
1185
|
+
...(entry.output ? { outputSchema: withoutDraft(entry.output.json) } : {}),
|
|
1186
|
+
...(entry.examples.length ? { examples: [...entry.examples] } : {}),
|
|
1187
|
+
} as TJsonValue;
|
|
1188
|
+
const bytes = Buffer.byteLength(JSON.stringify(method), 'utf8') + 1;
|
|
1189
|
+
if (methods.length > 0 && size + bytes > maxBytes) {
|
|
1190
|
+
omitted.push(...entries.slice(methods.length).map((skipped) => skipped.path));
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
size += bytes;
|
|
1194
|
+
methods.push(method);
|
|
1195
|
+
}
|
|
1196
|
+
return {
|
|
1197
|
+
output: JSON.parse(JSON.stringify({
|
|
1198
|
+
methods,
|
|
1199
|
+
...(typeSchemas.length ? { types: typeSchemas } : {}),
|
|
1200
|
+
...(notes.length ? { notes } : {}),
|
|
1201
|
+
...(omitted.length ? { omitted } : {}),
|
|
1202
|
+
})) as TJsonValue,
|
|
1203
|
+
declaredTypes: [],
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Issues the method's JSON Schema finds in `value`. */
|
|
1208
|
+
private validateStructure(entry: IMethodEntry, value: unknown): IFlexCatalogParamIssue[] {
|
|
1209
|
+
const result = this.core.validator(entry).validate(value);
|
|
1210
|
+
if (result.valid) return [];
|
|
1211
|
+
const root = this.core.selfContainedInput(entry);
|
|
1212
|
+
const writer = new SchemaDeclarationWriter({
|
|
1213
|
+
namedType: (ref) => {
|
|
1214
|
+
const match = definitionRefPattern.exec(ref);
|
|
1215
|
+
const key = match ? decodePointerToken(match[2]) : undefined;
|
|
1216
|
+
return key && typeNamePattern.test(key) ? key : undefined;
|
|
1217
|
+
},
|
|
1218
|
+
resolve: (ref) => {
|
|
1219
|
+
const target = resolvePointer(root, ref);
|
|
1220
|
+
return isSchemaRecord(target) || typeof target === 'boolean' ? target : undefined;
|
|
1221
|
+
},
|
|
1222
|
+
});
|
|
1223
|
+
const typeText = (schema: unknown): string =>
|
|
1224
|
+
isSchemaRecord(schema) || typeof schema === 'boolean' ? compactType(writer.type(schema)) : 'a value';
|
|
1225
|
+
const issues: IFlexCatalogParamIssue[] = [];
|
|
1226
|
+
const covered: string[] = [];
|
|
1227
|
+
let container: (typeof result.errors)[number] | undefined;
|
|
1228
|
+
for (const unit of result.errors) {
|
|
1229
|
+
if (covered.some((prefix) => unit.keywordLocation.startsWith(`${prefix}/`))) continue;
|
|
1230
|
+
const tokens = pointerTokens(unit.instanceLocation);
|
|
1231
|
+
const path = paramsPath(tokens);
|
|
1232
|
+
const got = preview(valueAt(value, tokens));
|
|
1233
|
+
const schemaHere = resolvePointer(root, unit.keywordLocation);
|
|
1234
|
+
const parent = resolvePointer(root, unit.keywordLocation.slice(0, unit.keywordLocation.lastIndexOf('/')));
|
|
1235
|
+
switch (unit.keyword) {
|
|
1236
|
+
case 'additionalProperties':
|
|
1237
|
+
case 'unevaluatedProperties':
|
|
1238
|
+
container = unit;
|
|
1239
|
+
continue;
|
|
1240
|
+
case 'properties':
|
|
1241
|
+
case 'patternProperties':
|
|
1242
|
+
case 'items':
|
|
1243
|
+
case 'prefixItems':
|
|
1244
|
+
case 'additionalItems':
|
|
1245
|
+
case 'unevaluatedItems':
|
|
1246
|
+
case 'allOf':
|
|
1247
|
+
case '$ref':
|
|
1248
|
+
case '$recursiveRef':
|
|
1249
|
+
case '$dynamicRef':
|
|
1250
|
+
case 'dependentSchemas':
|
|
1251
|
+
case 'if':
|
|
1252
|
+
case 'then':
|
|
1253
|
+
case 'else':
|
|
1254
|
+
continue;
|
|
1255
|
+
case 'false': {
|
|
1256
|
+
const name = tokens[tokens.length - 1] ?? '';
|
|
1257
|
+
const owner = container && unit.instanceLocation.startsWith(`${container.instanceLocation}/`)
|
|
1258
|
+
? resolvePointer(root, container.keywordLocation.slice(0, container.keywordLocation.lastIndexOf('/')))
|
|
1259
|
+
: undefined;
|
|
1260
|
+
if (isSchemaRecord(owner)) {
|
|
1261
|
+
const declaredProperty = isSchemaRecord(owner.properties)
|
|
1262
|
+
&& Object.prototype.hasOwnProperty.call(owner.properties, name);
|
|
1263
|
+
const matchesPattern = isSchemaRecord(owner.patternProperties)
|
|
1264
|
+
&& Object.keys(owner.patternProperties).some((pattern) => {
|
|
1265
|
+
try {
|
|
1266
|
+
return new RegExp(pattern, 'u').test(name);
|
|
1267
|
+
} catch {
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
if (declaredProperty || matchesPattern) continue;
|
|
1272
|
+
const known = isSchemaRecord(owner.properties) ? Object.keys(owner.properties) : [];
|
|
1273
|
+
issues.push({
|
|
1274
|
+
path,
|
|
1275
|
+
expected: known.length
|
|
1276
|
+
? `no such property; known properties: ${known.slice(0, 20).join(', ')}`
|
|
1277
|
+
: 'no such property',
|
|
1278
|
+
got,
|
|
1279
|
+
});
|
|
1280
|
+
} else {
|
|
1281
|
+
issues.push({ path, expected: 'no value', got });
|
|
1282
|
+
}
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
case 'required': {
|
|
1286
|
+
const required = Array.isArray(schemaHere) ? schemaHere.filter((key): key is string => typeof key === 'string') : [];
|
|
1287
|
+
const instance = valueAt(value, tokens);
|
|
1288
|
+
for (const key of required) {
|
|
1289
|
+
if (isSchemaRecord(instance) && Object.prototype.hasOwnProperty.call(instance, key)) continue;
|
|
1290
|
+
const property = isSchemaRecord(parent) && isSchemaRecord(parent.properties) ? parent.properties[key] : undefined;
|
|
1291
|
+
issues.push({ path: paramsPath([...tokens, key]), expected: typeText(property), got: 'missing' });
|
|
1292
|
+
}
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
case 'anyOf':
|
|
1296
|
+
case 'oneOf':
|
|
1297
|
+
case 'not':
|
|
1298
|
+
covered.push(unit.keywordLocation);
|
|
1299
|
+
issues.push({ path, expected: unit.keyword === 'not' ? `not ${typeText(schemaHere)}` : typeText(parent), got });
|
|
1300
|
+
continue;
|
|
1301
|
+
case 'type':
|
|
1302
|
+
issues.push({
|
|
1303
|
+
path,
|
|
1304
|
+
expected: Array.isArray(schemaHere) ? schemaHere.join(' | ') : String(schemaHere),
|
|
1305
|
+
got,
|
|
1306
|
+
});
|
|
1307
|
+
continue;
|
|
1308
|
+
case 'enum':
|
|
1309
|
+
case 'const':
|
|
1310
|
+
issues.push({ path, expected: typeText(parent), got });
|
|
1311
|
+
continue;
|
|
1312
|
+
case 'minimum':
|
|
1313
|
+
case 'exclusiveMinimum':
|
|
1314
|
+
case 'maximum':
|
|
1315
|
+
case 'exclusiveMaximum':
|
|
1316
|
+
case 'multipleOf':
|
|
1317
|
+
case 'minLength':
|
|
1318
|
+
case 'maxLength':
|
|
1319
|
+
case 'minItems':
|
|
1320
|
+
case 'maxItems':
|
|
1321
|
+
case 'uniqueItems':
|
|
1322
|
+
case 'pattern':
|
|
1323
|
+
case 'format': {
|
|
1324
|
+
const constraint = new SchemaDeclarationWriter({ namedType: () => undefined, resolve: () => undefined });
|
|
1325
|
+
const text = isSchemaRecord(parent) ? compactType(constraint.type(parent)) : 'a value';
|
|
1326
|
+
const subset = isSchemaRecord(parent) ? { type: parent.type, [unit.keyword]: schemaHere } : {};
|
|
1327
|
+
const doc = Object.keys(subset).length ? constraintText(subset) : unit.error;
|
|
1328
|
+
issues.push({ path, expected: doc ? `${text} (${doc})` : text, got });
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
default:
|
|
1332
|
+
issues.push({ path, expected: unit.error, got });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
const seen = new Set<string>();
|
|
1336
|
+
return issues.filter((issue) => {
|
|
1337
|
+
const key = `${issue.path}\u0000${issue.expected}`;
|
|
1338
|
+
if (seen.has(key)) return false;
|
|
1339
|
+
seen.add(key);
|
|
1340
|
+
return true;
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** Issues reported by a zod, Standard Schema or AI SDK validator. */
|
|
1345
|
+
private foreignIssues(error: Error, value: unknown): IFlexCatalogParamIssue[] {
|
|
1346
|
+
const source = error as Error & { issues?: unknown; cause?: unknown };
|
|
1347
|
+
const list = Array.isArray(source.issues)
|
|
1348
|
+
? source.issues
|
|
1349
|
+
: isSchemaRecord(source.cause) && Array.isArray((source.cause as { issues?: unknown }).issues)
|
|
1350
|
+
? (source.cause as { issues: unknown[] }).issues
|
|
1351
|
+
: Array.isArray(source.cause) ? source.cause : undefined;
|
|
1352
|
+
if (!list || list.length === 0) return [{ path: 'params', expected: error.message || 'valid params', got: preview(value) }];
|
|
1353
|
+
return list.map((issue) => {
|
|
1354
|
+
const record = isSchemaRecord(issue) ? issue : {};
|
|
1355
|
+
const tokens = (Array.isArray(record.path) ? record.path : []).map((segment) => {
|
|
1356
|
+
const key = isSchemaRecord(segment) ? segment.key : segment;
|
|
1357
|
+
return typeof key === 'number' ? key : String(key);
|
|
1358
|
+
});
|
|
1359
|
+
const expected = typeof record.expected === 'string'
|
|
1360
|
+
? record.expected
|
|
1361
|
+
: typeof record.message === 'string' ? record.message : 'a valid value';
|
|
1362
|
+
return { path: paramsPath(tokens), expected, got: preview(valueAt(value, tokens)) };
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const constraintText = (subset: Record<string, unknown>): string => {
|
|
1368
|
+
const parts: string[] = [];
|
|
1369
|
+
const value = (key: string) => subset[key];
|
|
1370
|
+
if (typeof value('minimum') === 'number') parts.push(`>= ${value('minimum')}`);
|
|
1371
|
+
if (typeof value('exclusiveMinimum') === 'number') parts.push(`> ${value('exclusiveMinimum')}`);
|
|
1372
|
+
if (typeof value('maximum') === 'number') parts.push(`<= ${value('maximum')}`);
|
|
1373
|
+
if (typeof value('exclusiveMaximum') === 'number') parts.push(`< ${value('exclusiveMaximum')}`);
|
|
1374
|
+
if (typeof value('multipleOf') === 'number') parts.push(`multiple of ${value('multipleOf')}`);
|
|
1375
|
+
if (typeof value('minLength') === 'number') parts.push(`at least ${countOf(value('minLength') as number, 'character')}`);
|
|
1376
|
+
if (typeof value('maxLength') === 'number') parts.push(`at most ${countOf(value('maxLength') as number, 'character')}`);
|
|
1377
|
+
if (typeof value('minItems') === 'number') parts.push(`at least ${countOf(value('minItems') as number, 'item')}`);
|
|
1378
|
+
if (typeof value('maxItems') === 'number') parts.push(`at most ${countOf(value('maxItems') as number, 'item')}`);
|
|
1379
|
+
if (value('uniqueItems') === true) parts.push('unique items');
|
|
1380
|
+
if (typeof value('pattern') === 'string') parts.push(`matching /${value('pattern')}/`);
|
|
1381
|
+
if (typeof value('format') === 'string') parts.push(`format ${value('format')}`);
|
|
1382
|
+
return parts.join(', ');
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* While a run has a catalog, its three tool names are reserved, and a method path must differ from
|
|
1387
|
+
* every tool name.
|
|
1388
|
+
*/
|
|
1389
|
+
export const assertNoCatalogCollisions = (
|
|
1390
|
+
catalog: FlexToolCatalog<unknown>,
|
|
1391
|
+
tools: Readonly<Record<string, unknown>>,
|
|
1392
|
+
): void => {
|
|
1393
|
+
const catalogToolNames = new Set<string>(Object.values(toolNames));
|
|
1394
|
+
for (const name of catalogToolNames) {
|
|
1395
|
+
if (Object.prototype.hasOwnProperty.call(tools, name)) {
|
|
1396
|
+
throw new FlexHarnessValidationError(`Tool provider cannot define enabled built-in tool "${name}".`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
for (const path of catalog.methods) {
|
|
1400
|
+
if (Object.prototype.hasOwnProperty.call(tools, path) || catalogToolNames.has(path) || path === 'invalid') {
|
|
1401
|
+
throw new FlexHarnessValidationError(`Tool catalog method "${path}" has the name of a tool.`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The AI SDK schema of a built-in catalog tool: the definition's JSON Schema, validated with it, so a
|
|
1408
|
+
* malformed call goes back to the model like any other invalid tool input.
|
|
1409
|
+
*/
|
|
1410
|
+
export const catalogToolInputSchema = <TInput>(definition: IFlexCatalogToolDefinition): plugins.Schema<TInput> => {
|
|
1411
|
+
const validator = new plugins.JsonSchemaValidator(structuredClone(definition.inputSchema) as never, '2020-12', false);
|
|
1412
|
+
return plugins.jsonSchema<TInput>(definition.inputSchema, {
|
|
1413
|
+
validate: (value) => {
|
|
1414
|
+
const result = validator.validate(value);
|
|
1415
|
+
if (result.valid) return { success: true, value: value as TInput };
|
|
1416
|
+
const message = result.errors
|
|
1417
|
+
.filter((unit) => !['properties', 'items', 'additionalProperties'].includes(unit.keyword))
|
|
1418
|
+
.map((unit) => `${unit.instanceLocation.replace(/^#/, '') || '/'}: ${unit.error}`)
|
|
1419
|
+
.join(' ');
|
|
1420
|
+
return { success: false, error: new Error(message || 'Invalid input.') };
|
|
1421
|
+
},
|
|
1422
|
+
});
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
/** A root-level method declaration gets `declare`, as a namespace member does not need it. */
|
|
1426
|
+
const withDeclare = (text: string): string => {
|
|
1427
|
+
const lines = text.split('\n');
|
|
1428
|
+
const index = lines.findIndex((line) => /^(?:async\s+)?function\s/.test(line));
|
|
1429
|
+
if (index !== -1) lines[index] = `declare ${lines[index]}`;
|
|
1430
|
+
return lines.join('\n');
|
|
1431
|
+
};
|