@formio/uag 1.13.2 → 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/README.md +237 -535
- package/bin/uag-eval.js +99 -0
- package/bin/uag.js +116 -0
- package/lib/UAGFormInterface.d.ts +72 -2
- package/lib/UAGFormInterface.js +226 -57
- package/lib/UAGProjectInterface.d.ts +45 -5
- package/lib/UAGProjectInterface.js +187 -36
- package/lib/actionLogs.d.ts +24 -0
- package/lib/actionLogs.js +149 -0
- package/lib/actions/runTask.d.ts +43 -0
- package/lib/actions/runTask.js +324 -0
- package/lib/agent/anonymous.d.ts +34 -0
- package/lib/agent/anonymous.js +99 -0
- package/lib/agent/chat.d.ts +114 -0
- package/lib/agent/chat.js +475 -0
- package/lib/agent/engines.d.ts +17 -0
- package/lib/agent/engines.js +46 -0
- package/lib/agent/errors.d.ts +25 -0
- package/lib/agent/errors.js +132 -0
- package/lib/agent/index.d.ts +4 -0
- package/lib/agent/index.js +19 -0
- package/lib/agent/remote.d.ts +71 -0
- package/lib/agent/remote.js +252 -0
- package/lib/agent/requestId.d.ts +4 -0
- package/lib/agent/requestId.js +19 -0
- package/lib/agent/router.d.ts +61 -0
- package/lib/agent/router.js +307 -0
- package/lib/agent/stubs.d.ts +37 -0
- package/lib/agent/stubs.js +39 -0
- package/lib/agent/tools.d.ts +31 -0
- package/lib/agent/tools.js +76 -0
- package/lib/config.d.ts +97 -3
- package/lib/config.js +19 -0
- package/lib/database.d.ts +70 -0
- package/lib/database.js +212 -0
- package/lib/documents/convert.d.ts +38 -0
- package/lib/documents/convert.js +151 -0
- package/lib/documents/converters.d.ts +28 -0
- package/lib/documents/converters.js +149 -0
- package/lib/documents/detect.d.ts +31 -0
- package/lib/documents/detect.js +169 -0
- package/lib/documents/index.d.ts +9 -0
- package/lib/documents/index.js +43 -0
- package/lib/documents/mime.d.ts +39 -0
- package/lib/documents/mime.js +100 -0
- package/lib/documents/plugins.d.ts +18 -0
- package/lib/documents/plugins.js +65 -0
- package/lib/documents/readers.d.ts +19 -0
- package/lib/documents/readers.js +64 -0
- package/lib/documents/routes.d.ts +52 -0
- package/lib/documents/routes.js +99 -0
- package/lib/documents/types.d.ts +126 -0
- package/lib/documents/types.js +2 -0
- package/lib/documents/uagJs.d.ts +17 -0
- package/lib/documents/uagJs.js +157 -0
- package/lib/formflow.d.ts +47 -0
- package/lib/formflow.js +28 -0
- package/lib/identity.d.ts +53 -0
- package/lib/identity.js +107 -0
- package/lib/index.d.ts +98 -1
- package/lib/index.js +382 -8
- package/lib/markdown.d.ts +1 -0
- package/lib/markdown.js +166 -0
- package/lib/modules/api.d.ts +25 -0
- package/lib/modules/api.js +98 -0
- package/lib/modules/index.d.ts +4 -0
- package/lib/modules/index.js +24 -0
- package/lib/modules/routes.d.ts +14 -0
- package/lib/modules/routes.js +21 -0
- package/lib/modules/types.d.ts +68 -0
- package/lib/modules/types.js +5 -0
- package/lib/pluginFiles.d.ts +28 -0
- package/lib/pluginFiles.js +101 -0
- package/lib/plugins.d.ts +17 -0
- package/lib/plugins.js +93 -0
- package/lib/richText.d.ts +20 -0
- package/lib/richText.js +99 -0
- package/lib/router.d.ts +9 -0
- package/lib/router.js +34 -25
- package/lib/runner.d.ts +149 -0
- package/lib/runner.js +353 -0
- package/lib/runs.d.ts +27 -0
- package/lib/runs.js +13 -0
- package/lib/template.js +7 -9
- package/lib/templates/uagComponentNotFound.md +5 -5
- package/lib/testing.d.ts +74 -0
- package/lib/testing.js +180 -0
- package/lib/tools/SchemaBuilder.d.ts +3 -3
- package/lib/tools/SchemaBuilder.js +4 -4
- package/lib/tools/agentDecide.d.ts +8 -0
- package/lib/tools/agentDecide.js +57 -0
- package/lib/tools/agentProvide.d.ts +8 -0
- package/lib/tools/agentProvide.js +97 -0
- package/lib/tools/collectData.js +3 -2
- package/lib/tools/confirmSubmission.js +1 -1
- package/lib/tools/fetchExternalData.js +8 -17
- package/lib/tools/findSubmission.js +62 -56
- package/lib/tools/getFieldInfo.js +1 -1
- package/lib/tools/getFormFields.js +1 -1
- package/lib/tools/getForms.js +1 -1
- package/lib/tools/index.d.ts +4 -1
- package/lib/tools/index.js +21 -14
- package/lib/tools/readDocument.d.ts +17 -0
- package/lib/tools/readDocument.js +99 -0
- package/lib/tools/submissionUpdate.js +4 -3
- package/lib/tools/submitForm.js +1 -1
- package/lib/tools/utils.d.ts +40 -0
- package/lib/tools/utils.js +16 -1
- package/lib/version.d.ts +2 -0
- package/lib/version.js +5 -0
- package/package.json +49 -19
- package/testing.d.ts +1 -0
- package/testing.js +2 -0
- package/lib/tools/agentProvideData.d.ts +0 -3
- package/lib/tools/agentProvideData.js +0 -62
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CHAT_INSTRUCTIONS = exports.CHAT_DEFAULTS = exports.CONFIRMED = exports.REPLY = void 0;
|
|
4
|
+
exports.chatSettings = chatSettings;
|
|
5
|
+
exports.readChatRequest = readChatRequest;
|
|
6
|
+
exports.trimTranscript = trimTranscript;
|
|
7
|
+
exports.transcriptText = transcriptText;
|
|
8
|
+
exports.chatValueSchema = chatValueSchema;
|
|
9
|
+
exports.remainingOf = remainingOf;
|
|
10
|
+
exports.chatEngines = chatEngines;
|
|
11
|
+
exports.chatTurn = chatTurn;
|
|
12
|
+
const lodash_1 = require("lodash");
|
|
13
|
+
const formflow_1 = require("../formflow");
|
|
14
|
+
Object.defineProperty(exports, "CHAT_INSTRUCTIONS", { enumerable: true, get: function () { return formflow_1.CHAT_INSTRUCTIONS; } });
|
|
15
|
+
const debug = require('debug')('formio:uag:agent');
|
|
16
|
+
/**
|
|
17
|
+
* The two values of a turn that are not form fields. A Form.io key cannot
|
|
18
|
+
* start with `-`, so no field's path can be either, and model APIs that limit
|
|
19
|
+
* property names to letters, digits, `_`, `.` and `-` accept them.
|
|
20
|
+
*/
|
|
21
|
+
exports.REPLY = formflow_1.CHAT_REPLY;
|
|
22
|
+
exports.CONFIRMED = formflow_1.CHAT_CONFIRMED;
|
|
23
|
+
exports.CHAT_DEFAULTS = {
|
|
24
|
+
engines: ['agent'],
|
|
25
|
+
maxIterations: 3,
|
|
26
|
+
maxMessages: 40,
|
|
27
|
+
maxMessageLength: 2000,
|
|
28
|
+
maxTranscriptChars: 24000,
|
|
29
|
+
};
|
|
30
|
+
function positive(value, fallback) {
|
|
31
|
+
const number = Number(value);
|
|
32
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
33
|
+
}
|
|
34
|
+
/** The chat settings in force (`agentApi.chat`), with their defaults. */
|
|
35
|
+
function chatSettings(project) {
|
|
36
|
+
const configured = project.formflowConfig()?.agentApi?.chat || {};
|
|
37
|
+
const engines = Array.isArray(configured.engines) && configured.engines.length ? configured.engines.map(String) : exports.CHAT_DEFAULTS.engines;
|
|
38
|
+
return {
|
|
39
|
+
engines,
|
|
40
|
+
maxIterations: positive(configured.maxIterations, exports.CHAT_DEFAULTS.maxIterations),
|
|
41
|
+
maxMessages: positive(configured.maxMessages, exports.CHAT_DEFAULTS.maxMessages),
|
|
42
|
+
maxMessageLength: positive(configured.maxMessageLength, exports.CHAT_DEFAULTS.maxMessageLength),
|
|
43
|
+
maxTranscriptChars: positive(configured.maxTranscriptChars, exports.CHAT_DEFAULTS.maxTranscriptChars),
|
|
44
|
+
...(configured.timeout !== undefined && { timeout: positive(configured.timeout, 0) || undefined }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Read and check a turn's body, or throw a 400. Nothing is called before this passes. */
|
|
48
|
+
function readChatRequest(body, settings) {
|
|
49
|
+
const messages = body?.messages;
|
|
50
|
+
if (!Array.isArray(messages) || !messages.length) {
|
|
51
|
+
throw new formflow_1.FormflowError(400, 'Send the conversation as "messages": a list of { role, content }, ending with the person\'s message.');
|
|
52
|
+
}
|
|
53
|
+
if (messages.length > settings.maxMessages) {
|
|
54
|
+
throw new formflow_1.FormflowError(400, `A conversation may carry at most ${settings.maxMessages} messages. Start a new one.`);
|
|
55
|
+
}
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
if (!message || (message.role !== 'user' && message.role !== 'assistant') || typeof message.content !== 'string') {
|
|
58
|
+
throw new formflow_1.FormflowError(400, 'Each message must be { "role": "user" | "assistant", "content": "<text>" }.');
|
|
59
|
+
}
|
|
60
|
+
if (message.content.length > settings.maxMessageLength) {
|
|
61
|
+
throw new formflow_1.FormflowError(400, `Messages are limited to ${settings.maxMessageLength} characters.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (messages[messages.length - 1].role !== 'user') {
|
|
65
|
+
throw new formflow_1.FormflowError(400, 'The last message must be the person\'s ("role": "user").');
|
|
66
|
+
}
|
|
67
|
+
const data = body?.data;
|
|
68
|
+
if (data !== undefined && data !== null && (typeof data !== 'object' || Array.isArray(data))) {
|
|
69
|
+
throw new formflow_1.FormflowError(400, '"data" must be an object: the form\'s current data.');
|
|
70
|
+
}
|
|
71
|
+
const locked = body?.locked;
|
|
72
|
+
if (locked !== undefined && (!Array.isArray(locked) || locked.length > 500 || !locked.every((path) => typeof path === 'string'))) {
|
|
73
|
+
throw new formflow_1.FormflowError(400, '"locked" must be a list of field paths: the fields the person set in the form.');
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
messages: messages.map((message) => ({ role: message.role, content: message.content })),
|
|
77
|
+
data: (0, lodash_1.cloneDeep)(data || {}),
|
|
78
|
+
locked: locked || []
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The newest messages that fit in `maxChars`, starting at one the person
|
|
83
|
+
* wrote. The newest message always stays, whatever its length.
|
|
84
|
+
*/
|
|
85
|
+
function trimTranscript(messages, maxChars) {
|
|
86
|
+
let start = messages.length - 1;
|
|
87
|
+
let total = messages[start].content.length;
|
|
88
|
+
while (start > 0 && total + messages[start - 1].content.length <= maxChars) {
|
|
89
|
+
start--;
|
|
90
|
+
total += messages[start].content.length;
|
|
91
|
+
}
|
|
92
|
+
while (start < messages.length - 1 && messages[start].role !== 'user')
|
|
93
|
+
start++;
|
|
94
|
+
return messages.slice(start);
|
|
95
|
+
}
|
|
96
|
+
function transcriptText(messages) {
|
|
97
|
+
return messages.map((message) => `${message.role === 'user' ? 'Person' : 'Assistant'}: ${message.content.trim()}`).join('\n\n');
|
|
98
|
+
}
|
|
99
|
+
/** The static options of a closed-set field, or none (free text, or options the chat does not know). */
|
|
100
|
+
function staticOptions(field) {
|
|
101
|
+
const options = (field.options || []).filter((option) => !String(option.label || '').startsWith('**') && option.label !== '');
|
|
102
|
+
return options.length ? options : undefined;
|
|
103
|
+
}
|
|
104
|
+
/** The JSON schema of a field's value, for structured extraction. */
|
|
105
|
+
function chatValueSchema(form, field) {
|
|
106
|
+
const component = form.getComponent(field.path) || {};
|
|
107
|
+
const options = staticOptions(field);
|
|
108
|
+
switch (component.type) {
|
|
109
|
+
case 'checkbox':
|
|
110
|
+
return { type: 'boolean' };
|
|
111
|
+
case 'number':
|
|
112
|
+
case 'currency':
|
|
113
|
+
return { type: 'number' };
|
|
114
|
+
case 'selectboxes':
|
|
115
|
+
return {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: Object.fromEntries((options || []).map((option) => [String(option.value), { type: 'boolean' }])),
|
|
118
|
+
additionalProperties: false
|
|
119
|
+
};
|
|
120
|
+
case 'survey': {
|
|
121
|
+
const questions = (component.questions || []);
|
|
122
|
+
const values = (component.values || []);
|
|
123
|
+
return {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: Object.fromEntries(questions.map((question) => [question.value, { enum: values.map((value) => value.value) }])),
|
|
126
|
+
additionalProperties: false
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
if (options && component.multiple)
|
|
131
|
+
return { type: 'array', items: { enum: options.map((option) => option.value) } };
|
|
132
|
+
if (options)
|
|
133
|
+
return { enum: options.map((option) => option.value) };
|
|
134
|
+
return component.multiple || component.type === 'tags' ? { type: 'array', items: { type: 'string' } } : { type: 'string' };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function fieldSpec(form, field) {
|
|
138
|
+
const component = form.getComponent(field.path) || {};
|
|
139
|
+
const spec = {
|
|
140
|
+
path: field.path,
|
|
141
|
+
label: field.label,
|
|
142
|
+
type: field.type,
|
|
143
|
+
description: [field.required ? 'Required.' : 'Optional.', field.description, field.prompt && `Placeholder: ${field.prompt}`].filter(Boolean).join(' '),
|
|
144
|
+
// The host's rule, then the component's validation (lengths, pattern, range, mask), so a
|
|
145
|
+
// first answer is likely to pass the form's checks.
|
|
146
|
+
rule: (0, formflow_1.ruleWithConstraints)(field.rule, component),
|
|
147
|
+
};
|
|
148
|
+
if (field.format)
|
|
149
|
+
spec.format = field.format;
|
|
150
|
+
const options = staticOptions(field);
|
|
151
|
+
if (options)
|
|
152
|
+
spec.options = options;
|
|
153
|
+
if (component.multiple || component.type === 'selectboxes' || component.type === 'tags')
|
|
154
|
+
spec.multiple = true;
|
|
155
|
+
return spec;
|
|
156
|
+
}
|
|
157
|
+
/** The longest value, in characters, the engine is shown for one field. */
|
|
158
|
+
const MAX_VALUE_CHARS = 500;
|
|
159
|
+
/**
|
|
160
|
+
* A labeled line per value the chat collects, for the engine's context. Only
|
|
161
|
+
* the form's collectable fields, each value and the whole bounded: the request's
|
|
162
|
+
* data is the client's, and its size must not decide what an engine is sent.
|
|
163
|
+
*/
|
|
164
|
+
function collectedText(fields, data, maxChars) {
|
|
165
|
+
const lines = [];
|
|
166
|
+
let total = 0;
|
|
167
|
+
for (const field of fields) {
|
|
168
|
+
const value = (0, lodash_1.get)(data, field.path);
|
|
169
|
+
if (value === undefined || value === null || value === '')
|
|
170
|
+
continue;
|
|
171
|
+
let text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
172
|
+
if (text.length > MAX_VALUE_CHARS)
|
|
173
|
+
text = `${text.slice(0, MAX_VALUE_CHARS)}…`;
|
|
174
|
+
const line = `- ${field.label}: ${text}`;
|
|
175
|
+
if (total + line.length > maxChars)
|
|
176
|
+
break;
|
|
177
|
+
lines.push(line);
|
|
178
|
+
total += line.length + 1;
|
|
179
|
+
}
|
|
180
|
+
return lines.length ? lines.join('\n') : '(nothing yet)';
|
|
181
|
+
}
|
|
182
|
+
function labels(fields) {
|
|
183
|
+
return fields.length ? fields.map((field) => `- ${field.label}`).join('\n') : '(none)';
|
|
184
|
+
}
|
|
185
|
+
/** What the form still needs, from its fields on the current data. */
|
|
186
|
+
function remainingOf(fields) {
|
|
187
|
+
const brief = (field) => ({ path: field.path, label: field.label });
|
|
188
|
+
return {
|
|
189
|
+
required: fields.fields.filter((field) => field.required && field.empty).map(brief),
|
|
190
|
+
optional: fields.fields.filter((field) => !field.required && field.empty).map(brief),
|
|
191
|
+
unsupported: fields.unsupported
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function isComplete(fields) {
|
|
195
|
+
return !fields.errors.length
|
|
196
|
+
&& !fields.fields.some((field) => field.required && field.empty)
|
|
197
|
+
&& !fields.unsupported.some((field) => field.required && field.empty);
|
|
198
|
+
}
|
|
199
|
+
/** A value as the form stores it: comma-separated multiple values and select boxes become arrays and maps. */
|
|
200
|
+
function normalized(form, path, value) {
|
|
201
|
+
return (0, lodash_1.get)(form.convertToSubmission({ [path]: value }).data, path);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Check a value's shape against its field, which the engine's schema only
|
|
205
|
+
* asks for: options, numbers, booleans, and the keys of select boxes and
|
|
206
|
+
* surveys. Returns the value as the form stores it, or why it is refused.
|
|
207
|
+
*/
|
|
208
|
+
function checkValue(form, field, value) {
|
|
209
|
+
const component = form.getComponent(field.path) || {};
|
|
210
|
+
const options = staticOptions(field);
|
|
211
|
+
const optionValues = (options || []).map((option) => JSON.stringify(option.value));
|
|
212
|
+
const isOption = (item) => optionValues.includes(JSON.stringify(item));
|
|
213
|
+
const notOption = { error: `${field.label} must be one of: ${(options || []).map((option) => option.label).join(', ')}.` };
|
|
214
|
+
switch (component.type) {
|
|
215
|
+
case 'number':
|
|
216
|
+
case 'currency': {
|
|
217
|
+
const number = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN);
|
|
218
|
+
return Number.isFinite(number) ? { value: number } : { error: `${field.label} must be a number.` };
|
|
219
|
+
}
|
|
220
|
+
case 'checkbox':
|
|
221
|
+
if (typeof value === 'boolean')
|
|
222
|
+
return { value };
|
|
223
|
+
if (value === 'true' || value === 'false')
|
|
224
|
+
return { value: value === 'true' };
|
|
225
|
+
return { error: `${field.label} must be true or false.` };
|
|
226
|
+
case 'selectboxes':
|
|
227
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
228
|
+
return notOption;
|
|
229
|
+
if (!Object.keys(value).every((key) => optionValues.includes(JSON.stringify(key)) || (options || []).some((option) => String(option.value) === key)))
|
|
230
|
+
return notOption;
|
|
231
|
+
return { value: Object.fromEntries(Object.entries(value).map(([key, checked]) => [key, checked === true || checked === 'true'])) };
|
|
232
|
+
case 'survey': {
|
|
233
|
+
const questions = new Set((component.questions || []).map((question) => String(question.value)));
|
|
234
|
+
const answers = (component.values || []).map((answer) => JSON.stringify(answer.value));
|
|
235
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
236
|
+
|| !Object.entries(value).every(([key, answer]) => questions.has(key) && answers.includes(JSON.stringify(answer)))) {
|
|
237
|
+
return { error: `${field.label} must answer its questions with one of their options.` };
|
|
238
|
+
}
|
|
239
|
+
return { value };
|
|
240
|
+
}
|
|
241
|
+
default:
|
|
242
|
+
if (options && !(Array.isArray(value) ? value : [value]).every(isOption))
|
|
243
|
+
return notOption;
|
|
244
|
+
return { value };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Merge an engine's values into the data and validate them with the form: a
|
|
249
|
+
* value the form rejects is put back as it was and reported `invalid`.
|
|
250
|
+
*/
|
|
251
|
+
async function applyValues(form, auth, data, collectable, values) {
|
|
252
|
+
const byPath = new Map(collectable.map((field) => [field.path, field]));
|
|
253
|
+
const merged = (0, lodash_1.cloneDeep)(data);
|
|
254
|
+
const fields = {};
|
|
255
|
+
const rejected = [];
|
|
256
|
+
const changed = [];
|
|
257
|
+
for (const [path, raw] of Object.entries(values || {})) {
|
|
258
|
+
const field = byPath.get(path);
|
|
259
|
+
if (!field || raw === undefined || raw === null)
|
|
260
|
+
continue;
|
|
261
|
+
const checked = checkValue(form, field, normalized(form, path, raw));
|
|
262
|
+
if ('error' in checked) {
|
|
263
|
+
const result = { path, label: field.label, status: 'invalid', value: raw, error: checked.error };
|
|
264
|
+
fields[path] = result;
|
|
265
|
+
rejected.push(result);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if ((0, lodash_1.isEqual)(checked.value, (0, lodash_1.get)(data, path)))
|
|
269
|
+
continue;
|
|
270
|
+
(0, lodash_1.set)(merged, path, checked.value);
|
|
271
|
+
changed.push(path);
|
|
272
|
+
}
|
|
273
|
+
// The form's processors change the data they are given (defaults, cleared
|
|
274
|
+
// hidden values): they read a copy, so the values sent back are the engine's.
|
|
275
|
+
let current = await form.chatFields({ data: (0, lodash_1.cloneDeep)(merged) }, auth);
|
|
276
|
+
const invalid = changed.filter((path) => current.errors.some((error) => error.path === path));
|
|
277
|
+
for (const path of invalid) {
|
|
278
|
+
const field = byPath.get(path);
|
|
279
|
+
const error = current.errors.find((item) => item.path === path);
|
|
280
|
+
const result = { path, label: field.label, status: 'invalid', value: (0, lodash_1.get)(merged, path), error: error.error };
|
|
281
|
+
fields[path] = result;
|
|
282
|
+
rejected.push(result);
|
|
283
|
+
const before = (0, lodash_1.get)(data, path);
|
|
284
|
+
if (before === undefined)
|
|
285
|
+
(0, lodash_1.unset)(merged, path);
|
|
286
|
+
else
|
|
287
|
+
(0, lodash_1.set)(merged, path, (0, lodash_1.cloneDeep)(before));
|
|
288
|
+
}
|
|
289
|
+
if (invalid.length)
|
|
290
|
+
current = await form.chatFields({ data: (0, lodash_1.cloneDeep)(merged) }, auth);
|
|
291
|
+
const patch = {};
|
|
292
|
+
for (const path of changed.filter((item) => !invalid.includes(item))) {
|
|
293
|
+
const field = byPath.get(path);
|
|
294
|
+
patch[path] = (0, lodash_1.get)(merged, path);
|
|
295
|
+
fields[path] = { path, label: field.label, status: 'suggested', value: patch[path] };
|
|
296
|
+
}
|
|
297
|
+
return { data: merged, patch, fields, rejected, current };
|
|
298
|
+
}
|
|
299
|
+
/** The validation tool an in-process engine may call before answering. */
|
|
300
|
+
function checkTool(form, auth, data, collectable) {
|
|
301
|
+
const definition = formflow_1.CHAT_CHECK_TOOL;
|
|
302
|
+
const call = async (name, args) => {
|
|
303
|
+
if (name !== definition.name) {
|
|
304
|
+
return { content: [{ type: 'text', text: `The tool "${name}" is not available.` }], isError: true };
|
|
305
|
+
}
|
|
306
|
+
const values = args?.values && typeof args.values === 'object' && !Array.isArray(args.values) ? args.values : {};
|
|
307
|
+
const applied = await applyValues(form, auth, data, collectable, values);
|
|
308
|
+
const remaining = remainingOf(applied.current);
|
|
309
|
+
const lines = applied.rejected.length
|
|
310
|
+
? ['Not accepted:', ...applied.rejected.map((result) => `- ${result.label}: ${result.error}`)]
|
|
311
|
+
: ['Every value is accepted.'];
|
|
312
|
+
lines.push('', 'Still needed after these values:', labels(remaining.required));
|
|
313
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
314
|
+
};
|
|
315
|
+
return { definition, call };
|
|
316
|
+
}
|
|
317
|
+
/** Run one engine call with a timeout; the signal is aborted when it passes. */
|
|
318
|
+
async function withTimeout(ms, call) {
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
if (!ms)
|
|
321
|
+
return call(controller.signal);
|
|
322
|
+
let timer;
|
|
323
|
+
const timeout = new Promise((_, reject) => {
|
|
324
|
+
timer = setTimeout(() => {
|
|
325
|
+
controller.abort();
|
|
326
|
+
reject(new Error(`timed out after ${ms} ms`));
|
|
327
|
+
}, ms);
|
|
328
|
+
});
|
|
329
|
+
try {
|
|
330
|
+
return await Promise.race([call(controller.signal), timeout]);
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** The agent engines that may serve chat, in order. */
|
|
337
|
+
function chatEngines(project, settings = chatSettings(project)) {
|
|
338
|
+
return (0, formflow_1.formflow)(project).policy.expand(settings.engines).filter((engine) => engine.kind === 'agent');
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Run one turn of a form's chat. The caller has already been authenticated,
|
|
342
|
+
* and checked for create access to the form.
|
|
343
|
+
*/
|
|
344
|
+
async function chatTurn(project, form, body, context) {
|
|
345
|
+
const settings = chatSettings(project);
|
|
346
|
+
const request = readChatRequest(body, settings);
|
|
347
|
+
const engines = chatEngines(project, settings);
|
|
348
|
+
if (!engines.length) {
|
|
349
|
+
throw new formflow_1.FormflowError(503, 'No enabled engine can serve chat. Enable an agent engine with UAG_ENGINES.', 'no_engine');
|
|
350
|
+
}
|
|
351
|
+
const service = (0, formflow_1.formflow)(project);
|
|
352
|
+
const before = await form.chatFields({ data: (0, lodash_1.cloneDeep)(request.data) }, context.auth);
|
|
353
|
+
// What the person set in the form itself stays theirs: shown to the engine, never asked for.
|
|
354
|
+
const locked = new Set(request.locked);
|
|
355
|
+
const collectable = before.fields.filter((field) => !locked.has(field.path));
|
|
356
|
+
const wasComplete = isComplete(before);
|
|
357
|
+
const remainingBefore = remainingOf(before);
|
|
358
|
+
// Agentic Formflow builds the engine's request from these.
|
|
359
|
+
const conversation = transcriptText(trimTranscript(request.messages, settings.maxTranscriptChars));
|
|
360
|
+
const contextNow = {
|
|
361
|
+
'Collected so far': collectedText(before.fields, request.data, settings.maxTranscriptChars),
|
|
362
|
+
'Still needed': labels(remainingBefore.required),
|
|
363
|
+
'Optional, not yet answered': labels(remainingBefore.optional),
|
|
364
|
+
...(remainingBefore.unsupported.some((field) => field.empty) && {
|
|
365
|
+
'Finished in the form, not in this chat': labels(remainingBefore.unsupported.filter((field) => field.empty))
|
|
366
|
+
})
|
|
367
|
+
};
|
|
368
|
+
const fields = collectable.map((field) => ({ spec: fieldSpec(form, field), schema: chatValueSchema(form, field) }));
|
|
369
|
+
const failed = [];
|
|
370
|
+
let limited = 0;
|
|
371
|
+
let retryAfterMs = 0;
|
|
372
|
+
for (const engine of engines) {
|
|
373
|
+
const started = Date.now();
|
|
374
|
+
// Only engines in this process can call the tool back; remote ones would need /mcp.
|
|
375
|
+
const tools = engine.toolUse === true && project.formflowEngines().source(engine).type !== 'remote';
|
|
376
|
+
const check = tools ? checkTool(form, context.auth, request.data, collectable) : null;
|
|
377
|
+
let response;
|
|
378
|
+
try {
|
|
379
|
+
service.engineLimiter.take(engine.name);
|
|
380
|
+
response = await withTimeout(settings.timeout ?? service.policy.timeout(engine), (signal) => service.chatExtract(engine, {
|
|
381
|
+
form: form,
|
|
382
|
+
fields,
|
|
383
|
+
conversation,
|
|
384
|
+
context: contextNow,
|
|
385
|
+
...(check && { checkValues: check.call, maxIterations: settings.maxIterations }),
|
|
386
|
+
signal
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
if (err instanceof formflow_1.RateLimitError) {
|
|
391
|
+
limited++;
|
|
392
|
+
retryAfterMs = retryAfterMs ? Math.min(retryAfterMs, err.retryAfterMs) : err.retryAfterMs;
|
|
393
|
+
}
|
|
394
|
+
failed.push(engine.name);
|
|
395
|
+
debug(`[${context.requestId}] chat engine ${engine.name} failed:`, err?.message || err);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const values = { ...(response.values || {}) };
|
|
399
|
+
let reply = typeof values[exports.REPLY] === 'string' ? values[exports.REPLY].trim() : '';
|
|
400
|
+
const saidConfirmed = values[exports.CONFIRMED] === true || values[exports.CONFIRMED] === 'true';
|
|
401
|
+
delete values[exports.REPLY];
|
|
402
|
+
delete values[exports.CONFIRMED];
|
|
403
|
+
const applied = await applyValues(form, context.auth, request.data, collectable, values);
|
|
404
|
+
const toolCalls = [...(response.toolCalls || [])];
|
|
405
|
+
const usage = { ...(response.usage || {}) };
|
|
406
|
+
const checked = toolCalls.some((call) => call.name === 'check_values');
|
|
407
|
+
if (applied.rejected.length && !checked) {
|
|
408
|
+
// The reply was written before the form rejected a value: ask for one that says so.
|
|
409
|
+
try {
|
|
410
|
+
service.engineLimiter.take(engine.name);
|
|
411
|
+
const corrected = await withTimeout(settings.timeout ?? service.policy.timeout(engine), (signal) => service.chatExtract(engine, {
|
|
412
|
+
form: form,
|
|
413
|
+
fields: [],
|
|
414
|
+
conversation,
|
|
415
|
+
context: {
|
|
416
|
+
...contextNow,
|
|
417
|
+
'Collected so far': collectedText(before.fields, applied.data, settings.maxTranscriptChars),
|
|
418
|
+
'Still needed': labels(remainingOf(applied.current).required),
|
|
419
|
+
'Not accepted (ask again for these)': applied.rejected.map((result) => `- ${result.label}: ${result.error}`).join('\n')
|
|
420
|
+
},
|
|
421
|
+
signal
|
|
422
|
+
}));
|
|
423
|
+
const text = typeof corrected.values?.[exports.REPLY] === 'string' ? corrected.values[exports.REPLY].trim() : '';
|
|
424
|
+
if (text)
|
|
425
|
+
reply = text;
|
|
426
|
+
if (corrected.usage) {
|
|
427
|
+
usage.inputTokens = (usage.inputTokens || 0) + (corrected.usage.inputTokens || 0);
|
|
428
|
+
usage.outputTokens = (usage.outputTokens || 0) + (corrected.usage.outputTokens || 0);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
debug(`[${context.requestId}] chat reply correction with ${engine.name} failed:`, err?.message || err);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const remaining = remainingOf(applied.current);
|
|
436
|
+
const complete = isComplete(applied.current);
|
|
437
|
+
if (!reply)
|
|
438
|
+
reply = fallbackReply(remaining, complete, applied.rejected);
|
|
439
|
+
// The client sends the reply back with the next turn, where messages are limited.
|
|
440
|
+
if (reply.length > settings.maxMessageLength)
|
|
441
|
+
reply = `${reply.slice(0, settings.maxMessageLength - 1).trimEnd()}…`;
|
|
442
|
+
return {
|
|
443
|
+
requestId: context.requestId,
|
|
444
|
+
reply,
|
|
445
|
+
patch: applied.patch,
|
|
446
|
+
data: applied.data,
|
|
447
|
+
fields: applied.fields,
|
|
448
|
+
errors: applied.current.errors,
|
|
449
|
+
remaining,
|
|
450
|
+
complete,
|
|
451
|
+
confirmed: saidConfirmed && wasComplete && complete && !Object.keys(applied.patch).length && !applied.rejected.length,
|
|
452
|
+
engine: {
|
|
453
|
+
name: engine.name,
|
|
454
|
+
model: response.model,
|
|
455
|
+
latencyMs: Date.now() - started,
|
|
456
|
+
toolCalls,
|
|
457
|
+
...((usage.inputTokens !== undefined || usage.outputTokens !== undefined) && { usage })
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (limited === engines.length) {
|
|
462
|
+
throw new formflow_1.RateLimitError('Every engine that could answer this chat is over its rate limit. Try again shortly.', retryAfterMs);
|
|
463
|
+
}
|
|
464
|
+
// Engine errors can carry provider details: they go to the log, not the caller.
|
|
465
|
+
throw new formflow_1.FormflowError(502, `The chat could not be answered (engines tried: ${failed.join(', ')}). See the UAG logs for this request id.`);
|
|
466
|
+
}
|
|
467
|
+
/** A reply for when the engine wrote none. */
|
|
468
|
+
function fallbackReply(remaining, complete, rejected) {
|
|
469
|
+
if (rejected.length)
|
|
470
|
+
return `${rejected[0].error} Could you give it again?`;
|
|
471
|
+
if (complete)
|
|
472
|
+
return 'Thanks, that is everything needed. Please review your answers and confirm to submit them.';
|
|
473
|
+
const next = remaining.required[0] || remaining.unsupported.find((field) => field.required && field.empty);
|
|
474
|
+
return next ? `Thanks. Next, please tell me: ${next.label}.` : 'Thanks. Is there anything else you would like to add?';
|
|
475
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EngineTypeDefinition } from '@formio/uag-sdk';
|
|
2
|
+
import { EngineRegistry } from '../formflow';
|
|
3
|
+
/** The UAG's own installation, where engine packages are looked up first. */
|
|
4
|
+
export declare const UAG_ROOT: string;
|
|
5
|
+
/** Engine types every UAG has, whatever is installed. */
|
|
6
|
+
export declare const BUILT_IN_ENGINES: Record<string, EngineTypeDefinition>;
|
|
7
|
+
/** What is known of each loaded engine type, by type name: for listing it as a plugin. */
|
|
8
|
+
export type LoadedEngineType = {
|
|
9
|
+
name: string;
|
|
10
|
+
title: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
kinds: ('decision' | 'agent')[];
|
|
13
|
+
instances: string[];
|
|
14
|
+
};
|
|
15
|
+
/** The engine types a registry's instances were created from, in order of first use. */
|
|
16
|
+
export declare function engineTypesOf(registry: EngineRegistry | undefined): LoadedEngineType[];
|
|
17
|
+
export declare function loadUAGEngines(config?: Record<string, any>, env?: Record<string, string | undefined>): Promise<EngineRegistry>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.BUILT_IN_ENGINES = exports.UAG_ROOT = void 0;
|
|
7
|
+
exports.engineTypesOf = engineTypesOf;
|
|
8
|
+
exports.loadUAGEngines = loadUAGEngines;
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const uag_sdk_1 = require("@formio/uag-sdk");
|
|
11
|
+
const loader_1 = require("@formio/uag-sdk/loader");
|
|
12
|
+
const appserver_1 = require("@formio/appserver");
|
|
13
|
+
const remote_1 = require("./remote");
|
|
14
|
+
const stubs_1 = require("./stubs");
|
|
15
|
+
/** The UAG's own installation, where engine packages are looked up first. */
|
|
16
|
+
exports.UAG_ROOT = path_1.default.resolve(__dirname, '..', '..');
|
|
17
|
+
/** Engine types every UAG has, whatever is installed. */
|
|
18
|
+
exports.BUILT_IN_ENGINES = {
|
|
19
|
+
remote: remote_1.RemoteEngine,
|
|
20
|
+
'stub-decision': stubs_1.StubDecisionEngine,
|
|
21
|
+
'stub-agent': stubs_1.StubAgentEngine,
|
|
22
|
+
};
|
|
23
|
+
const loadedTypes = new WeakMap();
|
|
24
|
+
/** The engine types a registry's instances were created from, in order of first use. */
|
|
25
|
+
function engineTypesOf(registry) {
|
|
26
|
+
return (registry && loadedTypes.get(registry)) || [];
|
|
27
|
+
}
|
|
28
|
+
async function loadUAGEngines(config = {}, env = process.env) {
|
|
29
|
+
const loaded = (0, loader_1.planEngines)((0, uag_sdk_1.engineInstances)(config, env), {
|
|
30
|
+
types: { ...exports.BUILT_IN_ENGINES, ...(config.engineTypes || {}) },
|
|
31
|
+
paths: (0, loader_1.enginePaths)(exports.UAG_ROOT, env),
|
|
32
|
+
env,
|
|
33
|
+
config,
|
|
34
|
+
apiVersion: appserver_1.Formflow.ENGINE_API_VERSION,
|
|
35
|
+
});
|
|
36
|
+
// Agentic Formflow creates the engines from the plans.
|
|
37
|
+
const registry = await appserver_1.Formflow.startEngines(loaded);
|
|
38
|
+
const types = new Map();
|
|
39
|
+
for (const entry of loaded) {
|
|
40
|
+
const type = types.get(entry.type) || { name: entry.type, title: entry.title || entry.type, version: entry.typeVersion || entry.version, kinds: entry.kinds, instances: [] };
|
|
41
|
+
type.instances.push(entry.name);
|
|
42
|
+
types.set(entry.type, type);
|
|
43
|
+
}
|
|
44
|
+
loadedTypes.set(registry, [...types.values()]);
|
|
45
|
+
return registry;
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Request, RequestHandler, Response } from "express";
|
|
2
|
+
export declare function isAgentApiRequest(req: Request): boolean;
|
|
3
|
+
/** The code for a status, when the error names none. */
|
|
4
|
+
export declare function errorCode(status: number): string;
|
|
5
|
+
export declare const INTERNAL_ERROR = "The request failed. See the UAG logs for this request id.";
|
|
6
|
+
/**
|
|
7
|
+
* An error as the Agent API answers it: its status, and the body. Errors with
|
|
8
|
+
* a status (FormflowError) keep their message and code; anything else is a 500
|
|
9
|
+
* with no detail. Agent operations and module routes share it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function agentError(requestId: string, err: any): {
|
|
12
|
+
status: number;
|
|
13
|
+
body: Record<string, any>;
|
|
14
|
+
};
|
|
15
|
+
/** The request's id, assigned once: the caller's, when it is a short token, or a new one. */
|
|
16
|
+
export declare function requestIdOf(req: Request, res: Response): string;
|
|
17
|
+
/** The error body for a status and whatever a handler sent: an object, JSON text, plain text or nothing. */
|
|
18
|
+
export declare function errorBody(requestId: string, status: number, sent: unknown): Record<string, any>;
|
|
19
|
+
/**
|
|
20
|
+
* Mounted ahead of every route: for an Agent API request, assigns the request
|
|
21
|
+
* id and rewrites any error response into the one shape, so refusals from
|
|
22
|
+
* middleware the UAG does not own (the project's authentication answers plain
|
|
23
|
+
* text, the portal's team access an empty 401) look like every other.
|
|
24
|
+
*/
|
|
25
|
+
export declare function agentErrorShape(): RequestHandler;
|