nexus-ai-pro 2.19.0 → 2.20.2
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/API_STABILITY.md +49 -1
- package/CHANGELOG.md +69 -1
- package/README.md +6 -3
- package/dist/agent/capabilities.d.ts +3 -0
- package/dist/agent/openapi.d.ts +139 -0
- package/dist/agent/openapi.js +455 -0
- package/dist/evaluate/emulate.d.ts +73 -6
- package/dist/evaluate/emulate.js +114 -12
- package/dist/optimizer/densifier.js +20 -11
- package/dist/protocols/a2a-grpc.js +1 -1
- package/dist/protocols/a2a-proto.d.ts +2 -2
- package/dist/protocols/a2a-proto.js +76 -1
- package/dist/protocols/a2a.d.ts +121 -0
- package/dist/protocols/a2a.js +88 -29
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist-cjs/agent/capabilities.d.ts +3 -0
- package/dist-cjs/agent/openapi.d.ts +139 -0
- package/dist-cjs/agent/openapi.js +461 -0
- package/dist-cjs/evaluate/emulate.d.ts +73 -6
- package/dist-cjs/evaluate/emulate.js +116 -13
- package/dist-cjs/optimizer/densifier.js +20 -11
- package/dist-cjs/protocols/a2a-grpc.js +1 -1
- package/dist-cjs/protocols/a2a-proto.d.ts +2 -2
- package/dist-cjs/protocols/a2a-proto.js +76 -1
- package/dist-cjs/protocols/a2a.d.ts +121 -0
- package/dist-cjs/protocols/a2a.js +88 -29
- package/dist-cjs/version.d.ts +1 -1
- package/dist-cjs/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +14 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { linkSignals } from '../utils/signals.js';
|
|
2
|
+
export class OpenApiError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
constructor(message, code, status) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = 'OpenApiError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
13
|
+
const IGNORED_HEADERS = new Set(['accept', 'content-type', 'authorization']);
|
|
14
|
+
const OPENAPI_ONLY = new Set(['nullable', 'example', 'xml', 'externalDocs', 'discriminator']);
|
|
15
|
+
export function openApiTools(document, options = {}) {
|
|
16
|
+
const doc = parseDocument(document);
|
|
17
|
+
const apiName = options.name ?? (slug(doc.info?.title ?? '') || 'api');
|
|
18
|
+
const baseUrl = baseOf(doc, options);
|
|
19
|
+
const tools = [];
|
|
20
|
+
const operations = [];
|
|
21
|
+
const results = {};
|
|
22
|
+
const skipped = [];
|
|
23
|
+
const used = new Set();
|
|
24
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
25
|
+
const pathItem = deref(doc, item);
|
|
26
|
+
for (const method of METHODS) {
|
|
27
|
+
const raw = pathItem[method];
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
continue;
|
|
30
|
+
const operationId = typeof raw.operationId === 'string' ? raw.operationId : undefined;
|
|
31
|
+
const name = uniqueName([options.prefix, sanitize(operationId ?? `${method}_${path}`) || method].filter(Boolean).join('_'), used);
|
|
32
|
+
const operation = {
|
|
33
|
+
name,
|
|
34
|
+
method: method.toUpperCase(),
|
|
35
|
+
path,
|
|
36
|
+
...(operationId ? { operationId } : {}),
|
|
37
|
+
...(typeof raw.summary === 'string' ? { summary: raw.summary } : {}),
|
|
38
|
+
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
|
|
39
|
+
access: method === 'delete' ? 'destructive' : ['get', 'head', 'options', 'trace'].includes(method) ? 'read' : 'write',
|
|
40
|
+
};
|
|
41
|
+
if (options.include && !options.include(operation)) {
|
|
42
|
+
used.delete(name);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const built = buildOperation(doc, pathItem, raw);
|
|
46
|
+
if ('skip' in built) {
|
|
47
|
+
used.delete(name);
|
|
48
|
+
skipped.push({ method: operation.method, path, reason: built.skip });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
operations.push(operation);
|
|
52
|
+
if (built.result)
|
|
53
|
+
results[name] = built.result;
|
|
54
|
+
tools.push({
|
|
55
|
+
name,
|
|
56
|
+
description: describe(raw, operation),
|
|
57
|
+
parameters: built.parameters,
|
|
58
|
+
capabilities: [
|
|
59
|
+
`api.${operation.access}:${apiName}/${name}`,
|
|
60
|
+
...(baseUrl ? [`network:${new URL(baseUrl).host}`] : []),
|
|
61
|
+
],
|
|
62
|
+
...(baseUrl ? { execute: caller(operation, built.placements, built.bodyType, baseUrl, options) } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { name: apiName, ...(baseUrl ? { baseUrl } : {}), tools, operations, results, skipped };
|
|
67
|
+
}
|
|
68
|
+
export async function loadOpenApiTools(url, options = {}) {
|
|
69
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
70
|
+
headers: { accept: 'application/json' },
|
|
71
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok)
|
|
74
|
+
throw new OpenApiError(`The OpenAPI document at ${url} answered ${response.status}`, 'OPENAPI_REQUEST', response.status);
|
|
75
|
+
return openApiTools(await response.text(), { documentUrl: url, ...options });
|
|
76
|
+
}
|
|
77
|
+
function parseDocument(document) {
|
|
78
|
+
let doc = document;
|
|
79
|
+
if (typeof document === 'string') {
|
|
80
|
+
try {
|
|
81
|
+
doc = JSON.parse(document);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new OpenApiError('The OpenAPI document is not JSON; parse a YAML document first, and pass the object', 'OPENAPI_INVALID');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!isRecord(doc))
|
|
88
|
+
throw new OpenApiError('An OpenAPI document is an object', 'OPENAPI_INVALID');
|
|
89
|
+
if (typeof doc.swagger === 'string') {
|
|
90
|
+
throw new OpenApiError(`Swagger ${doc.swagger} is not read; convert the document to OpenAPI 3`, 'OPENAPI_UNSUPPORTED');
|
|
91
|
+
}
|
|
92
|
+
if (typeof doc.openapi !== 'string' || !doc.openapi.startsWith('3.')) {
|
|
93
|
+
throw new OpenApiError(`An OpenAPI 3 document names its version in "openapi", not ${JSON.stringify(doc.openapi)}`, 'OPENAPI_INVALID');
|
|
94
|
+
}
|
|
95
|
+
return doc;
|
|
96
|
+
}
|
|
97
|
+
function baseOf(doc, options) {
|
|
98
|
+
if (options.baseUrl) {
|
|
99
|
+
try {
|
|
100
|
+
return new URL(options.baseUrl).href.replace(/\/+$/, '');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new OpenApiError(`baseUrl must be an absolute URL, not ${JSON.stringify(options.baseUrl)}`, 'OPENAPI_INVALID');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const server = doc.servers?.[0];
|
|
107
|
+
if (!server || typeof server.url !== 'string')
|
|
108
|
+
return undefined;
|
|
109
|
+
const url = server.url.replace(/\{([^}]+)\}/g, (_match, name) => server.variables?.[name]?.default ?? '');
|
|
110
|
+
try {
|
|
111
|
+
return new URL(url, options.documentUrl).href.replace(/\/+$/, '');
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function deref(doc, value, seen = []) {
|
|
118
|
+
if (!isRecord(value) || typeof value.$ref !== 'string')
|
|
119
|
+
return value;
|
|
120
|
+
const ref = value.$ref;
|
|
121
|
+
if (seen.includes(ref))
|
|
122
|
+
throw new OpenApiError(`${ref} refers to itself`, 'OPENAPI_INVALID');
|
|
123
|
+
return deref(doc, resolve(doc, ref), [...seen, ref]);
|
|
124
|
+
}
|
|
125
|
+
function resolve(doc, ref) {
|
|
126
|
+
if (!ref.startsWith('#')) {
|
|
127
|
+
throw new OpenApiError(`${ref} is outside the document; only references within it are followed`, 'OPENAPI_UNSUPPORTED');
|
|
128
|
+
}
|
|
129
|
+
let node = doc;
|
|
130
|
+
for (const part of ref.slice(1).split('/').slice(1)) {
|
|
131
|
+
const key = decodeURIComponent(part).replace(/~1/g, '/').replace(/~0/g, '~');
|
|
132
|
+
node = isRecord(node) || Array.isArray(node) ? node[key] : undefined;
|
|
133
|
+
if (node === undefined)
|
|
134
|
+
throw new OpenApiError(`${ref} names nothing in the document`, 'OPENAPI_INVALID');
|
|
135
|
+
}
|
|
136
|
+
return node;
|
|
137
|
+
}
|
|
138
|
+
function schemaOf(doc, schema, omit, seen = []) {
|
|
139
|
+
if (!isRecord(schema))
|
|
140
|
+
return {};
|
|
141
|
+
if (typeof schema.$ref === 'string') {
|
|
142
|
+
const ref = schema.$ref;
|
|
143
|
+
if (seen.includes(ref))
|
|
144
|
+
return { description: `The same shape as ${ref.split('/').at(-1)}, nested` };
|
|
145
|
+
const { $ref: _ref, ...siblings } = schema;
|
|
146
|
+
return { ...schemaOf(doc, resolve(doc, ref), omit, [...seen, ref]), ...schemaOf(doc, siblings, omit, seen) };
|
|
147
|
+
}
|
|
148
|
+
const out = {};
|
|
149
|
+
const one = (value) => (isRecord(value) ? schemaOf(doc, value, omit, seen) : value);
|
|
150
|
+
const many = (value) => (Array.isArray(value) ? value.map(one) : value);
|
|
151
|
+
const each = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, one(item)])) : value;
|
|
152
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
153
|
+
if (OPENAPI_ONLY.has(key))
|
|
154
|
+
continue;
|
|
155
|
+
if (key === 'properties' && isRecord(value)) {
|
|
156
|
+
const kept = {};
|
|
157
|
+
for (const [name, property] of Object.entries(value)) {
|
|
158
|
+
const read = schemaOf(doc, property, omit, seen);
|
|
159
|
+
if (read[omit] === true)
|
|
160
|
+
continue;
|
|
161
|
+
kept[name] = read;
|
|
162
|
+
}
|
|
163
|
+
out.properties = kept;
|
|
164
|
+
}
|
|
165
|
+
else if (['allOf', 'anyOf', 'oneOf', 'prefixItems'].includes(key))
|
|
166
|
+
out[key] = many(value);
|
|
167
|
+
else if (['$defs', 'definitions', 'patternProperties', 'dependentSchemas'].includes(key))
|
|
168
|
+
out[key] = each(value);
|
|
169
|
+
else if (['items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'if', 'then', 'else'].includes(key)) {
|
|
170
|
+
out[key] = one(value);
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
out[key] = value;
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(out.required) && isRecord(out.properties)) {
|
|
176
|
+
const properties = out.properties;
|
|
177
|
+
out.required = out.required.filter((name) => typeof name === 'string' && name in properties);
|
|
178
|
+
if (out.required.length === 0)
|
|
179
|
+
delete out.required;
|
|
180
|
+
}
|
|
181
|
+
if (schema.nullable === true) {
|
|
182
|
+
if (typeof out.type === 'string')
|
|
183
|
+
out.type = [out.type, 'null'];
|
|
184
|
+
else if (Array.isArray(out.type) && !out.type.includes('null'))
|
|
185
|
+
out.type = [...out.type, 'null'];
|
|
186
|
+
}
|
|
187
|
+
if ('example' in schema && !('examples' in schema))
|
|
188
|
+
out.examples = [schema.example];
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function buildOperation(doc, pathItem, raw) {
|
|
192
|
+
const declared = new Map();
|
|
193
|
+
for (const list of [pathItem.parameters, raw.parameters]) {
|
|
194
|
+
if (!Array.isArray(list))
|
|
195
|
+
continue;
|
|
196
|
+
for (const item of list) {
|
|
197
|
+
const parameter = deref(doc, item);
|
|
198
|
+
if (!isRecord(parameter) || typeof parameter.name !== 'string' || typeof parameter.in !== 'string')
|
|
199
|
+
continue;
|
|
200
|
+
declared.set(`${parameter.in}\u0000${parameter.name}`, parameter);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const properties = {};
|
|
204
|
+
const required = [];
|
|
205
|
+
const placements = [];
|
|
206
|
+
for (const parameter of declared.values()) {
|
|
207
|
+
const place = parameter.in;
|
|
208
|
+
const name = parameter.name;
|
|
209
|
+
if (place === 'cookie' || (place === 'header' && IGNORED_HEADERS.has(name.toLowerCase())))
|
|
210
|
+
continue;
|
|
211
|
+
if (place !== 'path' && place !== 'query' && place !== 'header')
|
|
212
|
+
continue;
|
|
213
|
+
const arg = name in properties ? `${place}_${name}` : name;
|
|
214
|
+
const content = isRecord(parameter.content) ? Object.values(parameter.content)[0] : undefined;
|
|
215
|
+
const schema = schemaOf(doc, parameter.schema ?? (isRecord(content) ? content.schema : undefined), 'readOnly');
|
|
216
|
+
properties[arg] = {
|
|
217
|
+
...schema,
|
|
218
|
+
...(typeof parameter.description === 'string' && !schema.description
|
|
219
|
+
? { description: parameter.description }
|
|
220
|
+
: {}),
|
|
221
|
+
};
|
|
222
|
+
if (place === 'path' || parameter.required === true)
|
|
223
|
+
required.push(arg);
|
|
224
|
+
placements.push({ arg, name, in: place });
|
|
225
|
+
}
|
|
226
|
+
let bodyType;
|
|
227
|
+
const body = deref(doc, raw.requestBody);
|
|
228
|
+
if (isRecord(body) && isRecord(body.content)) {
|
|
229
|
+
const types = Object.keys(body.content);
|
|
230
|
+
const json = types.find((type) => /^application\/(?:[\w.+-]+\+)?json\b/i.test(type));
|
|
231
|
+
const form = types.find((type) => /^application\/x-www-form-urlencoded\b/i.test(type));
|
|
232
|
+
const chosen = json ?? form;
|
|
233
|
+
if (!chosen)
|
|
234
|
+
return { skip: `its request body is ${types.join(' or ')}; only JSON and form bodies are sent` };
|
|
235
|
+
bodyType = json ? 'json' : 'form';
|
|
236
|
+
const media = body.content[chosen];
|
|
237
|
+
const schema = schemaOf(doc, isRecord(media) ? media.schema : undefined, 'readOnly');
|
|
238
|
+
const fields = isRecord(schema.properties) ? schema.properties : undefined;
|
|
239
|
+
const flat = fields &&
|
|
240
|
+
(schema.type === 'object' || schema.type === undefined) &&
|
|
241
|
+
!schema.allOf &&
|
|
242
|
+
!schema.anyOf &&
|
|
243
|
+
!schema.oneOf &&
|
|
244
|
+
Object.keys(fields).every((field) => !(field in properties));
|
|
245
|
+
if (flat) {
|
|
246
|
+
const fieldsRequired = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
|
|
247
|
+
for (const [field, fieldSchema] of Object.entries(fields)) {
|
|
248
|
+
properties[field] = fieldSchema;
|
|
249
|
+
if (body.required === true && fieldsRequired.has(field))
|
|
250
|
+
required.push(field);
|
|
251
|
+
placements.push({ arg: field, name: field, in: 'body-field' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const arg = 'body' in properties ? 'requestBody' : 'body';
|
|
256
|
+
properties[arg] = {
|
|
257
|
+
...schema,
|
|
258
|
+
...(typeof body.description === 'string' && !schema.description ? { description: body.description } : {}),
|
|
259
|
+
};
|
|
260
|
+
if (body.required === true)
|
|
261
|
+
required.push(arg);
|
|
262
|
+
placements.push({ arg, name: arg, in: 'body' });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
let result;
|
|
266
|
+
const responses = isRecord(raw.responses) ? raw.responses : {};
|
|
267
|
+
const success = Object.keys(responses)
|
|
268
|
+
.filter((code) => /^2(?:\d\d|XX)$/i.test(code))
|
|
269
|
+
.sort()[0];
|
|
270
|
+
if (success) {
|
|
271
|
+
const response = deref(doc, responses[success]);
|
|
272
|
+
const content = isRecord(response) && isRecord(response.content) ? response.content : undefined;
|
|
273
|
+
const json = content && Object.keys(content).find((type) => /json/i.test(type));
|
|
274
|
+
const media = json && content ? content[json] : undefined;
|
|
275
|
+
if (isRecord(media) && media.schema !== undefined)
|
|
276
|
+
result = schemaOf(doc, media.schema, 'writeOnly');
|
|
277
|
+
else if (!content)
|
|
278
|
+
result = { type: 'null' };
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
parameters: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
properties,
|
|
284
|
+
...(required.length ? { required } : {}),
|
|
285
|
+
additionalProperties: false,
|
|
286
|
+
},
|
|
287
|
+
placements,
|
|
288
|
+
...(bodyType ? { bodyType } : {}),
|
|
289
|
+
...(result ? { result } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function describe(raw, operation) {
|
|
293
|
+
const text = [raw.summary, raw.description].filter((part) => typeof part === 'string' && part.trim() !== '');
|
|
294
|
+
const said = text.length ? text.join('\n\n') : `${operation.method} ${operation.path}`;
|
|
295
|
+
return said.length > 1_024 ? `${said.slice(0, 1_021)}...` : said;
|
|
296
|
+
}
|
|
297
|
+
function caller(operation, placements, bodyType, baseUrl, options) {
|
|
298
|
+
const base = new URL(`${baseUrl}/`);
|
|
299
|
+
return async (args, context) => {
|
|
300
|
+
context?.signal?.throwIfAborted();
|
|
301
|
+
let path = operation.path;
|
|
302
|
+
for (const placement of placements.filter((item) => item.in === 'path')) {
|
|
303
|
+
const value = args[placement.arg];
|
|
304
|
+
if (value === undefined || value === null || String(value) === '') {
|
|
305
|
+
throw new OpenApiError(`${operation.name} needs "${placement.arg}"`, 'OPENAPI_INVALID');
|
|
306
|
+
}
|
|
307
|
+
const text = String(value);
|
|
308
|
+
if (text === '.' || text === '..') {
|
|
309
|
+
throw new OpenApiError(`"${placement.arg}" cannot be "${text}"`, 'OPENAPI_INVALID');
|
|
310
|
+
}
|
|
311
|
+
path = path.split(`{${placement.name}}`).join(encodeURIComponent(text));
|
|
312
|
+
}
|
|
313
|
+
const url = new URL(path.replace(/^\/+/, ''), base);
|
|
314
|
+
const headers = { accept: 'application/json' };
|
|
315
|
+
let body;
|
|
316
|
+
for (const placement of placements) {
|
|
317
|
+
const value = args[placement.arg];
|
|
318
|
+
if (value === undefined || value === null)
|
|
319
|
+
continue;
|
|
320
|
+
if (placement.in === 'query') {
|
|
321
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
322
|
+
url.searchParams.append(placement.name, text(item));
|
|
323
|
+
}
|
|
324
|
+
else if (placement.in === 'header')
|
|
325
|
+
headers[placement.name.toLowerCase()] = text(value);
|
|
326
|
+
else if (placement.in === 'body-field') {
|
|
327
|
+
body ??= {};
|
|
328
|
+
body[placement.name] = value;
|
|
329
|
+
}
|
|
330
|
+
else if (placement.in === 'body')
|
|
331
|
+
body = value;
|
|
332
|
+
}
|
|
333
|
+
const own = typeof options.headers === 'function' ? await options.headers({ operation, context }) : options.headers;
|
|
334
|
+
for (const [name, value] of Object.entries(own ?? {}))
|
|
335
|
+
headers[name.toLowerCase()] = value;
|
|
336
|
+
let payload;
|
|
337
|
+
if (body !== undefined && bodyType === 'form') {
|
|
338
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
339
|
+
payload = new URLSearchParams(Object.entries(body).map(([key, value]) => [key, text(value)])).toString();
|
|
340
|
+
}
|
|
341
|
+
else if (body !== undefined) {
|
|
342
|
+
headers['content-type'] = 'application/json';
|
|
343
|
+
payload = JSON.stringify(body);
|
|
344
|
+
}
|
|
345
|
+
const linked = linkSignals([context?.signal, AbortSignal.timeout(options.timeoutMs ?? 30_000)]);
|
|
346
|
+
try {
|
|
347
|
+
const response = await (options.fetch ?? globalThis.fetch)(url.href, {
|
|
348
|
+
method: operation.method,
|
|
349
|
+
headers,
|
|
350
|
+
...(payload !== undefined ? { body: payload } : {}),
|
|
351
|
+
redirect: 'manual',
|
|
352
|
+
signal: linked.signal,
|
|
353
|
+
});
|
|
354
|
+
const answer = await readCapped(response, options.maxResponseBytes ?? 1_000_000, operation);
|
|
355
|
+
if (response.status >= 300 && response.status < 400) {
|
|
356
|
+
throw new OpenApiError(`${operation.name} was redirected to ${response.headers.get('location') ?? 'another place'}, which is not followed`, 'OPENAPI_REQUEST', response.status);
|
|
357
|
+
}
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
throw new OpenApiError(`${operation.name} answered ${response.status}: ${detailOf(answer)}`, 'OPENAPI_REQUEST', response.status);
|
|
360
|
+
}
|
|
361
|
+
if (answer.trim() === '')
|
|
362
|
+
return null;
|
|
363
|
+
if (/json/i.test(response.headers.get('content-type') ?? '')) {
|
|
364
|
+
try {
|
|
365
|
+
return JSON.parse(answer);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
throw new OpenApiError(`${operation.name} answered with JSON that does not parse`, 'OPENAPI_REQUEST', response.status);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return answer;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (error instanceof OpenApiError || context?.signal?.aborted)
|
|
375
|
+
throw error;
|
|
376
|
+
throw new OpenApiError(`${operation.name} could not be reached: ${linked.signal.aborted ? `no answer within ${options.timeoutMs ?? 30_000} ms` : messageOf(error)}`, 'OPENAPI_REQUEST');
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
linked.dispose();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
async function readCapped(response, limit, operation) {
|
|
384
|
+
if (!response.body)
|
|
385
|
+
return '';
|
|
386
|
+
const reader = response.body.getReader();
|
|
387
|
+
const chunks = [];
|
|
388
|
+
let size = 0;
|
|
389
|
+
for (;;) {
|
|
390
|
+
const { done, value } = await reader.read();
|
|
391
|
+
if (done)
|
|
392
|
+
break;
|
|
393
|
+
size += value.byteLength;
|
|
394
|
+
if (size > limit) {
|
|
395
|
+
await reader.cancel().catch(() => undefined);
|
|
396
|
+
throw new OpenApiError(`${operation.name} answered more than ${limit} bytes`, 'OPENAPI_REQUEST', response.status);
|
|
397
|
+
}
|
|
398
|
+
chunks.push(value);
|
|
399
|
+
}
|
|
400
|
+
const bytes = new Uint8Array(size);
|
|
401
|
+
let offset = 0;
|
|
402
|
+
for (const chunk of chunks) {
|
|
403
|
+
bytes.set(chunk, offset);
|
|
404
|
+
offset += chunk.byteLength;
|
|
405
|
+
}
|
|
406
|
+
return new TextDecoder().decode(bytes);
|
|
407
|
+
}
|
|
408
|
+
function detailOf(answer) {
|
|
409
|
+
try {
|
|
410
|
+
const parsed = JSON.parse(answer);
|
|
411
|
+
if (isRecord(parsed)) {
|
|
412
|
+
for (const key of ['error', 'message', 'detail', 'title']) {
|
|
413
|
+
const value = parsed[key];
|
|
414
|
+
if (typeof value === 'string' && value.trim())
|
|
415
|
+
return value;
|
|
416
|
+
if (isRecord(value) && typeof value.message === 'string')
|
|
417
|
+
return value.message;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
}
|
|
423
|
+
return answer.trim().slice(0, 300) || 'no detail';
|
|
424
|
+
}
|
|
425
|
+
function sanitize(name) {
|
|
426
|
+
return name
|
|
427
|
+
.replace(/[^A-Za-z0-9_-]+/g, '_')
|
|
428
|
+
.replace(/_+/g, '_')
|
|
429
|
+
.replace(/^_+|_+$/g, '');
|
|
430
|
+
}
|
|
431
|
+
function uniqueName(name, used) {
|
|
432
|
+
const base = name.slice(0, 64);
|
|
433
|
+
let candidate = base;
|
|
434
|
+
for (let index = 2; used.has(candidate); index += 1) {
|
|
435
|
+
const suffix = `_${index}`;
|
|
436
|
+
candidate = `${base.slice(0, 64 - suffix.length)}${suffix}`;
|
|
437
|
+
}
|
|
438
|
+
used.add(candidate);
|
|
439
|
+
return candidate;
|
|
440
|
+
}
|
|
441
|
+
function slug(text) {
|
|
442
|
+
return text
|
|
443
|
+
.toLowerCase()
|
|
444
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
445
|
+
.replace(/^-+|-+$/g, '');
|
|
446
|
+
}
|
|
447
|
+
function text(value) {
|
|
448
|
+
return value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
449
|
+
}
|
|
450
|
+
function isRecord(value) {
|
|
451
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
452
|
+
}
|
|
453
|
+
function messageOf(error) {
|
|
454
|
+
return error instanceof Error ? error.message : String(error);
|
|
455
|
+
}
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tools emulated by a model, for evaluations that must not touch the world: an agent that would
|
|
3
|
-
* charge a card or write to a production database runs against a model playing each tool instead,
|
|
4
|
-
* with nothing sent, and the emulator's answers recorded so a run replays them exactly.
|
|
5
|
-
*/
|
|
6
1
|
import type { CompletionRequest, ToolDefinition } from '../types/messages.js';
|
|
7
2
|
import type { NexusResponse } from '../types/response.js';
|
|
8
3
|
/** The model that plays the tools: the client, or anything with its `complete()`. */
|
|
@@ -89,6 +84,76 @@ export interface EmulatedToolsOptions {
|
|
|
89
84
|
maxTokens?: number;
|
|
90
85
|
/** Called after each call, with who answered it. */
|
|
91
86
|
onCall?: (call: EmulatedToolCall) => void;
|
|
87
|
+
/**
|
|
88
|
+
* The world the tools act in, held across every run that shares it: each call made in it, and the
|
|
89
|
+
* changes they made, so an order one example refunds is refunded for the next. The emulator is
|
|
90
|
+
* told every change, and the latest `history` calls; its scenario is the tools' when they name
|
|
91
|
+
* none. Share one world among runs made one at a time, in order; or start each run from a
|
|
92
|
+
* `fork()`, or from a `snapshot()` read back.
|
|
93
|
+
*/
|
|
94
|
+
world?: EmulatedWorld;
|
|
95
|
+
/**
|
|
96
|
+
* Tools that change nothing, by name, beside those whose every capability reads: `filesystem:read`,
|
|
97
|
+
* `mcp.read`, or `api.read`, or none at all. A world counts what any other tool did, when it did
|
|
98
|
+
* not fail, as a change.
|
|
99
|
+
*/
|
|
100
|
+
reads?: readonly string[];
|
|
101
|
+
}
|
|
102
|
+
/** One call made in an emulated world. */
|
|
103
|
+
export interface EmulatedWorldCall extends EmulatedResult {
|
|
104
|
+
/** Whether it changed the world: it succeeded, and its tool does more than read. */
|
|
105
|
+
changed: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** An emulated world as plain JSON, to write to a file and start runs from. */
|
|
108
|
+
export interface EmulatedWorldSnapshot {
|
|
109
|
+
/** The world the tools act on, before any call. */
|
|
110
|
+
scenario?: string;
|
|
111
|
+
/** Every call made in it, oldest first. */
|
|
112
|
+
calls: EmulatedWorldCall[];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A world emulated tools act in, kept across the runs of an evaluation: the scenario it started
|
|
116
|
+
* from, and every call made in it, so what one run changes is there for the next, as on a staging
|
|
117
|
+
* copy of a service. `snapshot()` writes it as JSON, `new EmulatedWorld(snapshot)` reads it back,
|
|
118
|
+
* and `fork()` starts a world from this one's present whose changes stay its own.
|
|
119
|
+
*
|
|
120
|
+
* ```ts
|
|
121
|
+
* const world = new EmulatedWorld({ scenario: 'Order 1042 is delivered and cost $59.' });
|
|
122
|
+
* await evaluate(
|
|
123
|
+
* (inputs) => agentOver(emulatedTools(shop.tools, { client: ai, results: shop.results, world })).invoke(agentInput(inputs.request)),
|
|
124
|
+
* dataset,
|
|
125
|
+
* evaluators,
|
|
126
|
+
* { concurrency: 1 },
|
|
127
|
+
* );
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export declare class EmulatedWorld {
|
|
131
|
+
/** The world the tools act on, before any call. */
|
|
132
|
+
readonly scenario?: string;
|
|
133
|
+
private readonly log;
|
|
134
|
+
constructor(start?: {
|
|
135
|
+
scenario?: string;
|
|
136
|
+
calls?: readonly EmulatedWorldCall[];
|
|
137
|
+
});
|
|
138
|
+
/** Every call made in it, oldest first. */
|
|
139
|
+
get calls(): readonly EmulatedWorldCall[];
|
|
140
|
+
/** The calls that changed it, oldest first. */
|
|
141
|
+
get changes(): readonly EmulatedWorldCall[];
|
|
142
|
+
/** Records a call made in it, and whether it changed it. */
|
|
143
|
+
record(call: EmulatedResult, changed: boolean): void;
|
|
144
|
+
/**
|
|
145
|
+
* A digest of its scenario and every change made in it, which the keys of its calls' answers carry:
|
|
146
|
+
* two worlds that started alike and changed alike have the same digest, however much was read.
|
|
147
|
+
*/
|
|
148
|
+
digest(): string;
|
|
149
|
+
/** How many calls with these arguments it has seen since its last change. */
|
|
150
|
+
ordinal(tool: string, args: Record<string, unknown>): number;
|
|
151
|
+
/** It as plain JSON, to write to a file and start runs from. */
|
|
152
|
+
snapshot(): EmulatedWorldSnapshot;
|
|
153
|
+
/** A world starting from this one's present, whose changes stay its own. */
|
|
154
|
+
fork(): EmulatedWorld;
|
|
155
|
+
/** The same as `snapshot()`, so `JSON.stringify(world)` writes it. */
|
|
156
|
+
toJSON(): EmulatedWorldSnapshot;
|
|
92
157
|
}
|
|
93
158
|
/** Raised when a call cannot be emulated: no kept answer in `replay`, or no valid answer from the model. */
|
|
94
159
|
export declare class EmulationError extends Error {
|
|
@@ -121,13 +186,15 @@ export declare class MemoryEmulationStore implements EmulationStore {
|
|
|
121
186
|
}
|
|
122
187
|
/**
|
|
123
188
|
* The key of a call: its tool, its arguments, its scenario, and which call with those arguments it
|
|
124
|
-
* is, so an order looked up before and after a refund keeps both answers.
|
|
189
|
+
* is, so an order looked up before and after a refund keeps both answers. In a world, also the
|
|
190
|
+
* world's `digest()`, so the same call answers again only after the same changes.
|
|
125
191
|
*/
|
|
126
192
|
export declare function emulationKey(call: {
|
|
127
193
|
tool: string;
|
|
128
194
|
args: Record<string, unknown>;
|
|
129
195
|
scenario?: string;
|
|
130
196
|
ordinal?: number;
|
|
197
|
+
world?: string;
|
|
131
198
|
}): string;
|
|
132
199
|
/**
|
|
133
200
|
* Tools that look the same to the agent, by name, description, parameters, and capabilities, but are
|