@formio/uag-sdk 2.0.0-rc.6 → 2.0.0-rc.8

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @formio/uag-sdk
2
2
 
3
- <sub>[Form.io UAG](https://github.com/formio/uag/blob/main/README.md) &rsaquo; [Engines](https://github.com/formio/uag/blob/main/README.md#engines-and-routing) &rsaquo; this page</sub>
3
+ <sub>[Form.io UAG](https://github.com/formio/uag/blob/v2.0.0-rc.8/README.md) &rsaquo; [Engines](https://github.com/formio/uag/blob/v2.0.0-rc.8/README.md#engines-and-routing) &rsaquo; this page</sub>
4
4
 
5
5
  Build engine packages for the Form.io Universal Agent Gateway (UAG). This package contains:
6
6
 
@@ -15,13 +15,15 @@ An engine package depends on this package only. It never needs `@formio/uag`.
15
15
 
16
16
  ## An engine package
17
17
 
18
- Name the package `@formio/uag-<type>` (official engines) or `uag-engine-<type>` (anyone's). Export one engine type definition:
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
19
 
20
20
  ```js
21
21
  const { defineEngine, EngineError, ENGINE_API_VERSION, requiredSchema } = require('@formio/uag-sdk');
22
22
 
23
23
  module.exports = defineEngine({
24
- type: 'acme',
24
+ type: 'engine',
25
+ name: 'acme',
26
+ title: 'Acme',
25
27
  kinds: ['agent'],
26
28
  apiVersion: ENGINE_API_VERSION,
27
29
  settings: {
@@ -50,6 +52,23 @@ module.exports = defineEngine({
50
52
 
51
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.
52
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
+
53
72
  `create` receives `{ name, settings, options, config, requireSetting }`:
54
73
 
55
74
  - `settings`: the instance's value for each declared setting that has one.
@@ -135,8 +154,9 @@ The check creates an instance and calls it. Pass options, such as a fake client,
135
154
  - the definition and the engine have the contract's shape;
136
155
  - the engine uses the instance name and the `model` option;
137
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`;
138
158
  - research stops at `maxIterations`, and reports every tool call it made;
139
- - an aborted signal stops the engine, including during research.
159
+ - an aborted signal stops the engine, including during research and `generate`.
140
160
 
141
161
  A research round is the calls a model makes together; calls made one after another count as separate rounds.
142
162
 
package/conformance.js CHANGED
@@ -206,6 +206,35 @@ async function checkEngine(definition, { name = 'conformance', options = {}, env
206
206
  controller.abort();
207
207
  await within(timeout, rejects(engine.extract(extractRequest({ signal: controller.signal })), 'extract with an aborted signal'), 'extract with an aborted signal');
208
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
+ }
209
238
  if (engine.toolUse) {
210
239
  await check('research reports its tool calls and stops at maxIterations', async () => {
211
240
  const { state, callTool } = lookupTool();
package/contract.d.ts CHANGED
@@ -185,5 +185,39 @@ export interface AgentEngine {
185
185
  toolUse?: boolean;
186
186
  /** Structured extraction that returns values for the requested fields only. */
187
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>;
188
195
  }
189
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 CHANGED
@@ -6,6 +6,14 @@ const ENGINE_API_VERSION = 1;
6
6
  /** Engine type and instance names: what `UAG_ENGINES` and routing lists use. */
7
7
  const ENGINE_NAME = /^[a-z][a-z0-9-]{0,62}$/;
8
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
+
9
17
  const KINDS = ['decision', 'agent'];
10
18
  const SETTING_NAME = /^[a-z][a-zA-Z0-9]*$/;
11
19
  const VARIABLE_NAME = /^[A-Z][A-Z0-9_]*$/;
@@ -18,8 +26,23 @@ function definitionProblems(definition) {
18
26
  return ['it is not an object'];
19
27
  }
20
28
  const problems = [];
21
- if (typeof definition.type !== 'string' || !ENGINE_NAME.test(definition.type)) {
22
- problems.push('"type" must be a name of lowercase letters, digits and "-", starting with a letter');
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"');
23
46
  }
24
47
  if (!Array.isArray(definition.kinds) || !definition.kinds.length || !definition.kinds.every((kind) => KINDS.includes(kind))) {
25
48
  problems.push('"kinds" must list "decision", "agent" or both');
@@ -83,7 +106,10 @@ function isEngineType(value) {
83
106
  * mistake in it fails when the package loads rather than when the UAG starts:
84
107
  *
85
108
  * module.exports = defineEngine({
86
- * type: 'acme',
109
+ * type: 'engine',
110
+ * name: 'acme',
111
+ * title: 'Acme',
112
+ * version: '1.0.0', // optional: an engine package's own version is used
87
113
  * kinds: ['agent'],
88
114
  * apiVersion: ENGINE_API_VERSION,
89
115
  * settings: { apiKey: { env: 'ACME_API_KEY', required: true, secret: true } },
@@ -93,14 +119,24 @@ function isEngineType(value) {
93
119
  function defineEngine(definition) {
94
120
  const problems = definitionProblems(definition);
95
121
  if (problems.length) {
96
- const type = definition && typeof definition.type === 'string' ? ` "${definition.type}"` : '';
97
- throw new TypeError(`The engine type${type} is not valid: ${problems.join('; ')}.`);
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('; ')}.`);
98
124
  }
99
125
  const settings = {};
100
126
  for (const [key, spec] of Object.entries(definition.settings || {})) {
101
127
  settings[key] = Object.freeze({ ...spec, ...(spec.env !== undefined && { env: Object.freeze([].concat(spec.env)) }) });
102
128
  }
103
- return Object.freeze({ ...definition, kinds: Object.freeze([...definition.kinds]), settings: Object.freeze(settings) });
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 }) };
104
140
  }
105
141
 
106
- module.exports = { ENGINE_API_VERSION, ENGINE_NAME, KINDS, defineEngine, definitionProblems, isEngineType };
142
+ module.exports = { ENGINE_API_VERSION, ENGINE_NAME, ENGINE_PLUGIN_TYPE, KINDS, defineEngine, definitionProblems, definitionShape, isEngineType, withDefaults };
package/index.d.ts CHANGED
@@ -33,10 +33,19 @@ export type EngineCreateContext<Settings = Record<string, any>> = {
33
33
  requireSetting(key: string): any;
34
34
  };
35
35
 
36
- /** What an engine package exports. */
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
+ */
37
40
  export interface EngineTypeDefinition<Settings = Record<string, any>> {
41
+ /** Always "engine": the plugin type. */
42
+ type: 'engine';
38
43
  /** The type name, as in `UAG_ENGINES=<type>` and `<name>:<type>`. */
39
- type: string;
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;
40
49
  kinds: readonly ('decision' | 'agent')[];
41
50
  /** The engine contract version the type was built for: `ENGINE_API_VERSION`. */
42
51
  apiVersion: number;
@@ -45,7 +54,7 @@ export interface EngineTypeDefinition<Settings = Record<string, any>> {
45
54
  create(context: EngineCreateContext<Settings>): Engine | Promise<Engine>;
46
55
  }
47
56
 
48
- /** Validate an engine type definition and return it frozen. Throws a TypeError naming every problem. */
57
+ /** Validate an engine type definition and return it frozen, its title defaulting to its name. Throws a TypeError naming every problem. */
49
58
  export declare function defineEngine<T extends EngineTypeDefinition<any>>(definition: T): Readonly<T>;
50
59
 
51
60
  /** Whether a value is a valid engine type definition, from this SDK, another copy of it, or none. */
package/loader.d.ts CHANGED
@@ -15,7 +15,14 @@ export type LoadOptions = {
15
15
  /** An enabled engine and where it came from, ready for `Formflow.engineRegistry`. */
16
16
  export type LoadedEngine = {
17
17
  engine: Engine;
18
+ /** The engine type's name. */
18
19
  type: string;
20
+ /** The engine type's title. */
21
+ title?: string;
22
+ /** The kinds the engine type declares. */
23
+ kinds?: ('decision' | 'agent')[];
24
+ /** The engine type's own version, from its definition or its package. */
25
+ typeVersion?: string;
19
26
  package?: string;
20
27
  version?: string;
21
28
  };
package/loader.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const { ENGINE_API_VERSION, definitionProblems } = require('./define');
4
+ const { ENGINE_API_VERSION, definitionProblems, withDefaults } = require('./define');
5
5
  const { EngineConfigError, redact } = require('./errors');
6
6
  const { checkName, checkType, checkUnique, isPackageName } = require('./instances');
7
7
  const { missingSetting, resolveSettings, secretValues, settingVariable } = require('./settings');
@@ -87,19 +87,22 @@ function errorText(err) {
87
87
  return err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);
88
88
  }
89
89
 
90
- /** Check a definition, from this SDK, another copy of it, or none. */
91
- function checkDefinition(definition, source, apiVersion) {
90
+ /**
91
+ * Check a definition, from this SDK, another copy of it, or none, and return it
92
+ * with its defaults (`title`, and `version` from the package it came from).
93
+ */
94
+ function checkDefinition(definition, source, apiVersion, packageVersion) {
92
95
  const problems = definitionProblems(definition);
93
96
  if (problems.length) {
94
97
  throw new EngineConfigError(`${source} is not a UAG engine: it does not export a valid engine type definition (${problems.join('; ')}).`);
95
98
  }
96
99
  if (definition.apiVersion !== apiVersion) {
97
100
  throw new EngineConfigError(
98
- `${source} (engine type "${definition.type}") was built for engine API version ${definition.apiVersion}; ` +
101
+ `${source} (engine type "${definition.name}") was built for engine API version ${definition.apiVersion}; ` +
99
102
  `this server supports version ${apiVersion}.`
100
103
  );
101
104
  }
102
- return definition;
105
+ return withDefaults(definition, packageVersion);
103
106
  }
104
107
 
105
108
  /**
@@ -130,7 +133,7 @@ function resolveType(type, { types = {}, paths = [], apiVersion = ENGINE_API_VER
130
133
  }
131
134
  const definition = exported && exported.__esModule && exported.default ? exported.default : exported;
132
135
  return {
133
- definition: checkDefinition(definition, `The package "${candidate}"`, apiVersion),
136
+ definition: checkDefinition(definition, `The package "${candidate}"`, apiVersion, pkg.version),
134
137
  package: candidate,
135
138
  ...(pkg.version && { version: pkg.version })
136
139
  };
@@ -165,7 +168,7 @@ function checkVariables(resolved) {
165
168
  * Errors name the instance and its type, and never contain a secret setting.
166
169
  */
167
170
  async function createEngine(definition, { name, options = {}, env = process.env, config = {} }) {
168
- const label = `The "${name}" engine (type "${definition.type}")`;
171
+ const label = `The "${name}" engine (type "${definition.name}")`;
169
172
  const settings = resolveSettings(definition, name, options, env);
170
173
  const secrets = secretValues(definition, settings, options);
171
174
  let engine;
@@ -206,7 +209,7 @@ async function createEngine(definition, { name, options = {}, env = process.env,
206
209
  async function loadEngines(instances, { types = {}, paths = [], env = process.env, config = {}, apiVersion = ENGINE_API_VERSION } = {}) {
207
210
  const resolved = instances.map((instance) => {
208
211
  const found = resolveType(instance.type, { types, paths, apiVersion });
209
- const name = instance.name || found.definition.type;
212
+ const name = instance.name || found.definition.name;
210
213
  checkName(name);
211
214
  return { ...found, name, options: instance.options || {} };
212
215
  });
@@ -215,7 +218,7 @@ async function loadEngines(instances, { types = {}, paths = [], env = process.en
215
218
  const loaded = [];
216
219
  for (const { definition, name, options, package: pkg, version } of resolved) {
217
220
  const engine = await createEngine(definition, { name, options, env, config });
218
- loaded.push({ engine, type: definition.type, ...(pkg && { package: pkg }), ...(version && { version }) });
221
+ loaded.push({ engine, type: definition.name, title: definition.title, kinds: [...definition.kinds], ...(definition.version && { typeVersion: definition.version }), ...(pkg && { package: pkg }), ...(version && { version }) });
219
222
  }
220
223
  return loaded;
221
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formio/uag-sdk",
3
- "version": "2.0.0-rc.6",
3
+ "version": "2.0.0-rc.8",
4
4
  "description": "Build engine packages for the Form.io Universal Agent Gateway (UAG): the engine contract, defineEngine, settings and instance resolution, the engine loader, and a conformance check.",
5
5
  "keywords": [
6
6
  "formio",
package/protocol.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DecisionQuestion, DecisionResponse, DecisionState, ExtractResponse, FieldSpec, ToolDefinition } from './contract';
1
+ import type { Attachment, DecisionQuestion, DecisionResponse, DecisionState, ExtractResponse, FieldSpec, GenerateRequest, GenerateResponse, ToolDefinition } from './contract';
2
2
 
3
3
  /** The remote engine protocol version this SDK speaks. */
4
4
  export declare const PROTOCOL_VERSION: 1;
@@ -25,8 +25,20 @@ export type RemoteEngineDescription = {
25
25
  model: string;
26
26
  maxOptions?: number;
27
27
  toolUse?: boolean;
28
+ /** Agent engines: the document kinds generate reads. Default ['text']. */
29
+ accepts?: Array<'text' | 'pdf'>;
30
+ /** Agent engines: whether the engine generates free text. */
31
+ generate?: boolean;
28
32
  };
29
33
 
34
+ /** The most PDF bytes one generate request carries (32 MB). */
35
+ export declare const MAX_DOCUMENT_BYTES: number;
36
+
37
+ /** A generate request on the wire: PDFs as base64. */
38
+ export type WireAttachment =
39
+ | { kind: 'pdf'; data: string; label: string; pages?: number }
40
+ | { kind: 'text'; text: string; label: string; textFormat?: 'plain' | 'markdown' | 'csv' };
41
+
30
42
  export type RemoteExtractRequest = {
31
43
  instructions: string;
32
44
  state: DecisionState;
@@ -42,4 +54,8 @@ export declare function checkExtractRequest(body: unknown): RemoteExtractRequest
42
54
  export declare function checkDescription(body: unknown): RemoteEngineDescription[];
43
55
  export declare function checkDecisionResponse(body: unknown): DecisionResponse;
44
56
  export declare function checkExtractResponse(body: unknown): ExtractResponse;
57
+ export declare function checkGenerateRequest(body: unknown, maxBytes?: number): Omit<GenerateRequest, 'signal' | 'onProgress'>;
58
+ export declare function checkGenerateResponse(body: unknown): GenerateResponse;
59
+ export declare function encodeAttachments(attachments?: Attachment[]): WireAttachment[];
60
+ export declare function pdfByteLength(attachments?: Attachment[]): number;
45
61
  export declare function errorBody(message: string, expose?: boolean): { protocol: 1; error: string; expose: boolean };
package/protocol.js CHANGED
@@ -26,6 +26,14 @@ const SCOPED_TOKEN_HEADER = 'x-uag-scoped-token';
26
26
  const REQUEST_ID_HEADER = 'x-request-id';
27
27
  const REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
28
28
 
29
+ /**
30
+ * The most PDF bytes one generate request carries: the provider request cap
31
+ * the UAG's documents.maxTotalBytes stays within (32 MB).
32
+ */
33
+ const MAX_DOCUMENT_BYTES = 32 * 1024 * 1024;
34
+ const DOCUMENT_KINDS = ['text', 'pdf'];
35
+ const TEXT_FORMATS = ['plain', 'markdown', 'csv'];
36
+
29
37
  /** A request id that is safe to send and log: up to 128 letters, digits, `.`, `_`, `:` or `-`. Anything else is undefined. */
30
38
  function safeRequestId(value) {
31
39
  return typeof value === 'string' && REQUEST_ID.test(value) ? value : undefined;
@@ -102,16 +110,83 @@ function checkDescription(body) {
102
110
  if (engine.kind === 'decision') {
103
111
  check(Number.isInteger(engine.maxOptions) && engine.maxOptions >= 2, `The decision engine "${engine.name}" needs maxOptions.`);
104
112
  }
113
+ if (engine.kind === 'agent' && engine.accepts !== undefined) {
114
+ check(Array.isArray(engine.accepts) && engine.accepts.length && engine.accepts.every((kind) => DOCUMENT_KINDS.includes(kind)),
115
+ `The engine "${engine.name}" must list "text" and "pdf" only in "accepts".`);
116
+ }
105
117
  return {
106
118
  name: engine.name,
107
119
  kind: engine.kind,
108
120
  model: engine.model,
109
121
  ...(engine.kind === 'decision' && { maxOptions: engine.maxOptions }),
110
- ...(engine.kind === 'agent' && { toolUse: engine.toolUse === true }),
122
+ ...(engine.kind === 'agent' && {
123
+ toolUse: engine.toolUse === true,
124
+ accepts: engine.accepts || ['text'],
125
+ generate: engine.generate === true,
126
+ }),
111
127
  };
112
128
  });
113
129
  }
114
130
 
131
+ /**
132
+ * A generate request, as an engine server receives it. PDF attachments arrive
133
+ * as base64 and are returned as Buffers; their decoded total may not exceed
134
+ * `maxBytes`.
135
+ */
136
+ function checkGenerateRequest(body, maxBytes = MAX_DOCUMENT_BYTES) {
137
+ checkVersion(body);
138
+ check(typeof body.instructions === 'string', '"instructions" must be a string.');
139
+ check(body.system === undefined || typeof body.system === 'string', '"system" must be a string.');
140
+ check(['markdown', 'text'].includes(body.format), '"format" must be "markdown" or "text".');
141
+ check(body.maxTokens === undefined || (Number.isInteger(body.maxTokens) && body.maxTokens > 0), '"maxTokens" must be a positive whole number.');
142
+ check(body.attachments === undefined || Array.isArray(body.attachments), '"attachments" must be a list.');
143
+ let pdfBytes = 0;
144
+ const attachments = (body.attachments || []).map((attachment, i) => {
145
+ const where = `Attachment ${i + 1}`;
146
+ check(isObject(attachment) && typeof attachment.label === 'string', `${where} needs a label.`);
147
+ if (attachment.kind === 'pdf') {
148
+ check(typeof attachment.data === 'string', `${where} must carry its PDF as base64 "data".`);
149
+ check(attachment.pages === undefined || (Number.isInteger(attachment.pages) && attachment.pages >= 0), `${where} "pages" must be a whole number.`);
150
+ const data = Buffer.from(attachment.data, 'base64');
151
+ pdfBytes += data.length;
152
+ check(pdfBytes <= maxBytes, `The attached PDFs exceed ${maxBytes} bytes.`, 413);
153
+ return { kind: 'pdf', data, label: attachment.label, ...(attachment.pages !== undefined && { pages: attachment.pages }) };
154
+ }
155
+ check(attachment.kind === 'text' && typeof attachment.text === 'string', `${where} must be a "pdf" or "text" attachment.`);
156
+ check(attachment.textFormat === undefined || TEXT_FORMATS.includes(attachment.textFormat), `${where} "textFormat" must be plain, markdown or csv.`);
157
+ return { kind: 'text', text: attachment.text, label: attachment.label, ...(attachment.textFormat && { textFormat: attachment.textFormat }) };
158
+ });
159
+ return {
160
+ instructions: body.instructions,
161
+ ...(body.system !== undefined && { system: body.system }),
162
+ attachments,
163
+ format: body.format,
164
+ ...(body.maxTokens !== undefined && { maxTokens: body.maxTokens }),
165
+ };
166
+ }
167
+
168
+ /** Attachments for the wire: PDF bytes as base64. Never logged. */
169
+ function encodeAttachments(attachments = []) {
170
+ return attachments.map((attachment) => attachment.kind === 'pdf'
171
+ ? { kind: 'pdf', data: Buffer.from(attachment.data).toString('base64'), label: attachment.label, ...(attachment.pages !== undefined && { pages: attachment.pages }) }
172
+ : { kind: 'text', text: attachment.text, label: attachment.label, ...(attachment.textFormat && { textFormat: attachment.textFormat }) });
173
+ }
174
+
175
+ /** The decoded size of the PDFs among attachments. */
176
+ function pdfByteLength(attachments = []) {
177
+ return attachments.reduce((total, attachment) => total + (attachment.kind === 'pdf' ? Buffer.byteLength(attachment.data) : 0), 0);
178
+ }
179
+
180
+ /** A generate response, as the UAG receives it. */
181
+ function checkGenerateResponse(body) {
182
+ checkVersion(body, 'response');
183
+ check(typeof body.text === 'string', 'The response has no "text".');
184
+ check(typeof body.model === 'string', 'The response does not name its model.');
185
+ check(['end', 'max_tokens'].includes(body.stopReason), '"stopReason" must be "end" or "max_tokens".');
186
+ const usage = checkUsage(body.usage);
187
+ return { text: body.text, model: body.model, stopReason: body.stopReason, ...(usage && { usage }) };
188
+ }
189
+
115
190
  /** A token count: a whole number, or null when not reported. */
116
191
  function tokenCount(value, key) {
117
192
  if (value === undefined || value === null) return null;
@@ -185,5 +260,10 @@ module.exports = {
185
260
  checkDescription,
186
261
  checkDecisionResponse,
187
262
  checkExtractResponse,
263
+ checkGenerateRequest,
264
+ checkGenerateResponse,
265
+ encodeAttachments,
266
+ pdfByteLength,
267
+ MAX_DOCUMENT_BYTES,
188
268
  errorBody,
189
269
  };
package/settings.js CHANGED
@@ -81,7 +81,7 @@ function resolveSettings(definition, name, options = {}, env = process.env) {
81
81
  function missingSetting(definition, name, key) {
82
82
  const spec = (definition.settings || {})[key] || {};
83
83
  return new EngineConfigError(
84
- `The "${name}" engine (type "${definition.type}") needs its ${key} setting: set ${listNames(settingVariables(name, key, spec))}.`
84
+ `The "${name}" engine (type "${definition.name}") needs its ${key} setting: set ${listNames(settingVariables(name, key, spec))}.`
85
85
  );
86
86
  }
87
87