@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/contract.d.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine contract for the Form.io Universal Agent Gateway (UAG): what a
|
|
3
|
+
* decision engine or an agent engine receives and returns. Engine types
|
|
4
|
+
* declare `apiVersion: ENGINE_API_VERSION`; the UAG refuses a type built for
|
|
5
|
+
* another version at startup. It matches what @formio/appserver-core accepts.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Engine contracts for the generic Agent API.
|
|
9
|
+
*
|
|
10
|
+
* Two kinds of model plug into the UAG. A decision engine answers closed-set
|
|
11
|
+
* questions — pick one option, place the input on an ordered scale, yes or no —
|
|
12
|
+
* and returns a probability for each answer. An agent engine produces open
|
|
13
|
+
* values, researching with read-only tools when a task allows. The two take different inputs and return
|
|
14
|
+
* different outputs, so they get separate contracts instead of one generic call.
|
|
15
|
+
*/
|
|
16
|
+
/** Pick one option from a set with no order between options. */
|
|
17
|
+
export type ChoiceQuestion = {
|
|
18
|
+
type: 'choice';
|
|
19
|
+
instructions: string;
|
|
20
|
+
/** Option label → optional description. The labels are what the model chooses between. */
|
|
21
|
+
options: Record<string, string | null>;
|
|
22
|
+
};
|
|
23
|
+
/** Place the state at a position on an ordered list of levels. */
|
|
24
|
+
export type ScoreQuestion = {
|
|
25
|
+
type: 'score';
|
|
26
|
+
instructions: string;
|
|
27
|
+
/** Levels in order, lowest first. */
|
|
28
|
+
levels: string[];
|
|
29
|
+
};
|
|
30
|
+
/** A yes/no question where the probability of "yes" is the useful signal. */
|
|
31
|
+
export type NoulQuestion = {
|
|
32
|
+
type: 'noul';
|
|
33
|
+
instructions: string;
|
|
34
|
+
criteria?: {
|
|
35
|
+
true?: string;
|
|
36
|
+
false?: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export type DecisionQuestion = ChoiceQuestion | ScoreQuestion | NoulQuestion;
|
|
40
|
+
export type ChoiceAnswer = {
|
|
41
|
+
type: 'choice';
|
|
42
|
+
choice: string;
|
|
43
|
+
/** Option label → probability. */
|
|
44
|
+
probabilities: Record<string, number>;
|
|
45
|
+
/** Normalized for the number of options: (p_max − 1/n) / (1 − 1/n). */
|
|
46
|
+
confidence: number;
|
|
47
|
+
};
|
|
48
|
+
export type ScoreAnswer = {
|
|
49
|
+
type: 'score';
|
|
50
|
+
/** Position along the levels; may fall between two of them. */
|
|
51
|
+
score: number;
|
|
52
|
+
/** Level index (as a string) → probability. */
|
|
53
|
+
probabilities: Record<string, number>;
|
|
54
|
+
confidence: number;
|
|
55
|
+
};
|
|
56
|
+
export type NoulAnswer = {
|
|
57
|
+
type: 'noul';
|
|
58
|
+
/** Probability of "yes", from 0 to 1. */
|
|
59
|
+
probability: number;
|
|
60
|
+
};
|
|
61
|
+
export type DecisionAnswer = ChoiceAnswer | ScoreAnswer | NoulAnswer;
|
|
62
|
+
/**
|
|
63
|
+
* What every engine reads, as a structured object with named fields that
|
|
64
|
+
* questions refer to by name ("Based on `input`, …").
|
|
65
|
+
*/
|
|
66
|
+
export type DecisionState = {
|
|
67
|
+
/** The task's criteria. */
|
|
68
|
+
instructions?: string;
|
|
69
|
+
/** The task's input values keyed by field label (the whole submission when it has no input). */
|
|
70
|
+
input: Record<string, any>;
|
|
71
|
+
/** Values at the paths the task lists in `uagContext`, keyed by field label. */
|
|
72
|
+
context?: Record<string, any>;
|
|
73
|
+
/** Today's date, e.g. "Tuesday, October 20, 2026 (2026-10-20)". */
|
|
74
|
+
today: string;
|
|
75
|
+
};
|
|
76
|
+
export type DecisionRequest = {
|
|
77
|
+
state: DecisionState;
|
|
78
|
+
questions: Record<string, DecisionQuestion>;
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
};
|
|
81
|
+
export type EngineUsage = {
|
|
82
|
+
inputTokens?: number | null;
|
|
83
|
+
outputTokens?: number | null;
|
|
84
|
+
};
|
|
85
|
+
export type DecisionResponse = {
|
|
86
|
+
model: string;
|
|
87
|
+
answers: Record<string, DecisionAnswer>;
|
|
88
|
+
usage?: EngineUsage;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The version of this engine contract. An engine may declare the version it was
|
|
92
|
+
* built for as `apiVersion`; one built for another version is refused at startup.
|
|
93
|
+
*/
|
|
94
|
+
export declare const ENGINE_API_VERSION: 1;
|
|
95
|
+
export interface DecisionEngine {
|
|
96
|
+
name: string;
|
|
97
|
+
kind: 'decision';
|
|
98
|
+
/** The engine contract version the engine was built for. */
|
|
99
|
+
apiVersion?: number;
|
|
100
|
+
model: string;
|
|
101
|
+
/** The most options a single Choice or Score question may carry. */
|
|
102
|
+
maxOptions: number;
|
|
103
|
+
decide(request: DecisionRequest): Promise<DecisionResponse>;
|
|
104
|
+
}
|
|
105
|
+
/** One field an agent engine is asked to produce a value for. */
|
|
106
|
+
export type FieldSpec = {
|
|
107
|
+
path: string;
|
|
108
|
+
label: string;
|
|
109
|
+
type: string;
|
|
110
|
+
description: string;
|
|
111
|
+
/** How the value must be written, from the same rules `get_field_info` gives agents. */
|
|
112
|
+
rule: string;
|
|
113
|
+
format?: string;
|
|
114
|
+
/** For closed-set fields routed to an agent engine: the allowed values. */
|
|
115
|
+
options?: {
|
|
116
|
+
label: string;
|
|
117
|
+
value: any;
|
|
118
|
+
}[];
|
|
119
|
+
multiple?: boolean;
|
|
120
|
+
};
|
|
121
|
+
export type ExtractRequest = {
|
|
122
|
+
instructions: string;
|
|
123
|
+
state: DecisionState;
|
|
124
|
+
fields: FieldSpec[];
|
|
125
|
+
/** JSON schema of the object to return, keyed by field path. */
|
|
126
|
+
schema: Record<string, any>;
|
|
127
|
+
signal?: AbortSignal;
|
|
128
|
+
/**
|
|
129
|
+
* Read-only tools the engine may call before answering, when the task enables
|
|
130
|
+
* research (`uagTools`). Only engines that declare `toolUse` receive them. The
|
|
131
|
+
* answer is still structured values for `fields`; tools never write.
|
|
132
|
+
*/
|
|
133
|
+
tools?: ToolDefinition[];
|
|
134
|
+
/**
|
|
135
|
+
* Call one of `tools`. Runs in process with the caller's identity, so Form.io
|
|
136
|
+
* permissions apply; names outside `tools` are refused. Set with `tools`.
|
|
137
|
+
*/
|
|
138
|
+
callTool?(name: string, args: Record<string, any>): Promise<ToolCallResult>;
|
|
139
|
+
/** The most tool-use round trips before the engine must answer. Set with `tools`. */
|
|
140
|
+
maxIterations?: number;
|
|
141
|
+
/**
|
|
142
|
+
* A scoped token carrying the caller's identity and exactly `tools`, all
|
|
143
|
+
* read-only, for engines that run the model outside the UAG and call the
|
|
144
|
+
* tools back over `/mcp`. In-process engines use `callTool` and ignore it.
|
|
145
|
+
*/
|
|
146
|
+
scopedToken?: string;
|
|
147
|
+
};
|
|
148
|
+
export type ExtractResponse = {
|
|
149
|
+
model: string;
|
|
150
|
+
/** Field path → value. Paths that were not asked for are discarded by the caller. */
|
|
151
|
+
values: Record<string, any>;
|
|
152
|
+
/** Tools the engine called while researching, in order. */
|
|
153
|
+
toolCalls?: {
|
|
154
|
+
name: string;
|
|
155
|
+
isError: boolean;
|
|
156
|
+
}[];
|
|
157
|
+
usage?: EngineUsage;
|
|
158
|
+
};
|
|
159
|
+
/** A tool as an agent engine sees it: plain JSON, ready for any model API. */
|
|
160
|
+
export type ToolDefinition = {
|
|
161
|
+
name: string;
|
|
162
|
+
description: string;
|
|
163
|
+
/** JSON schema of the tool's arguments. */
|
|
164
|
+
inputSchema: Record<string, any>;
|
|
165
|
+
};
|
|
166
|
+
/** A tool call's outcome, in MCP content form. */
|
|
167
|
+
export type ToolCallResult = {
|
|
168
|
+
content: {
|
|
169
|
+
type: string;
|
|
170
|
+
text?: string;
|
|
171
|
+
[key: string]: any;
|
|
172
|
+
}[];
|
|
173
|
+
isError?: boolean;
|
|
174
|
+
};
|
|
175
|
+
export interface AgentEngine {
|
|
176
|
+
name: string;
|
|
177
|
+
kind: 'agent';
|
|
178
|
+
/** The engine contract version the engine was built for. */
|
|
179
|
+
apiVersion?: number;
|
|
180
|
+
model: string;
|
|
181
|
+
/**
|
|
182
|
+
* Whether `extract` accepts read-only tools for research. Engines without it
|
|
183
|
+
* are passed over for field groups whose task enables tools.
|
|
184
|
+
*/
|
|
185
|
+
toolUse?: boolean;
|
|
186
|
+
/** Structured extraction that returns values for the requested fields only. */
|
|
187
|
+
extract(request: ExtractRequest): Promise<ExtractResponse>;
|
|
188
|
+
/**
|
|
189
|
+
* The document representations `generate` reads natively. Default ['text'].
|
|
190
|
+
* An engine lists 'pdf' only when it sends PDFs to its model as documents.
|
|
191
|
+
*/
|
|
192
|
+
accepts?: Array<'text' | 'pdf'>;
|
|
193
|
+
/** Free-text generation over attached documents, for document plugins. Optional. */
|
|
194
|
+
generate?(request: GenerateRequest): Promise<GenerateResponse>;
|
|
195
|
+
}
|
|
196
|
+
export type Engine = DecisionEngine | AgentEngine;
|
|
197
|
+
|
|
198
|
+
/** A document an engine reads: PDF bytes, or text with its format. */
|
|
199
|
+
export type Attachment =
|
|
200
|
+
| { kind: 'pdf'; data: Buffer; label: string; pages?: number }
|
|
201
|
+
| { kind: 'text'; text: string; label: string; textFormat?: 'plain' | 'markdown' | 'csv' };
|
|
202
|
+
|
|
203
|
+
export type GenerateRequest = {
|
|
204
|
+
/** The user prompt. */
|
|
205
|
+
instructions: string;
|
|
206
|
+
system?: string;
|
|
207
|
+
/** Sent before the instructions, in order. */
|
|
208
|
+
attachments?: Attachment[];
|
|
209
|
+
format: 'markdown' | 'text';
|
|
210
|
+
/** Default: the engine's own output limit setting. */
|
|
211
|
+
maxTokens?: number;
|
|
212
|
+
signal?: AbortSignal;
|
|
213
|
+
/** Called as the answer streams, with the characters produced so far. */
|
|
214
|
+
onProgress?(outputChars: number): void;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type GenerateResponse = {
|
|
218
|
+
text: string;
|
|
219
|
+
model: string;
|
|
220
|
+
/** 'max_tokens': the answer was cut off at the output limit. */
|
|
221
|
+
stopReason: 'end' | 'max_tokens';
|
|
222
|
+
usage?: EngineUsage;
|
|
223
|
+
};
|
package/define.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** The engine contract version this SDK describes. */
|
|
4
|
+
const ENGINE_API_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/** Engine type and instance names: what `UAG_ENGINES` and routing lists use. */
|
|
7
|
+
const ENGINE_NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
8
|
+
|
|
9
|
+
/** The plugin type of every engine definition. */
|
|
10
|
+
const ENGINE_PLUGIN_TYPE = 'engine';
|
|
11
|
+
|
|
12
|
+
/** The shape an engine definition takes, shown when one is in another shape. */
|
|
13
|
+
function definitionShape(name = 'acme') {
|
|
14
|
+
return `{ type: 'engine', name: '${name}', title: '${name[0].toUpperCase()}${name.slice(1)}', version: '1.0.0', kinds, apiVersion, settings, create }`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const KINDS = ['decision', 'agent'];
|
|
18
|
+
const SETTING_NAME = /^[a-z][a-zA-Z0-9]*$/;
|
|
19
|
+
const VARIABLE_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
20
|
+
const SETTING_TYPES = ['string', 'number', 'boolean'];
|
|
21
|
+
const SETTING_KEYS = ['env', 'required', 'secret', 'default', 'type', 'description'];
|
|
22
|
+
|
|
23
|
+
/** The reasons a value is not a valid engine type definition; empty when it is. */
|
|
24
|
+
function definitionProblems(definition) {
|
|
25
|
+
if (!definition || typeof definition !== 'object') {
|
|
26
|
+
return ['it is not an object'];
|
|
27
|
+
}
|
|
28
|
+
const problems = [];
|
|
29
|
+
if (definition.type !== ENGINE_PLUGIN_TYPE) {
|
|
30
|
+
// The 2.0 release candidates put the engine's name in "type". The shape was
|
|
31
|
+
// never in a final release, so it is refused rather than converted.
|
|
32
|
+
if (typeof definition.type === 'string' && definition.name === undefined) {
|
|
33
|
+
return [`it is in the shape of the 2.0 release candidates, { type: ${JSON.stringify(definition.type)}, kinds, ... }; ` +
|
|
34
|
+
`an engine definition is now a plugin: ${definitionShape(ENGINE_NAME.test(definition.type) ? definition.type : 'acme')}`];
|
|
35
|
+
}
|
|
36
|
+
problems.push(`"type" must be "${ENGINE_PLUGIN_TYPE}"`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof definition.name !== 'string' || !ENGINE_NAME.test(definition.name)) {
|
|
39
|
+
problems.push('"name" must be a name of lowercase letters, digits and "-", starting with a letter');
|
|
40
|
+
}
|
|
41
|
+
if (definition.title !== undefined && (typeof definition.title !== 'string' || !definition.title)) {
|
|
42
|
+
problems.push('"title" must be a non-empty string');
|
|
43
|
+
}
|
|
44
|
+
if (definition.version !== undefined && (typeof definition.version !== 'string' || !/^\d+\.\d+\.\d+/.test(definition.version))) {
|
|
45
|
+
problems.push('"version" must be a semver string such as "1.0.0"');
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(definition.kinds) || !definition.kinds.length || !definition.kinds.every((kind) => KINDS.includes(kind))) {
|
|
48
|
+
problems.push('"kinds" must list "decision", "agent" or both');
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isInteger(definition.apiVersion) || definition.apiVersion < 1) {
|
|
51
|
+
problems.push('"apiVersion" must be the engine contract version it was built for');
|
|
52
|
+
}
|
|
53
|
+
if (typeof definition.create !== 'function') {
|
|
54
|
+
problems.push('"create" must be a function');
|
|
55
|
+
}
|
|
56
|
+
const settings = definition.settings === undefined ? {} : definition.settings;
|
|
57
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
58
|
+
problems.push('"settings" must be an object');
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
for (const [key, spec] of Object.entries(settings)) {
|
|
62
|
+
problems.push(...settingProblems(key, spec));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return problems;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function settingProblems(key, spec) {
|
|
69
|
+
const label = `setting "${key}"`;
|
|
70
|
+
if (!SETTING_NAME.test(key)) {
|
|
71
|
+
return [`${label} must be a camelCase name`];
|
|
72
|
+
}
|
|
73
|
+
if (!spec || typeof spec !== 'object') {
|
|
74
|
+
return [`${label} must be an object`];
|
|
75
|
+
}
|
|
76
|
+
const problems = [];
|
|
77
|
+
const unknown = Object.keys(spec).filter((name) => !SETTING_KEYS.includes(name));
|
|
78
|
+
if (unknown.length) {
|
|
79
|
+
problems.push(`${label} has unknown keys: ${unknown.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
const env = spec.env === undefined ? [] : [].concat(spec.env);
|
|
82
|
+
if (!env.every((name) => typeof name === 'string' && VARIABLE_NAME.test(name))) {
|
|
83
|
+
problems.push(`${label}: "env" must name upper-case environment variables`);
|
|
84
|
+
}
|
|
85
|
+
if (spec.type !== undefined && !SETTING_TYPES.includes(spec.type)) {
|
|
86
|
+
problems.push(`${label}: "type" must be one of ${SETTING_TYPES.join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
for (const flag of ['required', 'secret']) {
|
|
89
|
+
if (spec[flag] !== undefined && typeof spec[flag] !== 'boolean') {
|
|
90
|
+
problems.push(`${label}: "${flag}" must be true or false`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (spec.default !== undefined && typeof spec.default !== (spec.type || 'string')) {
|
|
94
|
+
problems.push(`${label}: "default" must be a ${spec.type || 'string'}`);
|
|
95
|
+
}
|
|
96
|
+
return problems;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether a value is a valid engine type definition, from this SDK or any other copy of it. */
|
|
100
|
+
function isEngineType(value) {
|
|
101
|
+
return definitionProblems(value).length === 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Define an engine type. Validates the definition and returns it frozen, so a
|
|
106
|
+
* mistake in it fails when the package loads rather than when the UAG starts:
|
|
107
|
+
*
|
|
108
|
+
* module.exports = defineEngine({
|
|
109
|
+
* type: 'engine',
|
|
110
|
+
* name: 'acme',
|
|
111
|
+
* title: 'Acme',
|
|
112
|
+
* version: '1.0.0', // optional: an engine package's own version is used
|
|
113
|
+
* kinds: ['agent'],
|
|
114
|
+
* apiVersion: ENGINE_API_VERSION,
|
|
115
|
+
* settings: { apiKey: { env: 'ACME_API_KEY', required: true, secret: true } },
|
|
116
|
+
* create({ name, settings }) { return { name, kind: 'agent', model: '...', extract }; },
|
|
117
|
+
* });
|
|
118
|
+
*/
|
|
119
|
+
function defineEngine(definition) {
|
|
120
|
+
const problems = definitionProblems(definition);
|
|
121
|
+
if (problems.length) {
|
|
122
|
+
const name = definition && typeof definition.name === 'string' ? definition.name : definition && typeof definition.type === 'string' && definition.type !== ENGINE_PLUGIN_TYPE ? definition.type : '';
|
|
123
|
+
throw new TypeError(`The engine type${name ? ` "${name}"` : ''} is not valid: ${problems.join('; ')}.`);
|
|
124
|
+
}
|
|
125
|
+
const settings = {};
|
|
126
|
+
for (const [key, spec] of Object.entries(definition.settings || {})) {
|
|
127
|
+
settings[key] = Object.freeze({ ...spec, ...(spec.env !== undefined && { env: Object.freeze([].concat(spec.env)) }) });
|
|
128
|
+
}
|
|
129
|
+
return Object.freeze({ ...definition, title: definition.title || definition.name, kinds: Object.freeze([...definition.kinds]), settings: Object.freeze(settings) });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A valid definition with its defaults: `title` is its name, and `version` the
|
|
134
|
+
* version of the package it was found in, when it does not give its own.
|
|
135
|
+
*/
|
|
136
|
+
function withDefaults(definition, packageVersion) {
|
|
137
|
+
if (definition.title && (definition.version || !packageVersion)) return definition;
|
|
138
|
+
const version = definition.version || packageVersion;
|
|
139
|
+
return { ...definition, title: definition.title || definition.name, ...(version && { version }) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { ENGINE_API_VERSION, ENGINE_NAME, ENGINE_PLUGIN_TYPE, KINDS, defineEngine, definitionProblems, definitionShape, isEngineType, withDefaults };
|
package/errors.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An engine failure whose message is safe to show to API callers, such as
|
|
5
|
+
* "Quota exceeded for this API key". The UAG reports any other engine error only
|
|
6
|
+
* as "the engine failed (see the logs for request X)", because a provider SDK's
|
|
7
|
+
* error can carry URLs or account details.
|
|
8
|
+
*/
|
|
9
|
+
class EngineError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'EngineError';
|
|
13
|
+
Object.defineProperty(this, 'expose', { value: true, enumerable: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Thrown when an engine instance cannot be enabled. The message names the
|
|
19
|
+
* instance, and is what the operator reads when the UAG refuses to start.
|
|
20
|
+
*/
|
|
21
|
+
class EngineConfigError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'EngineConfigError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shorter values are too likely to occur by chance to be worth redacting. */
|
|
29
|
+
const MIN_SECRET_LENGTH = 4;
|
|
30
|
+
|
|
31
|
+
/** `text` with every occurrence of each secret value replaced. */
|
|
32
|
+
function redact(text, secrets) {
|
|
33
|
+
let result = String(text);
|
|
34
|
+
for (const secret of secrets || []) {
|
|
35
|
+
const value = secret === undefined || secret === null ? '' : String(secret);
|
|
36
|
+
if (value.length >= MIN_SECRET_LENGTH) {
|
|
37
|
+
result = result.split(value).join('[redacted]');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { EngineError, EngineConfigError, redact };
|
package/helpers.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { EngineError } = require('./errors');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helpers shared by agent engines built on model APIs with structured outputs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The extraction schema with every field required, for strict structured
|
|
10
|
+
* outputs: providers limit how many optional properties and union types a
|
|
11
|
+
* strict schema may have, and OpenAI's strict mode needs every property in
|
|
12
|
+
* `required`. An empty value stands for "not stated": "" for text and options,
|
|
13
|
+
* [] for lists. Numbers and booleans travel as strings, keeping the field's
|
|
14
|
+
* title and description. `decode` maps an answer
|
|
15
|
+
* back to the UAG's schema, leaving the "not stated" fields out.
|
|
16
|
+
*
|
|
17
|
+
* const { schema, decode } = requiredSchema(request.schema);
|
|
18
|
+
* // ... ask the model for `schema` ...
|
|
19
|
+
* return { model, values: decode(answer) };
|
|
20
|
+
*/
|
|
21
|
+
function requiredSchema(schema) {
|
|
22
|
+
const { schema: sent, decoders } = encodeObject(schema, false);
|
|
23
|
+
return { schema: sent, decode: (values) => decodeValues(values, decoders) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function encodeObject(schema, nested) {
|
|
27
|
+
const properties = {};
|
|
28
|
+
const decoders = {};
|
|
29
|
+
for (const [path, property] of Object.entries((schema && schema.properties) || {})) {
|
|
30
|
+
const { sent, decode } = encodeProperty(property, nested);
|
|
31
|
+
properties[path] = sent;
|
|
32
|
+
if (decode) decoders[path] = decode;
|
|
33
|
+
}
|
|
34
|
+
return { schema: { ...schema, properties, required: Object.keys(properties) }, decoders };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encodeProperty(property, nested) {
|
|
38
|
+
// Before the number and boolean cases, so a typed enum keeps its values.
|
|
39
|
+
if (Array.isArray(property.enum)) {
|
|
40
|
+
if (property.enum.every((value) => typeof value === 'string')) {
|
|
41
|
+
return { sent: { ...property, enum: [...new Set([...property.enum, ''])] } };
|
|
42
|
+
}
|
|
43
|
+
// Other values travel as their JSON text. A provider that does not
|
|
44
|
+
// enforce the schema may answer anything: only an allowed value is kept.
|
|
45
|
+
const { type, ...rest } = property;
|
|
46
|
+
const allowed = new Map(property.enum.map((value) => [JSON.stringify(value), value]));
|
|
47
|
+
return {
|
|
48
|
+
sent: { ...rest, type: 'string', enum: [...allowed.keys(), ''] },
|
|
49
|
+
decode: (value) => (allowed.has(value) ? allowed.get(value) : undefined),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (property.type === 'number' || property.type === 'integer') {
|
|
53
|
+
return {
|
|
54
|
+
sent: { type: 'string', ...described(property, NUMBER_HINT) },
|
|
55
|
+
decode: parseNumber,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Inside an object (select boxes) false already means "not selected".
|
|
59
|
+
if (property.type === 'boolean' && !nested) {
|
|
60
|
+
return {
|
|
61
|
+
sent: { enum: ['true', 'false', ''], ...described(property) },
|
|
62
|
+
decode: (value) => (value === 'true' || value === true ? true : value === 'false' || value === false ? false : undefined),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (property.type === 'object' && property.properties) {
|
|
66
|
+
const inner = encodeObject(property, true);
|
|
67
|
+
return {
|
|
68
|
+
sent: inner.schema,
|
|
69
|
+
decode: (value) => {
|
|
70
|
+
const decoded = decodeValues(value, inner.decoders);
|
|
71
|
+
return Object.keys(decoded).length ? decoded : undefined;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { sent: property };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const NUMBER_HINT = 'Answer in digits only, such as 4250 or -3.5, with no currency or units.';
|
|
79
|
+
|
|
80
|
+
/** The field's title and description, which a string encoding would otherwise drop, with a hint. */
|
|
81
|
+
function described(property, hint) {
|
|
82
|
+
const description = [property.description, hint].filter(Boolean).join(' ');
|
|
83
|
+
return { ...(property.title && { title: property.title }), ...(description && { description }) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A number from a model's answer. Models write amounts as they read them
|
|
88
|
+
* ("$4,250"), so a currency sign and thousands separators are accepted;
|
|
89
|
+
* anything else that is not a plain number is "not stated".
|
|
90
|
+
*/
|
|
91
|
+
function parseNumber(value) {
|
|
92
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
93
|
+
if (typeof value !== 'string') return undefined;
|
|
94
|
+
const match = /^\s*([+-])?\s*[$€£¥]?\s*(\d[\d,]*(?:\.\d+)?|\.\d+)\s*[$€£¥]?\s*$/.exec(value);
|
|
95
|
+
if (!match) return undefined;
|
|
96
|
+
const digits = match[2];
|
|
97
|
+
if (digits.includes(',') && !/^\d{1,3}(,\d{3})+(\.\d+)?$/.test(digits)) return undefined;
|
|
98
|
+
const number = Number(`${match[1] || ''}${digits.replace(/,/g, '')}`);
|
|
99
|
+
return Number.isFinite(number) ? number : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The answer without the empty "not stated" values, in the UAG's types. */
|
|
103
|
+
function decodeValues(values, decoders) {
|
|
104
|
+
const decoded = {};
|
|
105
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) return decoded;
|
|
106
|
+
for (const [path, value] of Object.entries(values)) {
|
|
107
|
+
if (value === '' || value === null || (Array.isArray(value) && !value.length)) continue;
|
|
108
|
+
const result = decoders[path] ? decoders[path](value) : value;
|
|
109
|
+
if (result !== undefined) decoded[path] = result;
|
|
110
|
+
}
|
|
111
|
+
return decoded;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The first JSON object in a model's text answer, tolerating code fences and
|
|
116
|
+
* prose around it. Throws an `EngineError` when there is none.
|
|
117
|
+
*/
|
|
118
|
+
function parseJsonObject(text) {
|
|
119
|
+
const unfenced = String(text || '').replace(/```(?:json)?/gi, '');
|
|
120
|
+
const start = unfenced.indexOf('{');
|
|
121
|
+
const end = unfenced.lastIndexOf('}');
|
|
122
|
+
let parsed;
|
|
123
|
+
if (start !== -1 && end > start) {
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(unfenced.slice(start, end + 1));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
parsed = undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
132
|
+
throw new EngineError('The engine did not return a JSON object.');
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A tool call's result as text for the model: its text blocks, other blocks as JSON. */
|
|
138
|
+
function toolResultText(result) {
|
|
139
|
+
const text = ((result && result.content) || [])
|
|
140
|
+
.map((block) => (block && typeof block.text === 'string' ? block.text : JSON.stringify(block)))
|
|
141
|
+
.join('\n');
|
|
142
|
+
return text || '(no output)';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { requiredSchema, decodeValues, parseJsonObject, toolResultText };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Engine } from './contract';
|
|
2
|
+
|
|
3
|
+
export * from './contract';
|
|
4
|
+
|
|
5
|
+
/** Engine type and instance names: lowercase letters, digits and "-", starting with a letter. */
|
|
6
|
+
export declare const ENGINE_NAME: RegExp;
|
|
7
|
+
|
|
8
|
+
/** One setting an engine type reads. */
|
|
9
|
+
export type SettingSpec = {
|
|
10
|
+
/** The type's own environment variables for the setting, read after `UAG_ENGINE_<NAME>_<SETTING>`. */
|
|
11
|
+
env?: string | readonly string[];
|
|
12
|
+
/** Startup fails, naming the instance and the variables, when no value is set. */
|
|
13
|
+
required?: boolean;
|
|
14
|
+
/** Kept out of startup errors and capability discovery. */
|
|
15
|
+
secret?: boolean;
|
|
16
|
+
default?: string | number | boolean;
|
|
17
|
+
/** Values from the environment are converted to this type. Default: "string". */
|
|
18
|
+
type?: 'string' | 'number' | 'boolean';
|
|
19
|
+
description?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** What an engine type's `create` receives for one instance. */
|
|
23
|
+
export type EngineCreateContext<Settings = Record<string, any>> = {
|
|
24
|
+
/** The instance name. The engine must use it as its `name`. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** The instance's value for each declared setting that has one. */
|
|
27
|
+
settings: Settings;
|
|
28
|
+
/** The instance's options as given in configuration, including ones that are not settings (such as a test client). */
|
|
29
|
+
options: Record<string, any>;
|
|
30
|
+
/** The host's configuration, read-only (for example routing timeouts). */
|
|
31
|
+
config: Record<string, any>;
|
|
32
|
+
/** A setting's value, or the standard "needs its setting" error, for settings required only in some cases. */
|
|
33
|
+
requireSetting(key: string): any;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What an engine package exports: a plugin of type "engine". The 2.0 release
|
|
38
|
+
* candidates' shape, with the engine's name in `type`, is refused.
|
|
39
|
+
*/
|
|
40
|
+
export interface EngineTypeDefinition<Settings = Record<string, any>> {
|
|
41
|
+
/** Always "engine": the plugin type. */
|
|
42
|
+
type: 'engine';
|
|
43
|
+
/** The type name, as in `UAG_ENGINES=<type>` and `<name>:<type>`. */
|
|
44
|
+
name: string;
|
|
45
|
+
/** A human-readable name. Defaults to `name`. */
|
|
46
|
+
title?: string;
|
|
47
|
+
/** The engine's own version (semver). Defaults to the version in its package's package.json. */
|
|
48
|
+
version?: string;
|
|
49
|
+
kinds: readonly ('decision' | 'agent')[];
|
|
50
|
+
/** The engine contract version the type was built for: `ENGINE_API_VERSION`. */
|
|
51
|
+
apiVersion: number;
|
|
52
|
+
description?: string;
|
|
53
|
+
settings?: Record<string, SettingSpec>;
|
|
54
|
+
create(context: EngineCreateContext<Settings>): Engine | Promise<Engine>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Validate an engine type definition and return it frozen, its title defaulting to its name. Throws a TypeError naming every problem. */
|
|
58
|
+
export declare function defineEngine<T extends EngineTypeDefinition<any>>(definition: T): Readonly<T>;
|
|
59
|
+
|
|
60
|
+
/** Whether a value is a valid engine type definition, from this SDK, another copy of it, or none. */
|
|
61
|
+
export declare function isEngineType(value: unknown): value is EngineTypeDefinition;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An engine failure whose message is safe to show to API callers. Other engine
|
|
65
|
+
* errors are reported only as "the engine failed (see the logs for request X)".
|
|
66
|
+
*/
|
|
67
|
+
export declare class EngineError extends Error {
|
|
68
|
+
readonly expose: true;
|
|
69
|
+
constructor(message: string);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An engine instance that cannot be enabled. The message names the instance. */
|
|
73
|
+
export declare class EngineConfigError extends Error {
|
|
74
|
+
constructor(message: string);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `text` with each secret value replaced by "[redacted]". */
|
|
78
|
+
export declare function redact(text: unknown, secrets: unknown[]): string;
|
|
79
|
+
|
|
80
|
+
/** An engine instance to enable. `name` is undefined until loading when only a package or definition was given. */
|
|
81
|
+
export type EngineInstanceSpec = {
|
|
82
|
+
name?: string;
|
|
83
|
+
type: string | EngineTypeDefinition;
|
|
84
|
+
options?: Record<string, any>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Parse a `UAG_ENGINES` string (`jev,fast:claude`) or a configuration list. Nothing named means none. */
|
|
88
|
+
export declare function parseInstances(source: string | (string | EngineInstanceSpec)[] | undefined | null): EngineInstanceSpec[];
|
|
89
|
+
|
|
90
|
+
/** The instances from configuration (`engines`) when it lists them, otherwise from `UAG_ENGINES`. */
|
|
91
|
+
export declare function engineInstances(config?: Record<string, any>, env?: Record<string, string | undefined>): EngineInstanceSpec[];
|
|
92
|
+
|
|
93
|
+
/** An instance's own variable for a setting: `UAG_ENGINE_<NAME>_<SETTING>`. */
|
|
94
|
+
export declare function settingVariable(instance: string, setting: string): string;
|
|
95
|
+
|
|
96
|
+
/** An instance's settings: options, then `UAG_ENGINE_<NAME>_<SETTING>`, then the type's variables, then the default. */
|
|
97
|
+
export declare function resolveSettings(definition: EngineTypeDefinition, name: string, options?: Record<string, any>, env?: Record<string, string | undefined>): Record<string, any>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The extraction schema with every field required, for strict structured
|
|
101
|
+
* outputs, and `decode`, which maps an answer back to the UAG's schema.
|
|
102
|
+
*/
|
|
103
|
+
export declare function requiredSchema(schema: Record<string, any>): { schema: Record<string, any>; decode(values: unknown): Record<string, any> };
|
|
104
|
+
|
|
105
|
+
export declare function decodeValues(values: unknown, decoders: Record<string, (value: any) => any>): Record<string, any>;
|
|
106
|
+
|
|
107
|
+
/** The first JSON object in a model's text answer. Throws an `EngineError` when there is none. */
|
|
108
|
+
export declare function parseJsonObject(text: string): Record<string, any>;
|
|
109
|
+
|
|
110
|
+
/** A tool call's result as text for a model. */
|
|
111
|
+
export declare function toolResultText(result: { content?: { type: string; text?: string; [key: string]: any }[] }): string;
|