@formio/uag 1.13.2 → 2.0.0-rc.1

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.
Files changed (66) hide show
  1. package/README.md +235 -535
  2. package/bin/uag-eval.js +95 -0
  3. package/bin/uag.js +73 -0
  4. package/lib/UAGFormInterface.d.ts +60 -2
  5. package/lib/UAGFormInterface.js +172 -46
  6. package/lib/UAGProjectInterface.d.ts +37 -5
  7. package/lib/UAGProjectInterface.js +166 -33
  8. package/lib/actionLogs.d.ts +33 -0
  9. package/lib/actionLogs.js +147 -0
  10. package/lib/actions/runTask.d.ts +43 -0
  11. package/lib/actions/runTask.js +323 -0
  12. package/lib/agent/anonymous.d.ts +34 -0
  13. package/lib/agent/anonymous.js +98 -0
  14. package/lib/agent/chat.d.ts +113 -0
  15. package/lib/agent/chat.js +539 -0
  16. package/lib/agent/engines.d.ts +13 -0
  17. package/lib/agent/engines.js +37 -0
  18. package/lib/agent/index.d.ts +4 -0
  19. package/lib/agent/index.js +18 -0
  20. package/lib/agent/remote.d.ts +67 -0
  21. package/lib/agent/remote.js +229 -0
  22. package/lib/agent/requestId.d.ts +4 -0
  23. package/lib/agent/requestId.js +19 -0
  24. package/lib/agent/router.d.ts +31 -0
  25. package/lib/agent/router.js +295 -0
  26. package/lib/agent/stubs.d.ts +26 -0
  27. package/lib/agent/stubs.js +26 -0
  28. package/lib/agent/tools.d.ts +10 -0
  29. package/lib/agent/tools.js +33 -0
  30. package/lib/config.d.ts +67 -0
  31. package/lib/config.js +19 -0
  32. package/lib/database.d.ts +75 -0
  33. package/lib/database.js +229 -0
  34. package/lib/formflow.d.ts +40 -0
  35. package/lib/formflow.js +28 -0
  36. package/lib/identity.d.ts +53 -0
  37. package/lib/identity.js +107 -0
  38. package/lib/index.d.ts +41 -0
  39. package/lib/index.js +179 -7
  40. package/lib/router.js +15 -25
  41. package/lib/runner.d.ts +147 -0
  42. package/lib/runner.js +345 -0
  43. package/lib/runs.d.ts +89 -0
  44. package/lib/runs.js +74 -0
  45. package/lib/template.js +7 -9
  46. package/lib/templates/uagComponentNotFound.md +5 -5
  47. package/lib/tools/SchemaBuilder.d.ts +7 -2
  48. package/lib/tools/SchemaBuilder.js +11 -3
  49. package/lib/tools/agentDecide.d.ts +8 -0
  50. package/lib/tools/agentDecide.js +57 -0
  51. package/lib/tools/agentProvide.d.ts +13 -0
  52. package/lib/tools/agentProvide.js +130 -0
  53. package/lib/tools/collectData.js +1 -1
  54. package/lib/tools/confirmSubmission.js +1 -1
  55. package/lib/tools/fetchExternalData.js +8 -17
  56. package/lib/tools/findSubmission.js +62 -56
  57. package/lib/tools/getFieldInfo.js +1 -1
  58. package/lib/tools/getFormFields.js +1 -1
  59. package/lib/tools/getForms.js +1 -1
  60. package/lib/tools/index.d.ts +3 -1
  61. package/lib/tools/index.js +21 -14
  62. package/lib/tools/submissionUpdate.js +1 -1
  63. package/lib/tools/submitForm.js +1 -1
  64. package/package.json +41 -18
  65. package/lib/tools/agentProvideData.d.ts +0 -3
  66. package/lib/tools/agentProvideData.js +0 -62
