@formio/uag-sdk 0.0.0-stage → 2.0.0-rc.10
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/LICENSE.txt +19 -0
- package/README.md +162 -2
- package/conformance.d.ts +31 -0
- package/conformance.js +291 -0
- package/contract.d.ts +223 -0
- package/define.js +142 -0
- package/errors.js +43 -0
- package/helpers.js +145 -0
- package/index.d.ts +111 -0
- package/index.js +30 -0
- package/instances.js +117 -0
- package/loader.d.ts +67 -0
- package/loader.js +272 -0
- package/package.json +57 -4
- package/protocol.d.ts +61 -0
- package/protocol.js +269 -0
- package/settings.js +99 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright (c) 2025 Form.io
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
5
|
+
this software and associated documentation files (the "Software"), to deal in the
|
|
6
|
+
Software without restriction, including without limitation the rights to use,
|
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
8
|
+
Software, and to permit persons to whom the Software is furnished to do so,
|
|
9
|
+
subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
15
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
16
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
17
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
18
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
19
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,163 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @formio/uag-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<sub>[Form.io UAG](https://github.com/formio/uag/blob/v2.0.0-rc.8/README.md) › [Engines](https://github.com/formio/uag/blob/v2.0.0-rc.8/README.md#engines-and-routing) › this page</sub>
|
|
4
|
+
|
|
5
|
+
Build engine packages for the Form.io Universal Agent Gateway (UAG). This package contains:
|
|
6
|
+
|
|
7
|
+
- the engine contract's types, and `ENGINE_API_VERSION`;
|
|
8
|
+
- `defineEngine`, to declare an engine type and its settings;
|
|
9
|
+
- `EngineError`, for messages that are safe to show to API callers;
|
|
10
|
+
- the loader, which the UAG, `uag-eval` and the engine server use to find, configure and create engines;
|
|
11
|
+
- shared helpers for model APIs with structured outputs;
|
|
12
|
+
- a conformance check that runs from any test runner.
|
|
13
|
+
|
|
14
|
+
An engine package depends on this package only. It never needs `@formio/uag`.
|
|
15
|
+
|
|
16
|
+
## An engine package
|
|
17
|
+
|
|
18
|
+
Name the package `@formio/uag-<type>` (official engines) or `uag-engine-<type>` (anyone's). Export one engine type definition. It is a plugin of type `"engine"`: `name` is the type name used in `UAG_ENGINES`, `title` (default: `name`) is shown to people, and `version` defaults to your package's own version:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
const { defineEngine, EngineError, ENGINE_API_VERSION, requiredSchema } = require('@formio/uag-sdk');
|
|
22
|
+
|
|
23
|
+
module.exports = defineEngine({
|
|
24
|
+
type: 'engine',
|
|
25
|
+
name: 'acme',
|
|
26
|
+
title: 'Acme',
|
|
27
|
+
kinds: ['agent'],
|
|
28
|
+
apiVersion: ENGINE_API_VERSION,
|
|
29
|
+
settings: {
|
|
30
|
+
apiKey: { env: 'ACME_API_KEY', required: true, secret: true },
|
|
31
|
+
model: { env: 'ACME_MODEL', default: 'acme-large-2026-09-01' },
|
|
32
|
+
maxTokens: { env: 'ACME_MAX_TOKENS', type: 'number', default: 4000 },
|
|
33
|
+
},
|
|
34
|
+
create({ name, settings }) {
|
|
35
|
+
const client = new AcmeClient({ apiKey: settings.apiKey });
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
kind: 'agent',
|
|
39
|
+
apiVersion: ENGINE_API_VERSION,
|
|
40
|
+
model: settings.model,
|
|
41
|
+
async extract(request) {
|
|
42
|
+
request.signal?.throwIfAborted();
|
|
43
|
+
const { schema, decode } = requiredSchema(request.schema);
|
|
44
|
+
const answer = await client.complete({ schema, signal: request.signal /* ... */ });
|
|
45
|
+
if (answer.quotaExceeded) throw new EngineError('Quota exceeded for this API key.');
|
|
46
|
+
return { model: answer.model, values: decode(answer.json) };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The engine must use the instance `name` it is given, and one of the `kinds` it declares. A decision engine has `decide` and `maxOptions`; an agent engine has `extract`, and sets `toolUse: true` when `extract` accepts read-only research tools. See `contract.d.ts` for every request and response.
|
|
54
|
+
|
|
55
|
+
A definition in the shape of the 2.0 release candidates, with the engine's name in `type` (`{ type: 'acme', kinds, ... }`), is refused with a message showing the shape above.
|
|
56
|
+
|
|
57
|
+
### Generating text over documents
|
|
58
|
+
|
|
59
|
+
An agent engine may also implement `generate(request)`, which document plugins call to write free text over uploaded documents, and declare `accepts`, the document kinds it reads natively (default `['text']`; list `'pdf'` only when your model reads PDFs as documents):
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
accepts: ['text', 'pdf'],
|
|
63
|
+
async generate({ instructions, system, attachments, format, maxTokens, signal, onProgress }) {
|
|
64
|
+
// attachments: { kind: 'pdf', data: Buffer, label, pages? } or { kind: 'text', text, label, textFormat? }
|
|
65
|
+
// Put the documents before the instructions; report progress as the answer streams.
|
|
66
|
+
return { text, model, stopReason: 'end' /* or 'max_tokens' when cut off */, usage };
|
|
67
|
+
},
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`generate` must honour `signal`, never log the documents, and report a truncated answer as `stopReason: 'max_tokens'` rather than failing.
|
|
71
|
+
|
|
72
|
+
`create` receives `{ name, settings, options, config, requireSetting }`:
|
|
73
|
+
|
|
74
|
+
- `settings`: the instance's value for each declared setting that has one.
|
|
75
|
+
- `options`: the instance's options as given in configuration, including ones that are not settings, such as a fake client in tests.
|
|
76
|
+
- `config`: the host's configuration, read-only, for example `routing.timeouts[name]`.
|
|
77
|
+
- `requireSetting(key)`: a setting's value, or the standard "needs its setting" error, for a setting that is required only sometimes.
|
|
78
|
+
|
|
79
|
+
`create` may be async.
|
|
80
|
+
|
|
81
|
+
## Settings
|
|
82
|
+
|
|
83
|
+
Each setting is resolved from the first of these that is set:
|
|
84
|
+
|
|
85
|
+
1. the instance's options in configuration;
|
|
86
|
+
2. `UAG_ENGINE_<NAME>_<SETTING>`, from the instance name and the setting name (`fast` and `apiKey` give `UAG_ENGINE_FAST_API_KEY`);
|
|
87
|
+
3. the variables the setting lists in `env`, in order;
|
|
88
|
+
4. its `default`.
|
|
89
|
+
|
|
90
|
+
| Key | Meaning |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `env` | The type's own variables, as a name or a list. |
|
|
93
|
+
| `required` | Startup fails without a value. The error names the instance, the type, the setting and every variable that sets it. |
|
|
94
|
+
| `secret` | The value is kept out of startup errors and capability discovery. Errors an engine throws while it runs are logged as they are, so never put a setting's value in one. |
|
|
95
|
+
| `type` | `string` (the default), `number` or `boolean`. Values from the environment are converted. |
|
|
96
|
+
| `default` | Used when nothing else is set. |
|
|
97
|
+
|
|
98
|
+
## Instances
|
|
99
|
+
|
|
100
|
+
Hosts enable engines as named instances of a type:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
UAG_ENGINES=jev,fast:claude,deep:claude,x:@acme/uag-engine-x
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- A bare entry (`jev`) names an instance after its type.
|
|
107
|
+
- `<name>:<type>` names it yourself, so one type can run as several instances with different settings.
|
|
108
|
+
- A type starting with `@` or `uag-engine-`, or containing `/`, is a package name. A bare package entry takes the package's type name.
|
|
109
|
+
- Names use lowercase letters, digits and `-`, start with a letter, and are at most 63 characters. Anything else, including any path, is refused before anything is loaded.
|
|
110
|
+
|
|
111
|
+
In configuration, an entry is such a string, or `{ name, type, options }`, where `type` may also be an engine type definition.
|
|
112
|
+
|
|
113
|
+
## Loading
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
const { engineInstances } = require('@formio/uag-sdk');
|
|
117
|
+
const { enginePaths, loadEngines } = require('@formio/uag-sdk/loader');
|
|
118
|
+
|
|
119
|
+
const engines = await loadEngines(engineInstances(config, process.env), {
|
|
120
|
+
types: { remote: RemoteEngine }, // built-in and embedded types, tried first
|
|
121
|
+
paths: enginePaths(__dirname, process.env),
|
|
122
|
+
env: process.env,
|
|
123
|
+
config,
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A type name is looked up in `types`, then as `@formio/uag-<type>`, then as `uag-engine-<type>`. Packages are looked up in the `node_modules` of the host's installation and of the directories above it (where Node finds the host's own dependencies), then in `<dir>/node_modules` of each absolute directory in `UAG_ENGINE_PATH`. Never in the working directory, `NODE_PATH` or Node's global folders, and a package whose entry point resolves outside its own directory is refused. Nothing is ever installed. Engine packages are loaded with `require()`: CommonJS, or ES modules on Node 22.12 or later. Each result is `{ engine, type, package, version }`, which is what `Formflow.engineRegistry` takes.
|
|
128
|
+
|
|
129
|
+
Every definition is checked structurally, so a package built with another copy of this SDK, or with none, loads too. A package built for another engine contract version is refused, and the error names both versions.
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
Throw `EngineError` for a failure whose message the caller may see, such as "Quota exceeded for this API key." Any other error is reported to API callers only as "the engine failed (see the logs for request X)", because provider SDK errors can carry URLs and account details.
|
|
134
|
+
|
|
135
|
+
## Helpers
|
|
136
|
+
|
|
137
|
+
- `requiredSchema(schema)`: the extraction schema with every field required, for strict structured outputs, and `decode(answer)` to map the answer back. An empty value stands for "not stated" and is left out. Numbers and yes/no fields travel as strings, keeping the field's title and description; a number written as an amount (`$4,250`) decodes to 4250, and anything else that is not a number is left out. A typed enum keeps its values, and an answer outside them is dropped.
|
|
138
|
+
- `parseJsonObject(text)`: the first JSON object in a text answer, or an `EngineError`.
|
|
139
|
+
- `toolResultText(result)`: a tool call's result as text for a model.
|
|
140
|
+
|
|
141
|
+
## Conformance
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const { checkEngine } = require('@formio/uag-sdk/conformance');
|
|
145
|
+
|
|
146
|
+
it('meets the engine contract', async () => {
|
|
147
|
+
const report = await checkEngine(require('..'), { options: { client: fakeClient } });
|
|
148
|
+
assert.deepStrictEqual(report.failures, []);
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The check creates an instance and calls it. Pass options, such as a fake client, or the environment variables that let it answer. It checks that:
|
|
153
|
+
|
|
154
|
+
- the definition and the engine have the contract's shape;
|
|
155
|
+
- the engine uses the instance name and the `model` option;
|
|
156
|
+
- `extract` returns only the fields it was asked about, or `decide` only the questions it was asked;
|
|
157
|
+
- `accepts`, when set, lists only `text` and `pdf`; `generate`, when present, returns text, its model and a valid `stopReason`;
|
|
158
|
+
- research stops at `maxIterations`, and reports every tool call it made;
|
|
159
|
+
- an aborted signal stops the engine, including during research and `generate`.
|
|
160
|
+
|
|
161
|
+
A research round is the calls a model makes together; calls made one after another count as separate rounds.
|
|
162
|
+
|
|
163
|
+
License: MIT.
|
package/conformance.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EngineTypeDefinition } from './index';
|
|
2
|
+
import type { DecisionQuestion, DecisionState, FieldSpec, ToolDefinition } from './contract';
|
|
3
|
+
|
|
4
|
+
export type ConformanceResult = { check: string; ok: boolean; skipped?: boolean; message?: string };
|
|
5
|
+
|
|
6
|
+
export type ConformanceReport = {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
/** "<check>: <why>" for each failed check. */
|
|
9
|
+
failures: string[];
|
|
10
|
+
results: ConformanceResult[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Check an engine type against the engine contract, from any test runner. */
|
|
14
|
+
export declare function checkEngine(definition: EngineTypeDefinition, options?: {
|
|
15
|
+
/** The instance name to create. Default: "conformance". */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** The instance's options, such as a fake client. */
|
|
18
|
+
options?: Record<string, any>;
|
|
19
|
+
env?: Record<string, string | undefined>;
|
|
20
|
+
config?: Record<string, any>;
|
|
21
|
+
/** How long each call may take, in milliseconds. Default: 10000. */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
}): Promise<ConformanceReport>;
|
|
24
|
+
|
|
25
|
+
/** The fixed inputs the check sends, for an engine's own tests and fakes. */
|
|
26
|
+
export declare const STATE: DecisionState;
|
|
27
|
+
export declare const FIELDS: FieldSpec[];
|
|
28
|
+
export declare const SCHEMA: Record<string, any>;
|
|
29
|
+
export declare const QUESTIONS: Record<string, DecisionQuestion>;
|
|
30
|
+
/** The research tool the check offers. */
|
|
31
|
+
export declare const LOOKUP: ToolDefinition;
|
package/conformance.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { ENGINE_API_VERSION, definitionProblems } = require('./define');
|
|
3
|
+
const { createEngine } = require('./loader');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A conformance check for engine types, usable from any test runner:
|
|
7
|
+
*
|
|
8
|
+
* const { checkEngine } = require('@formio/uag-sdk/conformance');
|
|
9
|
+
* const report = await checkEngine(MyEngine, { options: { client: fakeClient } });
|
|
10
|
+
* assert.deepEqual(report.failures, []);
|
|
11
|
+
*
|
|
12
|
+
* It creates an instance and calls it, so pass options (a fake client, say) or
|
|
13
|
+
* environment variables that let the engine answer. With a real model the
|
|
14
|
+
* answers vary, so the checks test the contract, not the content.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const STATE = {
|
|
18
|
+
instructions: 'Fill in the fields from the notes.',
|
|
19
|
+
input: { Notes: 'My name is Ada Lovelace and I live in London.' },
|
|
20
|
+
today: 'Thursday, October 22, 2026 (2026-10-22)',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const FIELDS = [
|
|
24
|
+
{ path: 'name', label: 'Name', type: 'textfield', description: 'The person\'s full name.', rule: 'Plain text.' },
|
|
25
|
+
{ path: 'city', label: 'City', type: 'textfield', description: 'Where the person lives.', rule: 'Plain text.' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const SCHEMA = { type: 'object', properties: { name: { type: 'string' }, city: { type: 'string' } } };
|
|
29
|
+
|
|
30
|
+
const LOOKUP = {
|
|
31
|
+
name: 'lookup',
|
|
32
|
+
description: 'Look up more notes about the person. Results come in pages.',
|
|
33
|
+
inputSchema: { type: 'object', properties: { page: { type: 'number' } } },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const QUESTIONS = {
|
|
37
|
+
colour: { type: 'choice', instructions: 'Which colour does the input mention?', options: { Red: 'The colour red.', Blue: null } },
|
|
38
|
+
size: { type: 'score', instructions: 'How large is the request?', levels: ['Small', 'Medium', 'Large'] },
|
|
39
|
+
urgent: { type: 'noul', instructions: 'Is the request urgent?' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function extractRequest(extra = {}) {
|
|
43
|
+
return { instructions: STATE.instructions, state: STATE, fields: FIELDS, schema: SCHEMA, ...extra };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(message) {
|
|
47
|
+
throw new Error(message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isProbability(value) {
|
|
51
|
+
return typeof value === 'number' && value >= 0 && value <= 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Settle within `ms`, or fail saying what did not finish. */
|
|
55
|
+
function within(ms, promise, what) {
|
|
56
|
+
let timer;
|
|
57
|
+
return Promise.race([
|
|
58
|
+
promise.finally(() => clearTimeout(timer)),
|
|
59
|
+
new Promise((resolve, reject) => {
|
|
60
|
+
timer = setTimeout(() => reject(new Error(`${what} did not settle within ${ms}ms`)), ms);
|
|
61
|
+
}),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function rejects(promise, what) {
|
|
66
|
+
try {
|
|
67
|
+
await promise;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
fail(`${what} resolved instead of rejecting`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checkUsage(response) {
|
|
76
|
+
if (response.usage === undefined) return;
|
|
77
|
+
for (const key of ['inputTokens', 'outputTokens']) {
|
|
78
|
+
const value = response.usage[key];
|
|
79
|
+
if (value !== undefined && value !== null && !(Number.isInteger(value) && value >= 0)) {
|
|
80
|
+
fail(`usage.${key} must be a whole number, null or absent, not ${JSON.stringify(value)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function checkExtractResponse(response, asked) {
|
|
86
|
+
if (!response || typeof response !== 'object') fail('extract did not return an object');
|
|
87
|
+
if (typeof response.model !== 'string' || !response.model) fail('the response does not name its model');
|
|
88
|
+
if (!response.values || typeof response.values !== 'object' || Array.isArray(response.values)) fail('"values" must be an object');
|
|
89
|
+
const extra = Object.keys(response.values).filter((path) => !asked.includes(path));
|
|
90
|
+
if (extra.length) fail(`returned values for fields it was not asked about: ${extra.join(', ')}`);
|
|
91
|
+
checkUsage(response);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function checkAnswer(id, answer, question) {
|
|
95
|
+
if (!answer || answer.type !== question.type) fail(`the answer to "${id}" must have type "${question.type}"`);
|
|
96
|
+
if (question.type === 'noul') {
|
|
97
|
+
if (!isProbability(answer.probability)) fail(`the answer to "${id}" needs a probability from 0 to 1`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!answer.probabilities || !Object.values(answer.probabilities).every(isProbability)) {
|
|
101
|
+
fail(`the answer to "${id}" needs probabilities from 0 to 1`);
|
|
102
|
+
}
|
|
103
|
+
if (!isProbability(answer.confidence)) fail(`the answer to "${id}" needs a confidence from 0 to 1`);
|
|
104
|
+
if (question.type === 'choice' && !Object.keys(question.options).includes(answer.choice)) {
|
|
105
|
+
fail(`the answer to "${id}" chose ${JSON.stringify(answer.choice)}, which is not one of its options`);
|
|
106
|
+
}
|
|
107
|
+
if (question.type === 'score' && !(Number.isFinite(answer.score) && answer.score >= 0 && answer.score <= question.levels.length - 1)) {
|
|
108
|
+
fail(`the answer to "${id}" needs a score between 0 and ${question.levels.length - 1}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Tools that count research rounds: a call made while no other call is pending,
|
|
114
|
+
* after one has finished, starts a round. Calls one turn makes together count
|
|
115
|
+
* once; a turn's calls made one after another count as separate rounds.
|
|
116
|
+
*/
|
|
117
|
+
function lookupTool({ onCall } = {}) {
|
|
118
|
+
const state = { calls: [], rounds: 0, pending: 0, settled: true };
|
|
119
|
+
async function callTool(name, args) {
|
|
120
|
+
if (state.pending === 0 && state.settled) {
|
|
121
|
+
state.rounds++;
|
|
122
|
+
state.settled = false;
|
|
123
|
+
}
|
|
124
|
+
state.pending++;
|
|
125
|
+
state.calls.push(name);
|
|
126
|
+
try {
|
|
127
|
+
if (onCall) onCall(state);
|
|
128
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
129
|
+
if (name !== LOOKUP.name) {
|
|
130
|
+
return { content: [{ type: 'text', text: `The tool "${name}" is not available.` }], isError: true };
|
|
131
|
+
}
|
|
132
|
+
const page = Number(args && args.page) || 1;
|
|
133
|
+
return { content: [{ type: 'text', text: `Page ${page} of 9: Ada was born in 1815. Call lookup with page ${page + 1} for more.` }] };
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
state.pending--;
|
|
137
|
+
state.settled = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { state, callTool };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check an engine type against the engine contract. Resolves to
|
|
145
|
+
* `{ ok, failures, results }`; `failures` names each failed check and why.
|
|
146
|
+
*/
|
|
147
|
+
async function checkEngine(definition, { name = 'conformance', options = {}, env = process.env, config = {}, timeout = 10000 } = {}) {
|
|
148
|
+
const results = [];
|
|
149
|
+
async function check(label, fn) {
|
|
150
|
+
try {
|
|
151
|
+
const outcome = await fn();
|
|
152
|
+
results.push({ check: label, ok: true, ...(outcome === 'skipped' && { skipped: true }) });
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
results.push({ check: label, ok: false, message: err && err.message });
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const report = () => ({
|
|
161
|
+
ok: results.every((result) => result.ok),
|
|
162
|
+
failures: results.filter((result) => !result.ok).map((result) => `${result.check}: ${result.message}`),
|
|
163
|
+
results,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const valid = await check('is a valid engine type definition', () => {
|
|
167
|
+
const problems = definitionProblems(definition);
|
|
168
|
+
if (problems.length) fail(problems.join('; '));
|
|
169
|
+
if (definition.apiVersion !== ENGINE_API_VERSION) {
|
|
170
|
+
fail(`it was built for engine API version ${definition.apiVersion}; this SDK checks version ${ENGINE_API_VERSION}`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
if (!valid) return report();
|
|
174
|
+
|
|
175
|
+
let engine;
|
|
176
|
+
const created = await check('creates an engine with the instance name and the contract\'s shape', async () => {
|
|
177
|
+
engine = await createEngine(definition, { name, options, env, config });
|
|
178
|
+
if (typeof engine.model !== 'string' || !engine.model) fail('the engine does not name its model');
|
|
179
|
+
if (engine.apiVersion !== undefined && engine.apiVersion !== ENGINE_API_VERSION) fail(`the engine declares apiVersion ${engine.apiVersion}`);
|
|
180
|
+
if (engine.kind === 'decision') {
|
|
181
|
+
if (typeof engine.decide !== 'function') fail('a decision engine needs a decide function');
|
|
182
|
+
if (!Number.isInteger(engine.maxOptions) || engine.maxOptions < 2) fail('a decision engine needs maxOptions of at least 2');
|
|
183
|
+
}
|
|
184
|
+
else if (typeof engine.extract !== 'function') {
|
|
185
|
+
fail('an agent engine needs an extract function');
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
if (!created) return report();
|
|
189
|
+
|
|
190
|
+
await check('respects the instance\'s options', async () => {
|
|
191
|
+
// A definition made without defineEngine may have no settings.
|
|
192
|
+
const hasModel = !!(definition.settings && definition.settings.model);
|
|
193
|
+
const other = await createEngine(definition, { name: `${name}-other`, options: { ...options, ...(hasModel && { model: 'conformance-model-1' }) }, env, config });
|
|
194
|
+
if (hasModel && other.model !== 'conformance-model-1') {
|
|
195
|
+
fail(`the model option was set to "conformance-model-1" but the engine reports "${other.model}"`);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (engine.kind === 'agent') {
|
|
200
|
+
await check('extract answers only the fields it was asked about', async () => {
|
|
201
|
+
const response = await within(timeout, engine.extract(extractRequest()), 'extract');
|
|
202
|
+
checkExtractResponse(response, FIELDS.map((field) => field.path));
|
|
203
|
+
});
|
|
204
|
+
await check('extract stops when its signal is aborted', async () => {
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
controller.abort();
|
|
207
|
+
await within(timeout, rejects(engine.extract(extractRequest({ signal: controller.signal })), 'extract with an aborted signal'), 'extract with an aborted signal');
|
|
208
|
+
});
|
|
209
|
+
await check('accepts lists only known document kinds', () => {
|
|
210
|
+
if (engine.accepts === undefined) return;
|
|
211
|
+
if (!Array.isArray(engine.accepts) || !engine.accepts.length) fail('accepts must be a non-empty list of "text" and "pdf"');
|
|
212
|
+
const unknown = engine.accepts.filter((kind) => !['text', 'pdf'].includes(kind));
|
|
213
|
+
if (unknown.length) fail(`accepts lists unknown kinds: ${unknown.join(', ')}`);
|
|
214
|
+
});
|
|
215
|
+
if (typeof engine.generate === 'function') {
|
|
216
|
+
await check('generate returns text, its model and a valid stopReason', async () => {
|
|
217
|
+
const response = await within(timeout, engine.generate({
|
|
218
|
+
instructions: 'Summarize the attached document in one sentence.',
|
|
219
|
+
attachments: [{ kind: 'text', text: 'Ada Lovelace lives in London and works on engines.', label: 'Document 1: notes.txt', textFormat: 'plain' }],
|
|
220
|
+
format: 'markdown',
|
|
221
|
+
maxTokens: 200,
|
|
222
|
+
}), 'generate');
|
|
223
|
+
if (!response || typeof response !== 'object') fail('generate did not return an object');
|
|
224
|
+
if (typeof response.text !== 'string') fail('the response has no text');
|
|
225
|
+
if (typeof response.model !== 'string' || !response.model) fail('the response does not name its model');
|
|
226
|
+
if (!['end', 'max_tokens'].includes(response.stopReason)) fail(`stopReason must be "end" or "max_tokens", not ${JSON.stringify(response.stopReason)}`);
|
|
227
|
+
checkUsage(response);
|
|
228
|
+
});
|
|
229
|
+
await check('generate stops when its signal is aborted', async () => {
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
controller.abort();
|
|
232
|
+
await within(timeout, rejects(engine.generate({ instructions: 'Summarize.', format: 'text', signal: controller.signal }), 'generate with an aborted signal'), 'generate with an aborted signal');
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
else if (engine.generate !== undefined) {
|
|
236
|
+
await check('generate is a function', () => fail('generate must be a function when present'));
|
|
237
|
+
}
|
|
238
|
+
if (engine.toolUse) {
|
|
239
|
+
await check('research reports its tool calls and stops at maxIterations', async () => {
|
|
240
|
+
const { state, callTool } = lookupTool();
|
|
241
|
+
const response = await within(timeout, engine.extract(extractRequest({ tools: [LOOKUP], callTool, maxIterations: 2 })), 'extract with tools');
|
|
242
|
+
checkExtractResponse(response, FIELDS.map((field) => field.path));
|
|
243
|
+
if (!state.calls.length) return 'skipped';
|
|
244
|
+
if (state.rounds > 2) fail(`it made ${state.rounds} rounds of tool calls with maxIterations 2`);
|
|
245
|
+
const reported = (response.toolCalls || []).map((call) => call.name);
|
|
246
|
+
if (reported.length !== state.calls.length || reported.some((call, i) => call !== state.calls[i])) {
|
|
247
|
+
fail(`it reported tool calls ${JSON.stringify(reported)} but made ${JSON.stringify(state.calls)}`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
await check('research stops calling tools when its signal is aborted', async () => {
|
|
251
|
+
const controller = new AbortController();
|
|
252
|
+
let callsAtAbort;
|
|
253
|
+
const { state, callTool } = lookupTool({
|
|
254
|
+
onCall: (current) => {
|
|
255
|
+
if (callsAtAbort === undefined) {
|
|
256
|
+
callsAtAbort = current.calls.length;
|
|
257
|
+
controller.abort();
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const outcome = await within(timeout, engine.extract(extractRequest({ tools: [LOOKUP], callTool, maxIterations: 5, signal: controller.signal }))
|
|
262
|
+
.then(() => 'resolved', () => 'rejected'), 'extract aborted during research');
|
|
263
|
+
if (callsAtAbort === undefined) return 'skipped';
|
|
264
|
+
if (outcome !== 'rejected') fail('extract resolved after its signal was aborted');
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
266
|
+
// Calls started in the same turn as the aborting one may still run.
|
|
267
|
+
if (state.rounds > 1) fail(`it started ${state.rounds - 1} more round(s) of tool calls after the abort`);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
await check('decide answers only the questions it was asked, in the contract\'s shapes', async () => {
|
|
273
|
+
const response = await within(timeout, engine.decide({ state: STATE, questions: QUESTIONS }), 'decide');
|
|
274
|
+
if (!response || typeof response !== 'object') fail('decide did not return an object');
|
|
275
|
+
if (typeof response.model !== 'string' || !response.model) fail('the response does not name its model');
|
|
276
|
+
const answers = response.answers || {};
|
|
277
|
+
const extra = Object.keys(answers).filter((id) => !QUESTIONS[id]);
|
|
278
|
+
if (extra.length) fail(`answered questions it was not asked: ${extra.join(', ')}`);
|
|
279
|
+
for (const [id, answer] of Object.entries(answers)) checkAnswer(id, answer, QUESTIONS[id]);
|
|
280
|
+
checkUsage(response);
|
|
281
|
+
});
|
|
282
|
+
await check('decide stops when its signal is aborted', async () => {
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
controller.abort();
|
|
285
|
+
await within(timeout, rejects(engine.decide({ state: STATE, questions: QUESTIONS, signal: controller.signal }), 'decide with an aborted signal'), 'decide with an aborted signal');
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return report();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { checkEngine, STATE, FIELDS, SCHEMA, QUESTIONS, LOOKUP };
|