@formio/uag 2.0.0-rc.1 → 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 +17 -15
- package/bin/uag-eval.js +7 -3
- package/bin/uag.js +48 -5
- package/lib/UAGFormInterface.d.ts +12 -0
- package/lib/UAGFormInterface.js +55 -12
- package/lib/UAGProjectInterface.d.ts +9 -1
- package/lib/UAGProjectInterface.js +25 -7
- package/lib/actionLogs.d.ts +1 -10
- package/lib/actionLogs.js +5 -3
- package/lib/actions/runTask.js +4 -3
- package/lib/agent/anonymous.js +5 -4
- package/lib/agent/chat.d.ts +3 -2
- package/lib/agent/chat.js +35 -99
- package/lib/agent/engines.d.ts +10 -6
- package/lib/agent/engines.js +19 -10
- package/lib/agent/errors.d.ts +25 -0
- package/lib/agent/errors.js +132 -0
- package/lib/agent/index.d.ts +1 -1
- package/lib/agent/index.js +2 -1
- package/lib/agent/remote.d.ts +5 -1
- package/lib/agent/remote.js +25 -2
- package/lib/agent/router.d.ts +32 -2
- package/lib/agent/router.js +37 -25
- package/lib/agent/stubs.d.ts +13 -2
- package/lib/agent/stubs.js +18 -5
- package/lib/agent/tools.d.ts +24 -3
- package/lib/agent/tools.js +47 -4
- package/lib/config.d.ts +31 -4
- package/lib/database.d.ts +3 -8
- package/lib/database.js +2 -19
- 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 +11 -4
- package/lib/formflow.js +5 -5
- package/lib/index.d.ts +65 -9
- package/lib/index.js +228 -26
- 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 +19 -0
- package/lib/runner.d.ts +2 -0
- package/lib/runner.js +11 -3
- package/lib/runs.d.ts +17 -79
- package/lib/runs.js +10 -71
- package/lib/testing.d.ts +74 -0
- package/lib/testing.js +180 -0
- package/lib/tools/SchemaBuilder.d.ts +0 -5
- package/lib/tools/SchemaBuilder.js +0 -8
- package/lib/tools/agentDecide.js +2 -2
- package/lib/tools/agentProvide.d.ts +0 -5
- package/lib/tools/agentProvide.js +7 -40
- package/lib/tools/collectData.js +2 -1
- package/lib/tools/index.d.ts +3 -2
- package/lib/tools/index.js +5 -5
- package/lib/tools/readDocument.d.ts +17 -0
- package/lib/tools/readDocument.js +99 -0
- package/lib/tools/submissionUpdate.js +3 -2
- 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 +17 -10
- package/testing.d.ts +1 -0
- package/testing.js +2 -0
package/lib/agent/router.js
CHANGED
|
@@ -34,14 +34,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.AGENT_BODY_LIMIT = void 0;
|
|
37
|
+
exports.agentBodyLimit = agentBodyLimit;
|
|
38
|
+
exports.agentEndpoints = agentEndpoints;
|
|
37
39
|
exports.AgentRouter = AgentRouter;
|
|
38
40
|
exports.agentNotFound = agentNotFound;
|
|
39
41
|
exports.agentCredentials = agentCredentials;
|
|
40
42
|
exports.FormAgentRouter = FormAgentRouter;
|
|
41
|
-
const crypto_1 = require("crypto");
|
|
42
43
|
const lodash_1 = require("lodash");
|
|
43
44
|
const express_1 = __importStar(require("express"));
|
|
44
|
-
const
|
|
45
|
+
const errors_1 = require("./errors");
|
|
45
46
|
const formflow_1 = require("../formflow");
|
|
46
47
|
const requestId_1 = require("./requestId");
|
|
47
48
|
const anonymous_1 = require("./anonymous");
|
|
@@ -67,10 +68,17 @@ function openStream(res) {
|
|
|
67
68
|
function contextOf(req) {
|
|
68
69
|
return { auth: req.auth, requestId: req.requestId, identity: (0, formflow_1.callerIdentity)(req.auth, req) };
|
|
69
70
|
}
|
|
70
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The task the request names, if any. A request that still names a 1.x
|
|
73
|
+
* `persona` is refused: ignoring it would run every task, an engine call each.
|
|
74
|
+
*/
|
|
71
75
|
function taskOf(req) {
|
|
76
|
+
const named = (value) => typeof value === 'string' && value.trim() !== '';
|
|
72
77
|
const task = req.body?.task;
|
|
73
|
-
|
|
78
|
+
if (named(req.body?.persona) && !named(task)) {
|
|
79
|
+
throw new formflow_1.FormflowError(400, 'UAG 2.0 renamed "persona" to "task". Send "task" instead.');
|
|
80
|
+
}
|
|
81
|
+
return named(task) ? task : undefined;
|
|
74
82
|
}
|
|
75
83
|
/**
|
|
76
84
|
* Answer extract, streaming per-engine results when the client asks for a
|
|
@@ -101,27 +109,37 @@ async function respondWithStream(req, res, run) {
|
|
|
101
109
|
res.json(done);
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
|
-
/** The largest request body an agent operation takes, in bytes. */
|
|
112
|
+
/** The largest request body an agent operation takes by default, in bytes. */
|
|
105
113
|
exports.AGENT_BODY_LIMIT = 5 * 1024 * 1024;
|
|
114
|
+
/**
|
|
115
|
+
* The largest request body the project's agent operations take: `agentApi.bodyLimit`,
|
|
116
|
+
* then AGENT_BODY_LIMIT, then 5 MB. A document sent inline (a base64 file a task reads)
|
|
117
|
+
* may need more; the server's own MAX_BODY_SIZE (16 MB) still applies first.
|
|
118
|
+
*/
|
|
119
|
+
function agentBodyLimit(project) {
|
|
120
|
+
const configured = Number(project.config?.agentApi?.bodyLimit ?? process.env.AGENT_BODY_LIMIT);
|
|
121
|
+
return Number.isFinite(configured) && configured > 0 ? configured : exports.AGENT_BODY_LIMIT;
|
|
122
|
+
}
|
|
106
123
|
/** The parts every Agent API endpoint shares: the request id, and errors answered as JSON (or as an event mid-stream). */
|
|
107
124
|
function agentEndpoints(project) {
|
|
108
|
-
const
|
|
125
|
+
const limit = agentBodyLimit(project);
|
|
126
|
+
const parse = express_1.default.json({ limit });
|
|
109
127
|
// The server parses JSON bodies first, with its own larger limit, so the
|
|
110
128
|
// agent operations' limit is checked here on a body already read.
|
|
111
129
|
const json = (req, res, next) => {
|
|
112
130
|
if (req.body === undefined)
|
|
113
131
|
return parse(req, res, next);
|
|
114
132
|
const length = Number(req.get('content-length')) || Buffer.byteLength(JSON.stringify(req.body ?? null));
|
|
115
|
-
if (length >
|
|
116
|
-
return res.status(413).json({ error: `The request body is larger than the agent operations' limit of ${
|
|
133
|
+
if (length > limit) {
|
|
134
|
+
return res.status(413).json({ error: `The request body is larger than the agent operations' limit of ${Math.round(limit / (1024 * 1024) * 10) / 10} MB.`, code: 'payload_too_large' });
|
|
117
135
|
}
|
|
118
136
|
next();
|
|
119
137
|
};
|
|
120
|
-
//
|
|
121
|
-
// is not a short token is replaced, so it is never echoed,
|
|
138
|
+
// The request id, usually assigned already by agentErrorShape, ahead of every
|
|
139
|
+
// refusal. One that is not a short token is replaced, so it is never echoed,
|
|
140
|
+
// logged or sent on.
|
|
122
141
|
const requestId = (req, res, next) => {
|
|
123
|
-
|
|
124
|
-
res.set('X-Request-Id', req.requestId);
|
|
142
|
+
(0, errors_1.requestIdOf)(req, res);
|
|
125
143
|
next();
|
|
126
144
|
};
|
|
127
145
|
const handle = (operation, fn) => async (req, res) => {
|
|
@@ -133,19 +151,13 @@ function agentEndpoints(project) {
|
|
|
133
151
|
await (0, requestId_1.withRequestId)(agentReq.requestId, () => fn(agentReq, res, service));
|
|
134
152
|
}
|
|
135
153
|
catch (err) {
|
|
136
|
-
const status =
|
|
154
|
+
const { status, body } = (0, errors_1.agentError)(agentReq.requestId, err);
|
|
137
155
|
if (status === 500) {
|
|
138
156
|
debug(`[${agentReq.requestId}] ${operation} failed:`, err);
|
|
139
157
|
}
|
|
140
158
|
if (err instanceof formflow_1.RateLimitError) {
|
|
141
159
|
res.set('Retry-After', String(Math.ceil(err.retryAfterMs / 1000)));
|
|
142
160
|
}
|
|
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
161
|
if (res.headersSent) {
|
|
150
162
|
// Mid-stream: report the failure as an event and close.
|
|
151
163
|
res.write(`event: error\ndata: ${JSON.stringify(body)}\n\n`);
|
|
@@ -207,11 +219,11 @@ function agentNotFound(project) {
|
|
|
207
219
|
: !!formPath && forms.some((form) => String(form.path || '').toLowerCase() === formPath.toLowerCase());
|
|
208
220
|
if (known)
|
|
209
221
|
return next();
|
|
210
|
-
res.status(404).json({ error: `The form "${byId ? byId[1] : formPath}" does not exist or is not tagged "uag"
|
|
222
|
+
res.status(404).json({ error: `The form "${byId ? byId[1] : formPath}" does not exist or is not tagged "uag".`, code: 'form_not_found' });
|
|
211
223
|
};
|
|
212
224
|
}
|
|
213
|
-
function refuse(res,
|
|
214
|
-
return res.status(401).json({
|
|
225
|
+
function refuse(res, error) {
|
|
226
|
+
return res.status(401).json({ error, code: 'unauthorized' });
|
|
215
227
|
}
|
|
216
228
|
/**
|
|
217
229
|
* Who may call a form's agent operations, on top of the project's own
|
|
@@ -283,10 +295,10 @@ function FormAgentRouter(project, credentials) {
|
|
|
283
295
|
route('chat', 'chat', async (req, res, service) => {
|
|
284
296
|
const form = await project.formflowForm(formOf(req));
|
|
285
297
|
if (!form) {
|
|
286
|
-
throw new formflow_1.FormflowError(404, `The form "${formOf(req)}" does not exist or is not tagged "uag"
|
|
298
|
+
throw new formflow_1.FormflowError(404, `The form "${formOf(req)}" does not exist or is not tagged "uag".`, 'form_not_found');
|
|
287
299
|
}
|
|
288
|
-
if (
|
|
289
|
-
throw new formflow_1.FormflowError(403, `Chat is turned off for the form "${form.formflowName}"
|
|
300
|
+
if ((0, formflow_1.flagOff)(form.form?.properties?.uagChat)) {
|
|
301
|
+
throw new formflow_1.FormflowError(403, `Chat is turned off for the form "${form.formflowName}".`, 'chat_disabled');
|
|
290
302
|
}
|
|
291
303
|
service.requireAccess(req.auth, form, ['create']);
|
|
292
304
|
res.json(await (0, chat_1.chatTurn)(project, form, req.body, { auth: req.auth, requestId: req.requestId }));
|
package/lib/agent/stubs.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GenerateRequest } from '@formio/uag-sdk';
|
|
1
2
|
/**
|
|
2
3
|
* Stand-in engines for tests and local development. They answer instantly with
|
|
3
4
|
* predictable values, so the Agent API can be exercised end to end without
|
|
@@ -6,7 +7,9 @@
|
|
|
6
7
|
* any instance name.
|
|
7
8
|
*/
|
|
8
9
|
export declare const StubDecisionEngine: Readonly<{
|
|
9
|
-
type:
|
|
10
|
+
type: "engine";
|
|
11
|
+
name: string;
|
|
12
|
+
title: string;
|
|
10
13
|
kinds: "decision"[];
|
|
11
14
|
apiVersion: 1;
|
|
12
15
|
description: string;
|
|
@@ -14,8 +17,16 @@ export declare const StubDecisionEngine: Readonly<{
|
|
|
14
17
|
calls: number;
|
|
15
18
|
};
|
|
16
19
|
}>;
|
|
20
|
+
/** How many generate requests the stub agent engines keep: a UAG running them for a demo never grows without bound. */
|
|
21
|
+
export declare const STUB_GENERATE_REQUESTS_KEPT = 20;
|
|
22
|
+
/** The last generate requests the stub agent engines received (STUB_GENERATE_REQUESTS_KEPT), oldest first, for tests. */
|
|
23
|
+
export declare const stubGenerateRequests: Array<Omit<GenerateRequest, 'signal' | 'onProgress'> & {
|
|
24
|
+
engine: string;
|
|
25
|
+
}>;
|
|
17
26
|
export declare const StubAgentEngine: Readonly<{
|
|
18
|
-
type:
|
|
27
|
+
type: "engine";
|
|
28
|
+
name: string;
|
|
29
|
+
title: string;
|
|
19
30
|
kinds: "agent"[];
|
|
20
31
|
apiVersion: 1;
|
|
21
32
|
description: string;
|
package/lib/agent/stubs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.StubAgentEngine = exports.StubDecisionEngine = void 0;
|
|
3
|
+
exports.StubAgentEngine = exports.stubGenerateRequests = exports.STUB_GENERATE_REQUESTS_KEPT = exports.StubDecisionEngine = void 0;
|
|
4
4
|
const uag_sdk_1 = require("@formio/uag-sdk");
|
|
5
5
|
const formflow_1 = require("../formflow");
|
|
6
6
|
/**
|
|
@@ -11,16 +11,29 @@ const formflow_1 = require("../formflow");
|
|
|
11
11
|
* any instance name.
|
|
12
12
|
*/
|
|
13
13
|
exports.StubDecisionEngine = (0, uag_sdk_1.defineEngine)({
|
|
14
|
-
type: '
|
|
14
|
+
type: 'engine',
|
|
15
|
+
name: 'stub-decision',
|
|
16
|
+
title: 'Stub decision engine',
|
|
15
17
|
kinds: ['decision'],
|
|
16
18
|
apiVersion: uag_sdk_1.ENGINE_API_VERSION,
|
|
17
19
|
description: 'Answers every decision with a fixed, predictable value. For tests and local development.',
|
|
18
20
|
create: ({ name }) => (0, formflow_1.stubDecisionEngine)({ name }),
|
|
19
21
|
});
|
|
22
|
+
/** How many generate requests the stub agent engines keep: a UAG running them for a demo never grows without bound. */
|
|
23
|
+
exports.STUB_GENERATE_REQUESTS_KEPT = 20;
|
|
24
|
+
/** The last generate requests the stub agent engines received (STUB_GENERATE_REQUESTS_KEPT), oldest first, for tests. */
|
|
25
|
+
exports.stubGenerateRequests = [];
|
|
20
26
|
exports.StubAgentEngine = (0, uag_sdk_1.defineEngine)({
|
|
21
|
-
type: '
|
|
27
|
+
type: 'engine',
|
|
28
|
+
name: 'stub-agent',
|
|
29
|
+
title: 'Stub agent engine',
|
|
22
30
|
kinds: ['agent'],
|
|
23
31
|
apiVersion: uag_sdk_1.ENGINE_API_VERSION,
|
|
24
|
-
description: 'Extracts nothing. For tests and local development.',
|
|
25
|
-
|
|
32
|
+
description: 'Extracts nothing; generates a predictable answer from the text it is given. For tests and local development.',
|
|
33
|
+
// Text only; its generate answer shows what reached it. Observing its requests keeps it a built-in stub.
|
|
34
|
+
create: ({ name }) => (0, formflow_1.stubAgentEngine)({ name, onGenerate: (request) => {
|
|
35
|
+
exports.stubGenerateRequests.push({ ...request, engine: name });
|
|
36
|
+
if (exports.stubGenerateRequests.length > exports.STUB_GENERATE_REQUESTS_KEPT)
|
|
37
|
+
exports.stubGenerateRequests.splice(0, exports.stubGenerateRequests.length - exports.STUB_GENERATE_REQUESTS_KEPT);
|
|
38
|
+
} }),
|
|
26
39
|
});
|
package/lib/agent/tools.d.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import { FormflowTool } from "../formflow";
|
|
2
|
+
import type { AuthRequest } from "@formio/appserver";
|
|
2
3
|
import type { UAGProjectInterface } from "../UAGProjectInterface";
|
|
3
|
-
import { ToolInfo } from "../tools/utils";
|
|
4
|
-
/**
|
|
4
|
+
import { ToolEntry, ToolInfo } from "../tools/utils";
|
|
5
|
+
/** Record the module a tool came from: its `execute` gets the module's API as `extra.module`. */
|
|
6
|
+
export declare function setToolModule(entry: object, api: any): void;
|
|
7
|
+
/**
|
|
8
|
+
* A `config.tools` entry as a tool for the project: a bound tool's static
|
|
9
|
+
* fields, overlaid with what its `bind(project)` returns. A tool without
|
|
10
|
+
* `bind` (the 1.x shape) is used as it is.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveTool(project: UAGProjectInterface, entry: ToolEntry): Promise<ToolInfo | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Every tool the UAG serves, including custom tools from configuration, each
|
|
15
|
+
* entry of which is a tool, optionally bound to the project.
|
|
16
|
+
*/
|
|
5
17
|
export declare function allTools(project: UAGProjectInterface): Promise<ToolInfo[]>;
|
|
18
|
+
/**
|
|
19
|
+
* Run a tool with its arguments and the `ToolExtra` for the project: the same
|
|
20
|
+
* call on `/mcp` and for Agent API research. `context` is whatever else the
|
|
21
|
+
* caller has (on `/mcp`, the MCP SDK's request context, with `authInfo`).
|
|
22
|
+
*/
|
|
23
|
+
export declare function runTool(tool: ToolInfo, project: UAGProjectInterface, args: any, context: {
|
|
24
|
+
authInfo: AuthRequest;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}): any;
|
|
6
27
|
/**
|
|
7
28
|
* An MCP tool as an Agentic Formflow tool: its schema as JSON, its arguments
|
|
8
29
|
* validated with its own schema, and read-only only when annotated so.
|
|
9
30
|
*/
|
|
10
|
-
export declare function formflowTool(tool: ToolInfo): FormflowTool;
|
|
31
|
+
export declare function formflowTool(tool: ToolInfo, project: UAGProjectInterface): FormflowTool;
|
package/lib/agent/tools.js
CHANGED
|
@@ -3,19 +3,62 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.setToolModule = setToolModule;
|
|
7
|
+
exports.resolveTool = resolveTool;
|
|
6
8
|
exports.allTools = allTools;
|
|
9
|
+
exports.runTool = runTool;
|
|
7
10
|
exports.formflowTool = formflowTool;
|
|
8
11
|
const zod_1 = __importDefault(require("zod"));
|
|
9
12
|
const tools_1 = require("../tools");
|
|
10
|
-
|
|
13
|
+
const utils_1 = require("../tools/utils");
|
|
14
|
+
/** The module API of the module each `config.tools` entry came from, for its `extra.module`. */
|
|
15
|
+
const toolModules = new WeakMap();
|
|
16
|
+
/** Record the module a tool came from: its `execute` gets the module's API as `extra.module`. */
|
|
17
|
+
function setToolModule(entry, api) {
|
|
18
|
+
if (entry && typeof entry === 'object')
|
|
19
|
+
toolModules.set(entry, api);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A `config.tools` entry as a tool for the project: a bound tool's static
|
|
23
|
+
* fields, overlaid with what its `bind(project)` returns. A tool without
|
|
24
|
+
* `bind` (the 1.x shape) is used as it is.
|
|
25
|
+
*/
|
|
26
|
+
async function resolveTool(project, entry) {
|
|
27
|
+
if (!entry || typeof entry.bind !== 'function')
|
|
28
|
+
return entry;
|
|
29
|
+
const { bind, ...fixed } = entry;
|
|
30
|
+
const tool = { ...fixed, ...((await bind(project)) || {}) };
|
|
31
|
+
if (toolModules.has(entry))
|
|
32
|
+
toolModules.set(tool, toolModules.get(entry));
|
|
33
|
+
return tool;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Every tool the UAG serves, including custom tools from configuration, each
|
|
37
|
+
* entry of which is a tool, optionally bound to the project.
|
|
38
|
+
*/
|
|
11
39
|
async function allTools(project) {
|
|
12
|
-
|
|
40
|
+
(0, utils_1.assertToolEntries)(project.config?.tools);
|
|
41
|
+
const custom = await Promise.all((project.config?.tools || []).map((entry) => resolveTool(project, entry)));
|
|
42
|
+
return [...await (0, tools_1.getTools)(project), ...custom].filter((tool) => !!tool?.name);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run a tool with its arguments and the `ToolExtra` for the project: the same
|
|
46
|
+
* call on `/mcp` and for Agent API research. `context` is whatever else the
|
|
47
|
+
* caller has (on `/mcp`, the MCP SDK's request context, with `authInfo`).
|
|
48
|
+
*/
|
|
49
|
+
function runTool(tool, project, args, context) {
|
|
50
|
+
if (!tool.execute) {
|
|
51
|
+
return { content: [{ type: 'text', text: `The tool "${tool.name}" has no implementation.` }], isError: true };
|
|
52
|
+
}
|
|
53
|
+
const module = toolModules.get(tool);
|
|
54
|
+
const extra = { ...context, authInfo: context.authInfo, project, ...(module && { module }) };
|
|
55
|
+
return tool.execute(args ?? {}, extra);
|
|
13
56
|
}
|
|
14
57
|
/**
|
|
15
58
|
* An MCP tool as an Agentic Formflow tool: its schema as JSON, its arguments
|
|
16
59
|
* validated with its own schema, and read-only only when annotated so.
|
|
17
60
|
*/
|
|
18
|
-
function formflowTool(tool) {
|
|
61
|
+
function formflowTool(tool, project) {
|
|
19
62
|
const schema = zod_1.default.object(tool.inputSchema || {});
|
|
20
63
|
const inputSchema = zod_1.default.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' });
|
|
21
64
|
delete inputSchema.$schema;
|
|
@@ -28,6 +71,6 @@ function formflowTool(tool) {
|
|
|
28
71
|
const parsed = schema.safeParse(args || {});
|
|
29
72
|
return parsed.success ? { ok: true, value: parsed.data } : { ok: false, error: parsed.error.message };
|
|
30
73
|
},
|
|
31
|
-
execute: async (args, auth) => tool
|
|
74
|
+
execute: async (args, auth) => runTool(tool, project, args, { authInfo: auth })
|
|
32
75
|
};
|
|
33
76
|
}
|
package/lib/config.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ServerConfig } from '@formio/appserver';
|
|
2
2
|
import type { FormflowAgentApiConfig, FormflowRateLimit, FormflowRoutingConfig } from './formflow';
|
|
3
3
|
import type { EngineInstanceSpec, EngineTypeDefinition } from '@formio/uag-sdk';
|
|
4
|
-
import { ToolInfo } from './tools/utils';
|
|
4
|
+
import type { ToolEntry, ToolInfo } from './tools/utils';
|
|
5
5
|
import type { UAGDatabaseOptions } from './database';
|
|
6
6
|
export type UAGForm = {
|
|
7
7
|
name: string;
|
|
@@ -47,10 +47,32 @@ export type UAGToolOverride = {
|
|
|
47
47
|
submission_update?: ToolInfo;
|
|
48
48
|
find_submissions?: ToolInfo;
|
|
49
49
|
agent_provide?: ToolInfo;
|
|
50
|
-
/** @deprecated The earlier name of `agent_provide`; also applied to it when `agent_provide` is not set. */
|
|
51
|
-
agent_provide_data?: ToolInfo;
|
|
52
50
|
agent_decide?: ToolInfo;
|
|
53
51
|
fetch_external_data?: ToolInfo;
|
|
52
|
+
read_document?: ToolInfo;
|
|
53
|
+
};
|
|
54
|
+
/** `config.documents`: limits and choices of the AI Document module (@formio/uag-extras/modules/ai-document). */
|
|
55
|
+
export type UAGDocumentsConfig = {
|
|
56
|
+
/** Largest uploaded file read, in bytes (25 MB). DOCUMENTS_MAX_FILE_BYTES. */
|
|
57
|
+
maxFileBytes?: number;
|
|
58
|
+
/** Most files one component sends (10). */
|
|
59
|
+
maxFiles?: number;
|
|
60
|
+
/** Most bytes of one request's files (32 MB, the provider request cap). */
|
|
61
|
+
maxTotalBytes?: number;
|
|
62
|
+
/** Most pages once converted (500). DOCUMENTS_MAX_PAGES. */
|
|
63
|
+
maxPages?: number;
|
|
64
|
+
/** Most characters of text once converted (2,000,000). */
|
|
65
|
+
maxTextChars?: number;
|
|
66
|
+
/** One processing request, in ms (600000). DOCUMENTS_TIMEOUT_MS. */
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
/** Comment line interval on a streamed response, in ms (15000). */
|
|
69
|
+
heartbeatMs?: number;
|
|
70
|
+
/** Concurrent processing requests per identity (3). */
|
|
71
|
+
perUser?: number;
|
|
72
|
+
/** The engine instance documents use. Default: the first capable agent engine. DOCUMENTS_ENGINE. */
|
|
73
|
+
engine?: string;
|
|
74
|
+
/** Converter name by MIME type, when several can do a step. */
|
|
75
|
+
prefer?: Record<string, string>;
|
|
54
76
|
};
|
|
55
77
|
/**
|
|
56
78
|
* One engine instance: `"<type>"` or `"<name>:<type>"`, or `{ name, type, options }`, where the
|
|
@@ -87,6 +109,8 @@ export interface UAGChatConfig {
|
|
|
87
109
|
export type UAGAgentApiConfig = FormflowAgentApiConfig & {
|
|
88
110
|
anonymous?: UAGAnonymousConfig;
|
|
89
111
|
chat?: UAGChatConfig;
|
|
112
|
+
/** The largest request body an agent operation takes, in bytes (AGENT_BODY_LIMIT). Default 5 MB. */
|
|
113
|
+
bodyLimit?: number;
|
|
90
114
|
};
|
|
91
115
|
/**
|
|
92
116
|
* Express's `trust proxy` from TRUST_PROXY: `true` or `false`, a number of
|
|
@@ -100,8 +124,11 @@ export interface UAGConfig extends ServerConfig {
|
|
|
100
124
|
baseUrl?: string;
|
|
101
125
|
loginForm?: string;
|
|
102
126
|
responseTemplates?: UAGTemplateConfig;
|
|
127
|
+
/** Limits and choices of the AI Document module. */
|
|
128
|
+
documents?: UAGDocumentsConfig;
|
|
103
129
|
toolOverrides?: UAGToolOverride;
|
|
104
|
-
tools
|
|
130
|
+
/** Custom MCP tools: each a tool, optionally bound to the project with `bind(project)` (see ToolInfo.bind). */
|
|
131
|
+
tools?: ToolEntry[];
|
|
105
132
|
/**
|
|
106
133
|
* The engine instances to enable, as in `UAG_ENGINES` (`"jev"`, `"fast:claude"`) or as
|
|
107
134
|
* `{ name, type, options }`. When unset, `UAG_ENGINES` is used; when neither names any, no
|
package/lib/database.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Collection, Db, MongoClient, MongoClientOptions } from 'mongodb';
|
|
2
|
-
import type { AuthCodeStore } from '@formio/appserver
|
|
2
|
+
import type { AuthCodeStore } from '@formio/appserver';
|
|
3
3
|
/**
|
|
4
4
|
* The UAG's own database (MONGO, with the database named by UAG_DATABASE). It
|
|
5
|
-
* holds only the UAG's state: authorization codes for remote MCP clients,
|
|
6
|
-
*
|
|
5
|
+
* holds only the UAG's state: authorization codes for remote MCP clients, and
|
|
6
|
+
* task-run records. Never submissions.
|
|
7
7
|
*
|
|
8
8
|
* Every collection the UAG uses is named `uag-*`, and nothing else in the
|
|
9
9
|
* database is read or written, so the database may be shared. Before writing
|
|
@@ -66,10 +66,5 @@ export declare class UAGDatabase {
|
|
|
66
66
|
private privileged;
|
|
67
67
|
/** Authorization codes for remote MCP clients (MCP_OAUTH), in `uag-authcodes`. */
|
|
68
68
|
authCodeStore(): AuthCodeStore;
|
|
69
|
-
/** When the license server became unreachable, in `uag-meta`, so a restart neither begins nor extends the grace period. */
|
|
70
|
-
licenseGrace(): {
|
|
71
|
-
read(): Promise<number | undefined>;
|
|
72
|
-
write(since?: number): Promise<void>;
|
|
73
|
-
};
|
|
74
69
|
close(): Promise<void>;
|
|
75
70
|
}
|
package/lib/database.js
CHANGED
|
@@ -6,8 +6,8 @@ const mongodb_1 = require("mongodb");
|
|
|
6
6
|
const debug = require('debug')('formio:uag:database');
|
|
7
7
|
/**
|
|
8
8
|
* The UAG's own database (MONGO, with the database named by UAG_DATABASE). It
|
|
9
|
-
* holds only the UAG's state: authorization codes for remote MCP clients,
|
|
10
|
-
*
|
|
9
|
+
* holds only the UAG's state: authorization codes for remote MCP clients, and
|
|
10
|
+
* task-run records. Never submissions.
|
|
11
11
|
*
|
|
12
12
|
* Every collection the UAG uses is named `uag-*`, and nothing else in the
|
|
13
13
|
* database is read or written, so the database may be shared. Before writing
|
|
@@ -23,7 +23,6 @@ exports.UAG_COLLECTIONS = Object.freeze({
|
|
|
23
23
|
const PREFIX = 'uag-';
|
|
24
24
|
const APP = 'formio-uag';
|
|
25
25
|
const SCHEMA_ID = 'schema';
|
|
26
|
-
const LICENSE_ID = 'license';
|
|
27
26
|
const RETENTION_INDEX = 'retention';
|
|
28
27
|
const DEFAULT_RETENTION_DAYS = 90;
|
|
29
28
|
// MongoDB's "Unauthorized" error.
|
|
@@ -206,22 +205,6 @@ class UAGDatabase {
|
|
|
206
205
|
}
|
|
207
206
|
};
|
|
208
207
|
}
|
|
209
|
-
/** When the license server became unreachable, in `uag-meta`, so a restart neither begins nor extends the grace period. */
|
|
210
|
-
licenseGrace() {
|
|
211
|
-
const meta = this.meta;
|
|
212
|
-
const database = this;
|
|
213
|
-
return {
|
|
214
|
-
async read() {
|
|
215
|
-
const found = await meta.findOne({ _id: LICENSE_ID });
|
|
216
|
-
return typeof found?.outageSince === 'number' ? found.outageSince : undefined;
|
|
217
|
-
},
|
|
218
|
-
async write(since) {
|
|
219
|
-
// It may write before the license is known, so it never leaves uag-* collections without the marker.
|
|
220
|
-
await database.bootstrap();
|
|
221
|
-
await meta.updateOne({ _id: LICENSE_ID }, since === undefined ? { $unset: { outageSince: '' } } : { $set: { outageSince: since } }, { upsert: true });
|
|
222
|
-
}
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
208
|
async close() {
|
|
226
209
|
await this.client.close();
|
|
227
210
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PluginContext } from '@formio/appserver';
|
|
2
|
+
import type { Converter, ConverterOutput, Representation } from './types';
|
|
3
|
+
export declare const CONVERTER_DEFAULTS: {
|
|
4
|
+
maxBytes: number;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
concurrency: number;
|
|
7
|
+
};
|
|
8
|
+
/** A conversion that did not complete. The message names the converter and the file, never its content. */
|
|
9
|
+
export declare class ConversionError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export type ConvertOptions = {
|
|
13
|
+
to: Representation;
|
|
14
|
+
settings?: Record<string, any>;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
log: PluginContext['log'];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Run one conversion: under the converter's size limit, in a temporary
|
|
20
|
+
* directory made for the call and removed after it, aborted at its timeout,
|
|
21
|
+
* and queued behind its other conversions beyond its concurrency.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runConverter(converter: Converter, input: {
|
|
24
|
+
buffer: Buffer;
|
|
25
|
+
type: string;
|
|
26
|
+
name: string;
|
|
27
|
+
}, options: ConvertOptions): Promise<ConverterOutput>;
|
|
28
|
+
/**
|
|
29
|
+
* Run a route: each step's output feeds the next (an intermediate step always
|
|
30
|
+
* makes a PDF), with each converter's operator settings.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runRoute(steps: Converter[], input: {
|
|
33
|
+
buffer: Buffer;
|
|
34
|
+
type: string;
|
|
35
|
+
name: string;
|
|
36
|
+
}, to: Representation, options: Omit<ConvertOptions, 'to' | 'settings'> & {
|
|
37
|
+
settingsOf?: (converter: Converter) => Record<string, any> | undefined;
|
|
38
|
+
}): Promise<ConverterOutput>;
|
|
@@ -0,0 +1,151 @@
|
|
|
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.ConversionError = exports.CONVERTER_DEFAULTS = void 0;
|
|
7
|
+
exports.runConverter = runConverter;
|
|
8
|
+
exports.runRoute = runRoute;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
exports.CONVERTER_DEFAULTS = { maxBytes: 25 * 1024 * 1024, timeoutMs: 120000, concurrency: 2 };
|
|
13
|
+
/** A conversion that did not complete. The message names the converter and the file, never its content. */
|
|
14
|
+
class ConversionError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'ConversionError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.ConversionError = ConversionError;
|
|
21
|
+
/**
|
|
22
|
+
* A queue per converter, so at most `concurrency` of its conversions run at
|
|
23
|
+
* once. A slot is held until its conversion has actually stopped: a converter
|
|
24
|
+
* that ignores its signal still counts after its caller has given up on it.
|
|
25
|
+
*/
|
|
26
|
+
class Queue {
|
|
27
|
+
constructor(limit) {
|
|
28
|
+
this.limit = limit;
|
|
29
|
+
this.running = 0;
|
|
30
|
+
this.waiting = [];
|
|
31
|
+
}
|
|
32
|
+
/** Wait for a slot; the returned function frees it (once). */
|
|
33
|
+
async acquire(signal) {
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
if (this.running < this.limit) {
|
|
36
|
+
this.running++;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// A freed slot passes straight to the next waiter, so no new caller can take it in between.
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const go = () => { signal?.removeEventListener('abort', cancel); resolve(); };
|
|
42
|
+
const cancel = () => { this.waiting = this.waiting.filter((entry) => entry !== go); reject(signal.reason ?? new Error('aborted')); };
|
|
43
|
+
this.waiting.push(go);
|
|
44
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
let released = false;
|
|
48
|
+
return () => {
|
|
49
|
+
if (released)
|
|
50
|
+
return;
|
|
51
|
+
released = true;
|
|
52
|
+
const next = this.waiting.shift();
|
|
53
|
+
if (next)
|
|
54
|
+
next();
|
|
55
|
+
else
|
|
56
|
+
this.running--;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const queues = new WeakMap();
|
|
61
|
+
/**
|
|
62
|
+
* Run one conversion: under the converter's size limit, in a temporary
|
|
63
|
+
* directory made for the call and removed after it, aborted at its timeout,
|
|
64
|
+
* and queued behind its other conversions beyond its concurrency.
|
|
65
|
+
*/
|
|
66
|
+
async function runConverter(converter, input, options) {
|
|
67
|
+
const limits = { ...exports.CONVERTER_DEFAULTS, ...(converter.limits || {}) };
|
|
68
|
+
if (input.buffer.length > limits.maxBytes) {
|
|
69
|
+
throw new ConversionError(`"${input.name}" is larger than the ${converter.name} converter takes (${limits.maxBytes} bytes).`);
|
|
70
|
+
}
|
|
71
|
+
let queue = queues.get(converter);
|
|
72
|
+
if (!queue) {
|
|
73
|
+
queue = new Queue(Math.max(1, limits.concurrency));
|
|
74
|
+
queues.set(converter, queue);
|
|
75
|
+
}
|
|
76
|
+
const release = await queue.acquire(options.signal);
|
|
77
|
+
let tmpdir;
|
|
78
|
+
try {
|
|
79
|
+
options.signal?.throwIfAborted();
|
|
80
|
+
tmpdir = await fs_1.default.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'uag-convert-'));
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
release();
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
88
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
89
|
+
if (options.signal?.aborted)
|
|
90
|
+
abort();
|
|
91
|
+
const work = Promise.resolve().then(() => converter.convert(input, { to: options.to, settings: options.settings || {}, signal: controller.signal, tmpdir, log: options.log }));
|
|
92
|
+
// The slot and the temporary directory are kept until convert() settles, even after a timeout or an abort.
|
|
93
|
+
const stopped = work.then(() => undefined, () => undefined).then(async () => {
|
|
94
|
+
await fs_1.default.promises.rm(tmpdir, { recursive: true, force: true }).catch(() => undefined);
|
|
95
|
+
release();
|
|
96
|
+
});
|
|
97
|
+
let timer;
|
|
98
|
+
let finished = false;
|
|
99
|
+
try {
|
|
100
|
+
const timeout = new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => {
|
|
102
|
+
const error = new ConversionError(`Converting "${input.name}" with ${converter.name} took longer than ${limits.timeoutMs} ms.`);
|
|
103
|
+
// Abort first: a converter running a process stops it on its signal.
|
|
104
|
+
controller.abort(error);
|
|
105
|
+
reject(error);
|
|
106
|
+
}, limits.timeoutMs);
|
|
107
|
+
});
|
|
108
|
+
const aborted = new Promise((_, reject) => {
|
|
109
|
+
if (controller.signal.aborted)
|
|
110
|
+
reject(controller.signal.reason);
|
|
111
|
+
controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
|
|
112
|
+
});
|
|
113
|
+
const output = await Promise.race([work.finally(() => { finished = true; }), timeout, aborted]);
|
|
114
|
+
return checkOutput(converter, input.name, options.to, output);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
options.signal?.removeEventListener('abort', abort);
|
|
119
|
+
// A conversion that completed has its directory removed before its result is used.
|
|
120
|
+
if (finished)
|
|
121
|
+
await stopped;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function checkOutput(converter, name, to, output) {
|
|
125
|
+
if (to === 'pdf') {
|
|
126
|
+
if (!output || !Buffer.isBuffer(output.pdf))
|
|
127
|
+
throw new ConversionError(`The ${converter.name} converter did not produce a PDF for "${name}".`);
|
|
128
|
+
return output;
|
|
129
|
+
}
|
|
130
|
+
if (!output || typeof output.text !== 'string' || !['plain', 'markdown', 'csv'].includes(output.textFormat)) {
|
|
131
|
+
throw new ConversionError(`The ${converter.name} converter did not produce text for "${name}".`);
|
|
132
|
+
}
|
|
133
|
+
return output;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Run a route: each step's output feeds the next (an intermediate step always
|
|
137
|
+
* makes a PDF), with each converter's operator settings.
|
|
138
|
+
*/
|
|
139
|
+
async function runRoute(steps, input, to, options) {
|
|
140
|
+
let current = input;
|
|
141
|
+
let output;
|
|
142
|
+
for (let i = 0; i < steps.length; i++) {
|
|
143
|
+
const target = i === steps.length - 1 ? to : 'pdf';
|
|
144
|
+
output = await runConverter(steps[i], current, { ...options, to: target, settings: options.settingsOf?.(steps[i]) });
|
|
145
|
+
if (target === 'pdf')
|
|
146
|
+
current = { buffer: output.pdf, type: 'application/pdf', name: input.name };
|
|
147
|
+
}
|
|
148
|
+
if (!output)
|
|
149
|
+
throw new ConversionError(`No converter step for "${input.name}".`);
|
|
150
|
+
return output;
|
|
151
|
+
}
|