@@ -0,0 +1,67 @@
1
+ import type { RemoteEngineDescription } from '@formio/uag-sdk/protocol';
2
+ import type { AgentEngine, DecisionEngine } from '../formflow';
3
+ /**
4
+ * The `remote` engine type: an engine hosted by an engine server
5
+ * (`formio/uag-engines`), reached over the remote engine protocol. Any
6
+ * engine package can run there, so model credentials and model traffic stay in
7
+ * that container.
8
+ *
9
+ * UAG_ENGINES=jev,claude:remote
10
+ * UAG_ENGINE_CLAUDE_URL=http://engines:3300
11
+ * UAG_ENGINE_CLAUDE_KEY=<the engine server key, at least 32 characters>
12
+ *
13
+ * Every request carries the engine server key, and never a Form.io credential.
14
+ * Research extracts also carry a scoped token, which the engine server uses for
15
+ * its own MCP connection back to the UAG: it allows only the task's read-only
16
+ * tools, with the caller's identity. Each call also carries the Agent API
17
+ * request id, when it is a short token, for logs on either side.
18
+ */
19
+ /** The shortest engine server key accepted. */
20
+ export declare const MIN_KEY_LENGTH = 32;
21
+ type Fetch = typeof fetch;
22
+ type Connection = {
23
+ /** The instance name, for errors. */
24
+ name: string;
25
+ baseUrl: string;
26
+ key: string;
27
+ fetch: Fetch;
28
+ };
29
+ /**
30
+ * Describe the remote engine, retrying while the engine server is unreachable
31
+ * or failing, with the backoff used to wait for a Form.io project. A refused
32
+ * key, or a server that does not host the engine, stops startup at once.
33
+ */
34
+ export declare function describeRemote(connection: Connection, engine: string, { retries, retryDelay, timeout }?: {
35
+ retries?: number;
36
+ retryDelay?: number;
37
+ timeout?: number;
38
+ }): Promise<RemoteEngineDescription>;
39
+ /** An engine object for a described remote engine. */
40
+ export declare function remoteEngine(connection: Connection, remoteName: string, described: RemoteEngineDescription): DecisionEngine | AgentEngine;
41
+ export declare const RemoteEngine: Readonly<{
42
+ type: string;
43
+ kinds: ("agent" | "decision")[];
44
+ apiVersion: 1;
45
+ description: string;
46
+ settings: {
47
+ url: {
48
+ required: true;
49
+ description: string;
50
+ };
51
+ key: {
52
+ required: true;
53
+ secret: true;
54
+ description: string;
55
+ };
56
+ engine: {
57
+ description: string;
58
+ };
59
+ timeout: {
60
+ type: "number";
61
+ default: number;
62
+ description: string;
63
+ };
64
+ };
65
+ create({ name, settings, options, config }: import("@formio/uag-sdk").EngineCreateContext<any>): Promise<import("@formio/appserver-core/lib/formflow").AgentEngine | import("@formio/appserver-core/lib/formflow").DecisionEngine>;
66
+ }>;
67
+ export {};
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RemoteEngine = exports.MIN_KEY_LENGTH = void 0;
4
+ exports.describeRemote = describeRemote;
5
+ exports.remoteEngine = remoteEngine;
6
+ const uag_sdk_1 = require("@formio/uag-sdk");
7
+ const protocol_1 = require("@formio/uag-sdk/protocol");
8
+ const requestId_1 = require("./requestId");
9
+ const debug = require('debug')('formio:uag:remote');
10
+ /**
11
+ * The `remote` engine type: an engine hosted by an engine server
12
+ * (`formio/uag-engines`), reached over the remote engine protocol. Any
13
+ * engine package can run there, so model credentials and model traffic stay in
14
+ * that container.
15
+ *
16
+ * UAG_ENGINES=jev,claude:remote
17
+ * UAG_ENGINE_CLAUDE_URL=http://engines:3300
18
+ * UAG_ENGINE_CLAUDE_KEY=<the engine server key, at least 32 characters>
19
+ *
20
+ * Every request carries the engine server key, and never a Form.io credential.
21
+ * Research extracts also carry a scoped token, which the engine server uses for
22
+ * its own MCP connection back to the UAG: it allows only the task's read-only
23
+ * tools, with the caller's identity. Each call also carries the Agent API
24
+ * request id, when it is a short token, for logs on either side.
25
+ */
26
+ /** The shortest engine server key accepted. */
27
+ exports.MIN_KEY_LENGTH = 32;
28
+ const DEFAULT_RETRIES = 5;
29
+ const DEFAULT_RETRY_DELAY = 5000;
30
+ const MAX_RETRY_DELAY = 60000;
31
+ class StartupError extends uag_sdk_1.EngineConfigError {
32
+ /** Whether trying again may help: the server was unreachable or failing. */
33
+ constructor(message, retry) {
34
+ super(message);
35
+ this.retry = retry;
36
+ }
37
+ }
38
+ async function readJson(response) {
39
+ try {
40
+ return await response.json();
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /**
47
+ * Describe the remote engine, retrying while the engine server is unreachable
48
+ * or failing, with the backoff used to wait for a Form.io project. A refused
49
+ * key, or a server that does not host the engine, stops startup at once.
50
+ */
51
+ async function describeRemote(connection, engine, { retries = DEFAULT_RETRIES, retryDelay = DEFAULT_RETRY_DELAY, timeout = 10000 } = {}) {
52
+ const where = `the engine server at ${connection.baseUrl}`;
53
+ const attempts = Math.max(1, retries);
54
+ for (let attempt = 1;; attempt++) {
55
+ try {
56
+ return await describeOnce(connection, engine, where, timeout);
57
+ }
58
+ catch (err) {
59
+ if (!(err instanceof StartupError && err.retry) || attempt >= attempts) {
60
+ throw new uag_sdk_1.EngineConfigError(`The "${connection.name}" engine could not start: ${err.message}`);
61
+ }
62
+ const delay = Math.min(retryDelay * Math.pow(2, attempt - 1), MAX_RETRY_DELAY);
63
+ debug(`${err.message} Retrying in ${delay}ms (attempt ${attempt} of ${attempts}).`);
64
+ await new Promise((resolve) => setTimeout(resolve, delay));
65
+ }
66
+ }
67
+ }
68
+ async function describeOnce(connection, engine, where, timeout) {
69
+ let response;
70
+ try {
71
+ response = await connection.fetch(`${connection.baseUrl}/v1/engines`, {
72
+ headers: { Accept: 'application/json', Authorization: `Bearer ${connection.key}` },
73
+ signal: AbortSignal.timeout(timeout),
74
+ });
75
+ }
76
+ catch (err) {
77
+ throw new StartupError(`${where} is unreachable (${err?.cause?.code || err?.name || 'no response'}).`, true);
78
+ }
79
+ if (response.status === 401 || response.status === 403) {
80
+ throw new StartupError(`${where} refused the engine server key (HTTP ${response.status}).`, false);
81
+ }
82
+ if (!response.ok) {
83
+ throw new StartupError(`${where} answered HTTP ${response.status}.`, response.status >= 500);
84
+ }
85
+ let engines;
86
+ try {
87
+ engines = (0, protocol_1.checkDescription)(await readJson(response));
88
+ }
89
+ catch (err) {
90
+ throw new StartupError(`${where} did not answer as an engine server: ${err.message}`, false);
91
+ }
92
+ const described = engines.find((candidate) => candidate.name === engine);
93
+ if (!described) {
94
+ throw new StartupError(`${where} does not host an engine named "${engine}"` +
95
+ (engines.length ? ` (it hosts ${engines.map((e) => `"${e.name}"`).join(', ')}).` : ' (it hosts none).'), false);
96
+ }
97
+ return described;
98
+ }
99
+ /** One protocol call. Failures are engine failures, safe to show unless the server says otherwise. */
100
+ async function call(connection, path, body, signal, scopedToken) {
101
+ signal?.throwIfAborted();
102
+ const requestId = (0, protocol_1.safeRequestId)((0, requestId_1.currentRequestId)());
103
+ const headers = {
104
+ 'Content-Type': 'application/json',
105
+ Accept: 'application/json',
106
+ Authorization: `Bearer ${connection.key}`,
107
+ ...(scopedToken && { [protocol_1.SCOPED_TOKEN_HEADER]: scopedToken }),
108
+ ...(requestId && { [protocol_1.REQUEST_ID_HEADER]: requestId }),
109
+ };
110
+ let response;
111
+ try {
112
+ response = await connection.fetch(`${connection.baseUrl}${path}`, {
113
+ method: 'POST',
114
+ headers,
115
+ body: JSON.stringify({ protocol: protocol_1.PROTOCOL_VERSION, ...body }),
116
+ signal,
117
+ });
118
+ }
119
+ catch (err) {
120
+ if (signal?.aborted)
121
+ throw signal.reason ?? err;
122
+ // Callers see the failure; only the UAG's log names where the engine server is.
123
+ debug(`The engine server at ${connection.baseUrl} could not be reached: ${err?.cause?.code || err?.name || 'no response'}`);
124
+ throw new uag_sdk_1.EngineError('The engine server could not be reached.');
125
+ }
126
+ const json = await readJson(response);
127
+ if (!response.ok) {
128
+ if (json?.expose === true && typeof json.error === 'string') {
129
+ throw new uag_sdk_1.EngineError(json.error);
130
+ }
131
+ debug(`The engine server answered HTTP ${response.status}: ${json?.error || '(no message)'}`);
132
+ throw new uag_sdk_1.EngineError(`The engine server answered HTTP ${response.status}.`);
133
+ }
134
+ return json;
135
+ }
136
+ /** Validate a response, reporting a malformed one as an engine failure. */
137
+ function valid(check, body) {
138
+ try {
139
+ return check(body);
140
+ }
141
+ catch (err) {
142
+ throw new uag_sdk_1.EngineError(`The engine server's answer did not follow the remote engine protocol: ${err.message}`);
143
+ }
144
+ }
145
+ /** An engine object for a described remote engine. */
146
+ function remoteEngine(connection, remoteName, described) {
147
+ const path = `/v1/engines/${encodeURIComponent(remoteName)}`;
148
+ if (described.kind === 'decision') {
149
+ return {
150
+ name: connection.name,
151
+ kind: 'decision',
152
+ apiVersion: uag_sdk_1.ENGINE_API_VERSION,
153
+ model: described.model,
154
+ maxOptions: described.maxOptions,
155
+ async decide({ state, questions, signal }) {
156
+ const response = valid(protocol_1.checkDecisionResponse, await call(connection, `${path}/decide`, { state, questions }, signal));
157
+ return { ...response, model: response.model || described.model };
158
+ },
159
+ };
160
+ }
161
+ return {
162
+ name: connection.name,
163
+ kind: 'agent',
164
+ apiVersion: uag_sdk_1.ENGINE_API_VERSION,
165
+ model: described.model,
166
+ toolUse: described.toolUse === true,
167
+ async extract(request) {
168
+ const research = !!request.tools?.length;
169
+ // The engine server researches through its own MCP connection to the
170
+ // UAG, which needs a scoped token; without one (no JWT_SECRET), no call.
171
+ if (research && !request.scopedToken) {
172
+ throw new uag_sdk_1.EngineError('The remote engine cannot research without a scoped token. Set JWT_SECRET on the UAG.');
173
+ }
174
+ const body = {
175
+ instructions: request.instructions,
176
+ state: request.state,
177
+ fields: request.fields,
178
+ schema: request.schema,
179
+ ...(research && { tools: request.tools, maxIterations: request.maxIterations || 10 }),
180
+ };
181
+ const response = valid(protocol_1.checkExtractResponse, await call(connection, `${path}/extract`, body, request.signal, research ? request.scopedToken : undefined));
182
+ return { ...response, model: response.model || described.model };
183
+ },
184
+ };
185
+ }
186
+ exports.RemoteEngine = (0, uag_sdk_1.defineEngine)({
187
+ type: 'remote',
188
+ kinds: ['decision', 'agent'],
189
+ apiVersion: uag_sdk_1.ENGINE_API_VERSION,
190
+ description: 'An engine hosted by an engine server, over the remote engine protocol.',
191
+ settings: {
192
+ url: { required: true, description: 'The engine server, such as http://engines:3300.' },
193
+ key: { required: true, secret: true, description: `The engine server key, at least ${exports.MIN_KEY_LENGTH} characters.` },
194
+ engine: { description: 'The engine\'s name on the engine server. Default: the instance name.' },
195
+ timeout: { type: 'number', default: 10000, description: 'Milliseconds for each attempt to describe the engine at startup.' },
196
+ },
197
+ async create({ name, settings, options, config }) {
198
+ let url;
199
+ try {
200
+ url = new URL(settings.url);
201
+ }
202
+ catch {
203
+ throw new uag_sdk_1.EngineConfigError(`The "${name}" engine's url setting is not a URL: ${JSON.stringify(settings.url)}.`);
204
+ }
205
+ if (!['http:', 'https:'].includes(url.protocol)) {
206
+ throw new uag_sdk_1.EngineConfigError(`The "${name}" engine's url setting must be an http or https URL.`);
207
+ }
208
+ if (url.username || url.password) {
209
+ throw new uag_sdk_1.EngineConfigError(`The "${name}" engine's url setting must not include a user name or password: its key setting authenticates the UAG to the engine server.`);
210
+ }
211
+ if (String(settings.key).length < exports.MIN_KEY_LENGTH) {
212
+ throw new uag_sdk_1.EngineConfigError(`The "${name}" engine's key setting must be at least ${exports.MIN_KEY_LENGTH} characters, the same key as the engine server's UAG_ENGINE_SERVER_KEY.`);
213
+ }
214
+ const connection = {
215
+ name,
216
+ baseUrl: url.href.replace(/\/+$/, ''),
217
+ key: settings.key,
218
+ fetch: options.fetch || fetch,
219
+ };
220
+ const remoteName = settings.engine || name;
221
+ const described = await describeRemote(connection, remoteName, {
222
+ retries: config.retries,
223
+ retryDelay: config.retryDelay,
224
+ timeout: settings.timeout,
225
+ });
226
+ debug(`"${name}" is the ${described.kind} engine "${remoteName}" (${described.model}) at ${connection.baseUrl}.`);
227
+ return remoteEngine(connection, remoteName, described);
228
+ },
229
+ });
@@ -0,0 +1,4 @@
1
+ /** Run `fn` as part of the request `requestId`. */
2
+ export declare function withRequestId<T>(requestId: string | undefined, fn: () => T): T;
3
+ /** The id of the request being served, if any. */
4
+ export declare function currentRequestId(): string | undefined;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withRequestId = withRequestId;
4
+ exports.currentRequestId = currentRequestId;
5
+ const async_hooks_1 = require("async_hooks");
6
+ /**
7
+ * The Agent API request an operation runs for, so the `remote` engine type can
8
+ * send its id to the engine server: the engine contract carries no request id,
9
+ * and Formflow calls engines within the operation's async context.
10
+ */
11
+ const current = new async_hooks_1.AsyncLocalStorage();
12
+ /** Run `fn` as part of the request `requestId`. */
13
+ function withRequestId(requestId, fn) {
14
+ return current.run(requestId, fn);
15
+ }
16
+ /** The id of the request being served, if any. */
17
+ function currentRequestId() {
18
+ return current.getStore();
19
+ }
@@ -0,0 +1,31 @@
1
+ import { RequestHandler, Router } from "express";
2
+ import type { UAGProjectInterface } from "../UAGProjectInterface";
3
+ /** The largest request body an agent operation takes, in bytes. */
4
+ export declare const AGENT_BODY_LIMIT: number;
5
+ /**
6
+ * `GET /agent/capabilities`, behind `authenticate`. Only this path is claimed:
7
+ * any other `/agent...` path (a project form at `agent`, for one) falls through
8
+ * to the routes after this router.
9
+ */
10
+ export declare function AgentRouter(project: UAGProjectInterface, authenticate: RequestHandler): Router;
11
+ /**
12
+ * A form's agent operation for a path that names no form of the project (the
13
+ * old root operations, a deleted form): answered 404 here, so it is never
14
+ * passed through to Form.io. A form that exists goes on to its own routes,
15
+ * which answer 404 themselves when it is not tagged `uag`.
16
+ */
17
+ export declare function agentNotFound(project: UAGProjectInterface): RequestHandler;
18
+ /**
19
+ * Who may call a form's agent operations, on top of the project's own
20
+ * authentication (Form.io user tokens, portal remote tokens, server keys):
21
+ * scoped tokens never (they are for /mcp), server keys only from servers, and
22
+ * UAG bearer tokens, verified here. A caller with no credential gets through
23
+ * only where the form allows anonymous use (see anonymousGate).
24
+ */
25
+ export declare function agentCredentials(project: UAGProjectInterface): RequestHandler;
26
+ /**
27
+ * A form's agent operations, `POST /agent/extract|decide|generate|chat`, mounted on
28
+ * the form's own routes (see UAGFormInterface.router), so `req.form` is the
29
+ * form of the URL.
30
+ */
31
+ export declare function FormAgentRouter(project: UAGProjectInterface, credentials: RequestHandler): Router;
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AGENT_BODY_LIMIT = void 0;
37
+ exports.AgentRouter = AgentRouter;
38
+ exports.agentNotFound = agentNotFound;
39
+ exports.agentCredentials = agentCredentials;
40
+ exports.FormAgentRouter = FormAgentRouter;
41
+ const crypto_1 = require("crypto");
42
+ const lodash_1 = require("lodash");
43
+ const express_1 = __importStar(require("express"));
44
+ const protocol_1 = require("@formio/uag-sdk/protocol");
45
+ const formflow_1 = require("../formflow");
46
+ const requestId_1 = require("./requestId");
47
+ const anonymous_1 = require("./anonymous");
48
+ const chat_1 = require("./chat");
49
+ const debug = require('debug')('formio:uag:agent');
50
+ function wantsStream(req) {
51
+ return String(req.get('accept') || '').includes('text/event-stream');
52
+ }
53
+ /** Server-Sent Events over the POST response. */
54
+ function openStream(res) {
55
+ res.status(200).set({
56
+ 'Content-Type': 'text/event-stream',
57
+ 'Cache-Control': 'no-cache',
58
+ 'Connection': 'keep-alive',
59
+ 'X-Accel-Buffering': 'no'
60
+ });
61
+ res.flushHeaders?.();
62
+ return (event, data) => {
63
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
64
+ };
65
+ }
66
+ /** Who is asking, and who the operation runs as. */
67
+ function contextOf(req) {
68
+ return { auth: req.auth, requestId: req.requestId, identity: (0, formflow_1.callerIdentity)(req.auth, req) };
69
+ }
70
+ /** The task the request names, if any. */
71
+ function taskOf(req) {
72
+ const task = req.body?.task;
73
+ return typeof task === 'string' && task ? task : undefined;
74
+ }
75
+ /**
76
+ * Answer extract, streaming per-engine results when the client asks for a
77
+ * stream: decision results arrive before slower LLM results.
78
+ */
79
+ async function respondWithStream(req, res, run) {
80
+ const stream = wantsStream(req);
81
+ let send = null;
82
+ const done = await run({
83
+ ...contextOf(req),
84
+ onStart: () => {
85
+ if (stream)
86
+ send = openStream(res);
87
+ },
88
+ onResult: (result) => {
89
+ if (!send)
90
+ return;
91
+ send('result', (0, formflow_1.resultEvent)(result));
92
+ if (result.error)
93
+ send('error', { kind: result.kind, task: result.task, error: result.error, attempts: result.attempts });
94
+ }
95
+ });
96
+ if (send) {
97
+ send('done', done);
98
+ res.end();
99
+ }
100
+ else {
101
+ res.json(done);
102
+ }
103
+ }
104
+ /** The largest request body an agent operation takes, in bytes. */
105
+ exports.AGENT_BODY_LIMIT = 5 * 1024 * 1024;
106
+ /** The parts every Agent API endpoint shares: the request id, and errors answered as JSON (or as an event mid-stream). */
107
+ function agentEndpoints(project) {
108
+ const parse = express_1.default.json({ limit: exports.AGENT_BODY_LIMIT });
109
+ // The server parses JSON bodies first, with its own larger limit, so the
110
+ // agent operations' limit is checked here on a body already read.
111
+ const json = (req, res, next) => {
112
+ if (req.body === undefined)
113
+ return parse(req, res, next);
114
+ const length = Number(req.get('content-length')) || Buffer.byteLength(JSON.stringify(req.body ?? null));
115
+ if (length > exports.AGENT_BODY_LIMIT) {
116
+ return res.status(413).json({ error: `The request body is larger than the agent operations' limit of ${exports.AGENT_BODY_LIMIT / (1024 * 1024)} MB.` });
117
+ }
118
+ next();
119
+ };
120
+ // Request id, after authentication: refused requests carry none. One that
121
+ // is not a short token is replaced, so it is never echoed, logged or sent on.
122
+ const requestId = (req, res, next) => {
123
+ req.requestId = (0, protocol_1.safeRequestId)(req.get('x-request-id')) || (0, crypto_1.randomUUID)();
124
+ res.set('X-Request-Id', req.requestId);
125
+ next();
126
+ };
127
+ const handle = (operation, fn) => async (req, res) => {
128
+ const agentReq = req;
129
+ const service = (0, formflow_1.formflow)(project);
130
+ try {
131
+ service.takeCaller(operation, agentReq.auth);
132
+ // Remote engines send the request id on to the engine server.
133
+ await (0, requestId_1.withRequestId)(agentReq.requestId, () => fn(agentReq, res, service));
134
+ }
135
+ catch (err) {
136
+ const status = err instanceof formflow_1.FormflowError ? err.status : 500;
137
+ if (status === 500) {
138
+ debug(`[${agentReq.requestId}] ${operation} failed:`, err);
139
+ }
140
+ if (err instanceof formflow_1.RateLimitError) {
141
+ res.set('Retry-After', String(Math.ceil(err.retryAfterMs / 1000)));
142
+ }
143
+ const body = {
144
+ requestId: agentReq.requestId,
145
+ error: status === 500 ? 'The request failed. See the UAG logs for this request id.' : err.message,
146
+ ...(err instanceof formflow_1.FormflowError && err.code && { code: err.code }),
147
+ ...(err?.edition && { edition: err.edition })
148
+ };
149
+ if (res.headersSent) {
150
+ // Mid-stream: report the failure as an event and close.
151
+ res.write(`event: error\ndata: ${JSON.stringify(body)}\n\n`);
152
+ res.end();
153
+ }
154
+ else {
155
+ res.status(status).json(body);
156
+ }
157
+ }
158
+ };
159
+ return { json, requestId, handle };
160
+ }
161
+ /**
162
+ * `GET /agent/capabilities`, behind `authenticate`. Only this path is claimed:
163
+ * any other `/agent...` path (a project form at `agent`, for one) falls through
164
+ * to the routes after this router.
165
+ */
166
+ function AgentRouter(project, authenticate) {
167
+ const router = (0, express_1.Router)();
168
+ const { json, requestId, handle } = agentEndpoints(project);
169
+ router.get('/agent/capabilities', authenticate, json, requestId, handle('capabilities', async (req, res, service) => {
170
+ const capabilities = await service.capabilities();
171
+ // Chat is part of conversation forms: it needs an agent engine, never a license.
172
+ const engines = (0, chat_1.chatEngines)(project).map((engine) => engine.name);
173
+ capabilities.operations.chat = { available: engines.length > 0, engines };
174
+ res.json({ requestId: req.requestId, ...capabilities });
175
+ }));
176
+ return router;
177
+ }
178
+ /**
179
+ * A form's agent operation for a path that names no form of the project (the
180
+ * old root operations, a deleted form): answered 404 here, so it is never
181
+ * passed through to Form.io. A form that exists goes on to its own routes,
182
+ * which answer 404 themselves when it is not tagged `uag`.
183
+ */
184
+ function agentNotFound(project) {
185
+ // Express matches routes ignoring case, so this does too.
186
+ const operation = /^(.*)\/agent\/(extract|decide|generate|chat)\/?$/i;
187
+ return async (req, res, next) => {
188
+ const match = req.method === 'POST' ? operation.exec(req.path) : null;
189
+ if (!match)
190
+ return next();
191
+ // The form's path under the project's mount: the root on Open Source,
192
+ // otherwise `/{name}` or `/project/{id}`, and nothing else.
193
+ const mounts = [/^\/project\/[0-9a-fA-F]{24}(?=\/|$)/i];
194
+ if (project.name)
195
+ mounts.push(new RegExp(`^/${(0, lodash_1.escapeRegExp)(project.name)}(?=/|$)`, 'i'));
196
+ let formPath = match[1];
197
+ const mount = mounts.find((pattern) => pattern.test(formPath));
198
+ if (mount)
199
+ formPath = formPath.replace(mount, '');
200
+ else if (!project.oss)
201
+ formPath = '';
202
+ formPath = formPath.replace(/^\/+/, '');
203
+ const byId = /^form\/([0-9a-fA-F]{24})$/i.exec(formPath);
204
+ const forms = Object.values(project.forms || {});
205
+ const known = byId
206
+ ? forms.some((form) => String(form.form?._id || '') === byId[1]) || !!(await project.getKnownFormById?.(byId[1]))
207
+ : !!formPath && forms.some((form) => String(form.path || '').toLowerCase() === formPath.toLowerCase());
208
+ if (known)
209
+ return next();
210
+ res.status(404).json({ error: `The form "${byId ? byId[1] : formPath}" does not exist or is not tagged "uag".` });
211
+ };
212
+ }
213
+ function refuse(res, detail) {
214
+ return res.status(401).json({ detail });
215
+ }
216
+ /**
217
+ * Who may call a form's agent operations, on top of the project's own
218
+ * authentication (Form.io user tokens, portal remote tokens, server keys):
219
+ * scoped tokens never (they are for /mcp), server keys only from servers, and
220
+ * UAG bearer tokens, verified here. A caller with no credential gets through
221
+ * only where the form allows anonymous use (see anonymousGate).
222
+ */
223
+ function agentCredentials(project) {
224
+ const anonymous = (0, anonymous_1.anonymousGate)(project);
225
+ return (req, res, next) => {
226
+ const request = req;
227
+ const authorization = req.get('authorization');
228
+ const bearer = (0, formflow_1.bearerOf)(authorization);
229
+ // Scoped tokens are for a task's calls back into /mcp. They must never
230
+ // start new work: an admin scoped token would otherwise grant full
231
+ // admin access to the agent operations for its lifetime.
232
+ if (bearer && (0, formflow_1.isScopedToken)(bearer)) {
233
+ return refuse(res, 'Scoped tokens can only be used on /mcp.');
234
+ }
235
+ if (req.get('x-token') !== undefined || req.get('x-admin-key') !== undefined) {
236
+ // Browsers send Origin on cross-origin requests, and Sec-Fetch-Site on all
237
+ // of them. (Node's fetch sends Sec-Fetch-Mode but not Sec-Fetch-Site.)
238
+ if (req.get('origin') || req.get('sec-fetch-site')) {
239
+ return refuse(res, 'Admin credentials cannot be used from a browser. Use a bearer token.');
240
+ }
241
+ return request.auth?.admin ? next() : refuse(res, 'Invalid admin credential.');
242
+ }
243
+ if (request.auth?.admin || request.auth?.user) {
244
+ return next();
245
+ }
246
+ if (bearer) {
247
+ const form = request.form;
248
+ return project.authorizeRequest(request, res, () => {
249
+ if (request.auth)
250
+ request.auth.form = form || null;
251
+ next();
252
+ });
253
+ }
254
+ return anonymous(req, res, next);
255
+ };
256
+ }
257
+ /**
258
+ * A form's agent operations, `POST /agent/extract|decide|generate|chat`, mounted on
259
+ * the form's own routes (see UAGFormInterface.router), so `req.form` is the
260
+ * form of the URL.
261
+ */
262
+ function FormAgentRouter(project, credentials) {
263
+ const router = (0, express_1.Router)();
264
+ const { json, requestId, handle } = agentEndpoints(project);
265
+ const route = (path, operation, fn) => {
266
+ router.post(`/agent/${path}`, credentials, json, requestId, handle(operation, fn));
267
+ };
268
+ const formOf = (req) => req.form?.name;
269
+ route('extract', 'extract', async (req, res, service) => {
270
+ const { text, data, overwrite } = req.body || {};
271
+ const task = taskOf(req);
272
+ await respondWithStream(req, res, (context) => service.extract({ form: formOf(req), task, text, data, overwrite }, context));
273
+ });
274
+ route('decide', 'decide', async (req, res, service) => {
275
+ const { data, submissionId, overwrite } = req.body || {};
276
+ res.json(await service.decide({ form: formOf(req), task: taskOf(req), data, submissionId, overwrite }, contextOf(req)));
277
+ });
278
+ route('generate', 'generate', async (req, res, service) => {
279
+ const { data, overwrite } = req.body || {};
280
+ res.json(await service.generate({ form: formOf(req), task: taskOf(req), data, overwrite }, contextOf(req)));
281
+ });
282
+ // A turn of the form's chat. Not Agentic Formflow: no license check, as for the MCP tools.
283
+ route('chat', 'chat', async (req, res, service) => {
284
+ const form = await project.formflowForm(formOf(req));
285
+ if (!form) {
286
+ throw new formflow_1.FormflowError(404, `The form "${formOf(req)}" does not exist or is not tagged "uag".`);
287
+ }
288
+ if (String(form.form?.properties?.uagChat) === 'false') {
289
+ throw new formflow_1.FormflowError(403, `Chat is turned off for the form "${form.formflowName}".`);
290
+ }
291
+ service.requireAccess(req.auth, form, ['create']);
292
+ res.json(await (0, chat_1.chatTurn)(project, form, req.body, { auth: req.auth, requestId: req.requestId }));
293
+ });
294
+ return router;
295
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Stand-in engines for tests and local development. They answer instantly with
3
+ * predictable values, so the Agent API can be exercised end to end without
4
+ * model credentials: `UAG_ENGINES=stub-decision,stub-agent`. Core creates
5
+ * them, so they keep the standing that lets them run without a license under
6
+ * any instance name.
7
+ */
8
+ export declare const StubDecisionEngine: Readonly<{
9
+ type: string;
10
+ kinds: "decision"[];
11
+ apiVersion: 1;
12
+ description: string;
13
+ create: ({ name }: import("@formio/uag-sdk").EngineCreateContext<any>) => import("@formio/appserver-core/lib/formflow").DecisionEngine & {
14
+ calls: number;
15
+ };
16
+ }>;
17
+ export declare const StubAgentEngine: Readonly<{
18
+ type: string;
19
+ kinds: "agent"[];
20
+ apiVersion: 1;
21
+ description: string;
22
+ create: ({ name }: import("@formio/uag-sdk").EngineCreateContext<any>) => import("@formio/appserver-core/lib/formflow").AgentEngine & {
23
+ calls: number;
24
+ lastExtract?: import("@formio/appserver-core/lib/formflow").ExtractRequest;
25
+ };
26
+ }>;