@formio/uag 1.13.1 → 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 (69) 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 -29
  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/annotations.d.ts +34 -0
  54. package/lib/tools/annotations.js +56 -0
  55. package/lib/tools/collectData.js +3 -1
  56. package/lib/tools/confirmSubmission.js +3 -1
  57. package/lib/tools/fetchExternalData.js +10 -17
  58. package/lib/tools/findSubmission.js +64 -56
  59. package/lib/tools/getFieldInfo.js +3 -1
  60. package/lib/tools/getFormFields.js +3 -1
  61. package/lib/tools/getForms.js +3 -1
  62. package/lib/tools/index.d.ts +4 -1
  63. package/lib/tools/index.js +22 -14
  64. package/lib/tools/submissionUpdate.js +3 -1
  65. package/lib/tools/submitForm.js +3 -1
  66. package/lib/tools/utils.d.ts +2 -0
  67. package/package.json +41 -18
  68. package/lib/tools/agentProvideData.d.ts +0 -3
  69. package/lib/tools/agentProvideData.js +0 -60
package/lib/runner.js ADDED
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TaskRunner = exports.SWEEP_MS = exports.MAX_CHAIN = exports.RUN_TASK_CHAIN = exports.RUN_TASK_AS_USER = exports.RUN_TASK_IDENTITY = exports.RUN_TASK_REQUEST_ID = exports.RUN_TASK_CALLER = exports.RUN_TASK_ACTING_FOR = exports.RUN_TASK_SAVE = void 0;
4
+ exports.taskRunner = taskRunner;
5
+ const formflow_1 = require("./formflow");
6
+ const identity_1 = require("./identity");
7
+ const runs_1 = require("./runs");
8
+ const requestId_1 = require("./agent/requestId");
9
+ const debug = require('debug')('formio:uag:runner');
10
+ /**
11
+ * The request field that marks the runner's own save, so the form's Run task
12
+ * actions that run before the save skip it: its data is the agent's output.
13
+ */
14
+ exports.RUN_TASK_SAVE = 'uagRunTaskSave';
15
+ /**
16
+ * The request fields that carry, through the runner's own save, who the run
17
+ * acts for, so a Run task action that the save starts acts for the same user.
18
+ */
19
+ exports.RUN_TASK_ACTING_FOR = 'uagActingFor';
20
+ exports.RUN_TASK_CALLER = 'uagCaller';
21
+ exports.RUN_TASK_REQUEST_ID = 'uagRequestId';
22
+ exports.RUN_TASK_IDENTITY = 'uagIdentity';
23
+ exports.RUN_TASK_AS_USER = 'uagAsUser';
24
+ exports.RUN_TASK_CHAIN = 'uagChain';
25
+ /**
26
+ * Runs a runner's save may start in a row: a task whose save starts a task
27
+ * that changes something each time would otherwise never stop.
28
+ */
29
+ exports.MAX_CHAIN = 5;
30
+ const DEFAULT_CONCURRENCY = 4;
31
+ /** How often a replica looks for runs that are pending or lost their replica. */
32
+ exports.SWEEP_MS = 60 * 1000;
33
+ /**
34
+ * Runs Run task actions' background runs: load the stored submission, run the
35
+ * task, and save only what changed, as an update through the UAG's own
36
+ * pipeline (so the form's update actions, including other Run task actions,
37
+ * run on it). If the submission changes while the task runs, the run starts
38
+ * again once on the new version, then gives up as a conflict.
39
+ *
40
+ * Without the UAG's database, runs are kept in memory, a few at a time, and
41
+ * are lost if the process stops. With it, every run is recorded; a run that
42
+ * acts with an agent role is recorded as pending before the caller's response
43
+ * and taken from the database by one replica at a time, under a lease it
44
+ * renews, so it survives a restart. A sweep at startup and every minute takes
45
+ * runs that are pending or whose replica stopped.
46
+ */
47
+ class TaskRunner {
48
+ constructor(project, concurrency = DEFAULT_CONCURRENCY) {
49
+ this.project = project;
50
+ this.concurrency = concurrency;
51
+ this.queue = [];
52
+ this.active = 0;
53
+ this.waiting = [];
54
+ this.sweeping = false;
55
+ }
56
+ /** Whether runs acting with an agent role are recorded and survive a restart. */
57
+ get durable() {
58
+ return !!this.store;
59
+ }
60
+ /** Record runs in the UAG's database, and take durable runs from it. */
61
+ useStore(store) {
62
+ this.store = store;
63
+ this.stop();
64
+ this.sweeper = setInterval(() => this.sweep(), exports.SWEEP_MS);
65
+ this.sweeper.unref?.();
66
+ this.sweep();
67
+ }
68
+ /** Stop sweeping (the runs in progress finish). */
69
+ stop() {
70
+ if (this.sweeper)
71
+ clearInterval(this.sweeper);
72
+ this.sweeper = undefined;
73
+ }
74
+ /** The project's run records, when there is a database. */
75
+ get records() {
76
+ return this.store;
77
+ }
78
+ /**
79
+ * Start a run. A durable run is recorded as pending before this resolves, so
80
+ * the caller's response is sent only once it can survive a restart.
81
+ */
82
+ async enqueue(job) {
83
+ if (this.store) {
84
+ const durable = !!job.agentRole;
85
+ try {
86
+ const record = await this.store.add(this.newRecord(job, durable));
87
+ if (durable) {
88
+ this.sweep();
89
+ return;
90
+ }
91
+ this.queue.push({ job, record });
92
+ }
93
+ catch (err) {
94
+ console.error(`[formio-uag] Run task "${job.task}" could not be recorded (${err?.message || err}); it runs in memory.`);
95
+ this.queue.push({ job });
96
+ }
97
+ }
98
+ else {
99
+ this.queue.push({ job });
100
+ }
101
+ this.drain();
102
+ }
103
+ /** Record a run made before the save, once the save gave the submission its id. */
104
+ async recordBefore(job, outcome) {
105
+ if (!this.store)
106
+ return;
107
+ try {
108
+ const record = await this.store.add({ ...this.newRecord(job, false, 'before'), status: 'running' });
109
+ await this.store.finish(record, resultOf(outcome));
110
+ }
111
+ catch (err) {
112
+ debug(`Could not record a run before the save: ${err?.message || err}`);
113
+ }
114
+ }
115
+ newRecord(job, durable, handler = 'after') {
116
+ return {
117
+ project: String(this.project.project?._id || ''),
118
+ form: job.formId || '',
119
+ formName: job.form,
120
+ submission: job.submission,
121
+ action: job.action || 'Agent: Run task',
122
+ task: job.task,
123
+ answer: job.answer,
124
+ handler,
125
+ method: job.method || 'create',
126
+ durable,
127
+ actingFor: job.actingFor,
128
+ ...(job.agentRole && { agentRole: job.agentRole }),
129
+ ...(job.requestId && { requestId: job.requestId }),
130
+ ...(job.chain && { chain: job.chain }),
131
+ status: (durable ? 'pending' : 'running')
132
+ };
133
+ }
134
+ /** Resolves once every queued and running job has finished (for tests and shutdown). */
135
+ idle() {
136
+ if (this.isIdle())
137
+ return Promise.resolve();
138
+ return new Promise((resolve) => this.waiting.push(resolve));
139
+ }
140
+ isIdle() {
141
+ return !this.active && !this.queue.length && !this.sweeping;
142
+ }
143
+ settled() {
144
+ if (this.isIdle())
145
+ this.waiting.splice(0).forEach((resolve) => resolve());
146
+ }
147
+ drain() {
148
+ while (this.active < this.concurrency && this.queue.length) {
149
+ const { job, record } = this.queue.shift();
150
+ this.track(job, record ? (outcome) => this.store.finish(record, resultOf(outcome)) : undefined);
151
+ }
152
+ }
153
+ /** Take durable runs from the database while there is room. */
154
+ async sweep() {
155
+ if (!this.store || this.sweeping)
156
+ return;
157
+ this.sweeping = true;
158
+ try {
159
+ while (this.active < this.concurrency) {
160
+ const record = await this.store.claim();
161
+ if (!record)
162
+ break;
163
+ this.runRecord(record);
164
+ }
165
+ }
166
+ catch (err) {
167
+ debug(`Could not take runs from the database: ${err?.message || err}`);
168
+ }
169
+ finally {
170
+ this.sweeping = false;
171
+ this.settled();
172
+ }
173
+ }
174
+ runRecord(record) {
175
+ const store = this.store;
176
+ const renewal = setInterval(() => store.renew(record._id).catch(() => { }), runs_1.RENEW_MS);
177
+ renewal.unref?.();
178
+ this.track(jobOf(record), (outcome) => store.finish(record._id, resultOf(outcome)), () => {
179
+ clearInterval(renewal);
180
+ this.sweep();
181
+ });
182
+ }
183
+ /** Run a job, report it, and record its end. */
184
+ track(job, finish, done) {
185
+ this.active++;
186
+ // Engines (the remote type) send the id of the request that started the run.
187
+ (0, requestId_1.withRequestId)(job.requestId, () => this.execute(job))
188
+ .catch((err) => ({ status: 'failed', written: {}, cleared: [], engines: [], saved: false, error: err?.message || String(err) }))
189
+ .then(async (outcome) => {
190
+ this.report(job, outcome);
191
+ await finish?.(outcome).catch((err) => debug(`Could not record the end of a run: ${err?.message || err}`));
192
+ })
193
+ .finally(() => {
194
+ this.active--;
195
+ done?.();
196
+ this.drain();
197
+ this.settled();
198
+ });
199
+ }
200
+ report(job, outcome) {
201
+ if (outcome.status === 'failed') {
202
+ console.error(`[formio-uag] Run task "${job.task}" failed for submission ${job.submission} of "${job.form}": ${outcome.error}`);
203
+ }
204
+ else {
205
+ debug(`Run task "${job.task}" on ${job.form}/${job.submission}: ${outcome.saved ? `saved ${Object.keys(outcome.written).length} field(s)` : 'nothing changed'}`);
206
+ }
207
+ }
208
+ /**
209
+ * Who the job acts as at Form.io (`act`: a new identity for each request,
210
+ * since signed tokens are short-lived), and who its engines research as.
211
+ * Never the project's key: the agent role's signed token, or the caller's own.
212
+ */
213
+ async identities(job) {
214
+ if (job.agentRole) {
215
+ const claim = { action: job.action, task: job.task };
216
+ const research = await (0, identity_1.userAuth)(this.project, job.actingFor, claim);
217
+ return {
218
+ act: () => (0, identity_1.agentAuth)(this.project, job.actingFor, job.agentRole, claim),
219
+ research,
220
+ identity: (0, formflow_1.identityOf)(research, `Bearer ${research.token}`)
221
+ };
222
+ }
223
+ if (job.asUser) {
224
+ const claim = { action: job.action, task: job.task };
225
+ const research = await (0, identity_1.userAuth)(this.project, job.actingFor, claim);
226
+ return {
227
+ act: () => (0, identity_1.userAuth)(this.project, job.actingFor, claim),
228
+ research,
229
+ identity: (0, formflow_1.identityOf)(research, `Bearer ${research.token}`)
230
+ };
231
+ }
232
+ if (!job.caller)
233
+ throw new formflow_1.FormflowError(500, 'A run without an agent role needs the caller\'s identity.');
234
+ const caller = job.caller;
235
+ return { act: async () => caller.clone(), research: caller, identity: job.identity };
236
+ }
237
+ /**
238
+ * Why Form.io refused the agent's signed token, when it did: asks Form.io who
239
+ * the token is, so a JWT_SECRET that is not Form.io's is told apart from
240
+ * other failures.
241
+ */
242
+ async refusal(job) {
243
+ if (!job.agentRole || !this.project.endpoint)
244
+ return null;
245
+ try {
246
+ const auth = await (0, identity_1.agentAuth)(this.project, job.actingFor, job.agentRole, { action: job.action, task: job.task });
247
+ const response = await fetch(`${this.project.endpoint}/current`, { headers: { 'x-jwt-token': auth.token || '', Accept: 'application/json' } });
248
+ return (0, identity_1.refusedSignedToken)(response.status);
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ /** Run one job to completion. */
255
+ async execute(job) {
256
+ try {
257
+ return await this.run(job);
258
+ }
259
+ catch (err) {
260
+ throw (await this.refusal(job)) || err;
261
+ }
262
+ }
263
+ async run(job) {
264
+ const form = await this.project.formflowForm(job.form);
265
+ if (!form)
266
+ throw new formflow_1.FormflowError(404, `The form "${job.form}" was not found.`);
267
+ const { act, research, identity } = await this.identities(job);
268
+ const service = (0, formflow_1.formflow)(this.project);
269
+ const load = async () => {
270
+ const stored = await form.loadSubmission(job.submission, await act());
271
+ if (!stored)
272
+ throw new formflow_1.FormflowError(404, `Submission "${job.submission}" was not found.`);
273
+ return stored;
274
+ };
275
+ let stored = await load();
276
+ for (let attempt = 1;; attempt++) {
277
+ const run = await service.runTask(form, job.task, stored, research, { answer: job.answer, stored: true, identity, requestId: job.requestId });
278
+ const engines = run.results.map((result) => ({ kind: result.kind, engine: result.engine ?? null, model: result.model ?? null }));
279
+ const failed = run.failed.map((group) => group.error || 'no engine could answer').join('; ');
280
+ const changed = Object.keys(run.written).length > 0 || run.cleared.length > 0;
281
+ if (!changed) {
282
+ return { status: failed ? 'failed' : 'done', written: {}, cleared: [], engines, saved: false, ...(failed && { error: failed }) };
283
+ }
284
+ try {
285
+ // Only the task's fields changed; `modified` refuses the save if a person saved in the meantime.
286
+ await form.submit({ _id: stored._id, modified: stored.modified, data: run.submission.data, metadata: run.submission.metadata }, await act(), {
287
+ [exports.RUN_TASK_SAVE]: true,
288
+ [exports.RUN_TASK_ACTING_FOR]: job.actingFor,
289
+ ...(job.requestId && { [exports.RUN_TASK_REQUEST_ID]: job.requestId }),
290
+ // The runs this save starts act as the user, never with this run's agent role.
291
+ ...(job.caller ? { [exports.RUN_TASK_CALLER]: job.caller, [exports.RUN_TASK_IDENTITY]: job.identity } : { [exports.RUN_TASK_AS_USER]: true }),
292
+ [exports.RUN_TASK_CHAIN]: (job.chain || 0) + 1
293
+ });
294
+ return { status: failed ? 'failed' : 'done', written: run.written, cleared: run.cleared, engines, saved: true, ...(failed && { error: failed }) };
295
+ }
296
+ catch (err) {
297
+ if (err?.name !== 'ConflictError' || attempt >= 2) {
298
+ if (err?.name === 'ConflictError') {
299
+ throw new formflow_1.FormflowError(409, 'The submission changed while its task was running, and again on the second try. Nothing was saved.');
300
+ }
301
+ throw err;
302
+ }
303
+ stored = await load();
304
+ }
305
+ }
306
+ }
307
+ }
308
+ exports.TaskRunner = TaskRunner;
309
+ /** What a record keeps of a run's outcome: paths, never values. */
310
+ function resultOf(outcome) {
311
+ return {
312
+ status: outcome.status,
313
+ engines: outcome.engines,
314
+ written: Object.keys(outcome.written),
315
+ cleared: outcome.cleared,
316
+ saved: outcome.saved,
317
+ ...(outcome.error && { error: outcome.error })
318
+ };
319
+ }
320
+ /** The job a durable record describes. */
321
+ function jobOf(record) {
322
+ return {
323
+ form: record.formName,
324
+ formId: record.form,
325
+ submission: record.submission,
326
+ task: record.task,
327
+ answer: record.answer,
328
+ actingFor: record.actingFor,
329
+ agentRole: record.agentRole,
330
+ action: record.action,
331
+ method: record.method,
332
+ ...(record.requestId && { requestId: record.requestId }),
333
+ ...(record.chain && { chain: record.chain })
334
+ };
335
+ }
336
+ /** The project's runner, created on first use. */
337
+ const runners = new WeakMap();
338
+ function taskRunner(project) {
339
+ let runner = runners.get(project);
340
+ if (!runner) {
341
+ runner = new TaskRunner(project);
342
+ runners.set(project, runner);
343
+ }
344
+ return runner;
345
+ }
package/lib/runs.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { Collection, ObjectId } from 'mongodb';
2
+ import type { ActingFor } from './identity';
3
+ /**
4
+ * Run task records in the UAG's database (`uag-taskruns`): one per run, for
5
+ * audit (Action Logs), and, for runs that act with an agent role, the queue
6
+ * that lets a run survive a restart and be taken by one replica at a time.
7
+ * A record never holds a token.
8
+ */
9
+ export type TaskRunStatus = 'pending' | 'running' | 'done' | 'failed';
10
+ export type TaskRunRecord = {
11
+ _id: ObjectId;
12
+ project: string;
13
+ /** The form's id, and its name for the runner. */
14
+ form: string;
15
+ formName: string;
16
+ submission?: string;
17
+ /** The action's title, and the task it runs. */
18
+ action: string;
19
+ task: string;
20
+ answer: 'decisions' | 'task';
21
+ /** When it ran: before the save, or after it. */
22
+ handler: 'before' | 'after';
23
+ method: string;
24
+ /** Resumed after a restart (it acts with an agent role). */
25
+ durable: boolean;
26
+ actingFor: ActingFor;
27
+ /** The X-Request-Id of the request that started the run. */
28
+ requestId?: string;
29
+ agentRole?: string;
30
+ /** How many runner saves led to this run. */
31
+ chain?: number;
32
+ status: TaskRunStatus;
33
+ attempts: number;
34
+ owner?: string;
35
+ leaseUntil?: Date;
36
+ engines?: {
37
+ kind: string;
38
+ engine: string | null;
39
+ model: string | null;
40
+ }[];
41
+ written?: string[];
42
+ cleared?: string[];
43
+ saved?: boolean;
44
+ error?: string;
45
+ created: Date;
46
+ modified: Date;
47
+ started?: Date;
48
+ finished?: Date;
49
+ };
50
+ export type NewTaskRun = Omit<TaskRunRecord, '_id' | 'attempts' | 'created' | 'modified' | 'status'> & {
51
+ status?: TaskRunStatus;
52
+ };
53
+ /** How long a replica holds a run before another may take it over, and how often it renews. */
54
+ export declare const LEASE_MS: number;
55
+ export declare const RENEW_MS: number;
56
+ /** Attempts before a run that keeps losing its replica is given up. */
57
+ export declare const MAX_ATTEMPTS = 3;
58
+ export declare class TaskRunStore {
59
+ readonly collection: Collection<TaskRunRecord>;
60
+ readonly project?: string | undefined;
61
+ /** This replica, as the owner of the runs it holds. */
62
+ readonly owner: `${string}-${string}-${string}-${string}-${string}`;
63
+ /**
64
+ * `project` limits the durable runs this replica takes to its project's:
65
+ * UAGs of different projects may share one database.
66
+ */
67
+ constructor(collection: Collection<TaskRunRecord>, project?: string | undefined);
68
+ private get scope();
69
+ add(run: NewTaskRun): Promise<ObjectId>;
70
+ /**
71
+ * Take the oldest durable run that is pending, or whose holder's lease ran
72
+ * out, for this replica. Only one replica can take a run.
73
+ */
74
+ claim(now?: Date): Promise<TaskRunRecord | null>;
75
+ /** Fail the runs that lost their replica too many times. */
76
+ private giveUp;
77
+ /** Keep holding a run. False when another replica took it over. */
78
+ renew(id: ObjectId): Promise<boolean>;
79
+ /** Record how a run ended. Only the replica holding a durable run can. */
80
+ finish(id: ObjectId, result: Pick<TaskRunRecord, 'engines' | 'written' | 'cleared' | 'saved' | 'error'> & {
81
+ status: 'done' | 'failed';
82
+ }): Promise<void>;
83
+ get(id: string): Promise<TaskRunRecord | null>;
84
+ /** The project's records matching a filter, newest change first. */
85
+ list(filter: Record<string, any>, limit: number): Promise<{
86
+ items: TaskRunRecord[];
87
+ total: number;
88
+ }>;
89
+ }
package/lib/runs.js ADDED
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TaskRunStore = exports.MAX_ATTEMPTS = exports.RENEW_MS = exports.LEASE_MS = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const mongodb_1 = require("mongodb");
6
+ /** How long a replica holds a run before another may take it over, and how often it renews. */
7
+ exports.LEASE_MS = 2 * 60 * 1000;
8
+ exports.RENEW_MS = 30 * 1000;
9
+ /** Attempts before a run that keeps losing its replica is given up. */
10
+ exports.MAX_ATTEMPTS = 3;
11
+ class TaskRunStore {
12
+ /**
13
+ * `project` limits the durable runs this replica takes to its project's:
14
+ * UAGs of different projects may share one database.
15
+ */
16
+ constructor(collection, project) {
17
+ this.collection = collection;
18
+ this.project = project;
19
+ /** This replica, as the owner of the runs it holds. */
20
+ this.owner = (0, crypto_1.randomUUID)();
21
+ }
22
+ get scope() {
23
+ return this.project === undefined ? {} : { project: this.project };
24
+ }
25
+ async add(run) {
26
+ const now = new Date();
27
+ const record = { ...run, _id: new mongodb_1.ObjectId(), status: run.status || 'pending', attempts: 0, created: now, modified: now };
28
+ await this.collection.insertOne(record);
29
+ return record._id;
30
+ }
31
+ /**
32
+ * Take the oldest durable run that is pending, or whose holder's lease ran
33
+ * out, for this replica. Only one replica can take a run.
34
+ */
35
+ async claim(now = new Date()) {
36
+ await this.giveUp(now);
37
+ return this.collection.findOneAndUpdate({
38
+ ...this.scope,
39
+ durable: true,
40
+ attempts: { $lt: exports.MAX_ATTEMPTS },
41
+ $or: [{ status: 'pending' }, { status: 'running', leaseUntil: { $lt: now } }]
42
+ }, { $set: { status: 'running', owner: this.owner, leaseUntil: new Date(now.getTime() + exports.LEASE_MS), started: now, modified: now }, $inc: { attempts: 1 } }, { sort: { created: 1 }, returnDocument: 'after' });
43
+ }
44
+ /** Fail the runs that lost their replica too many times. */
45
+ async giveUp(now) {
46
+ await this.collection.updateMany({ ...this.scope, durable: true, status: 'running', leaseUntil: { $lt: now }, attempts: { $gte: exports.MAX_ATTEMPTS } }, { $set: { status: 'failed', error: `The run was given up after ${exports.MAX_ATTEMPTS} attempts.`, finished: now, modified: now }, $unset: { leaseUntil: '', owner: '' } });
47
+ }
48
+ /** Keep holding a run. False when another replica took it over. */
49
+ async renew(id) {
50
+ const now = new Date();
51
+ const result = await this.collection.updateOne({ _id: id, owner: this.owner, status: 'running' }, { $set: { leaseUntil: new Date(now.getTime() + exports.LEASE_MS), modified: now } });
52
+ return result.matchedCount > 0;
53
+ }
54
+ /** Record how a run ended. Only the replica holding a durable run can. */
55
+ async finish(id, result) {
56
+ const now = new Date();
57
+ await this.collection.updateOne({ _id: id, $or: [{ durable: false }, { owner: this.owner }] }, { $set: { ...result, finished: now, modified: now }, $unset: { leaseUntil: '', owner: '' } });
58
+ }
59
+ async get(id) {
60
+ if (!mongodb_1.ObjectId.isValid(id))
61
+ return null;
62
+ return this.collection.findOne({ _id: new mongodb_1.ObjectId(id) });
63
+ }
64
+ /** The project's records matching a filter, newest change first. */
65
+ async list(filter, limit) {
66
+ const [items, total] = await Promise.all([
67
+ // A limit of 0 would be none at all to MongoDB.
68
+ limit > 0 ? this.collection.find(filter).sort({ modified: -1 }).limit(limit).toArray() : Promise.resolve([]),
69
+ this.collection.countDocuments(filter)
70
+ ]);
71
+ return { items, total };
72
+ }
73
+ }
74
+ exports.TaskRunStore = TaskRunStore;
package/lib/template.js CHANGED
@@ -89,18 +89,16 @@ class UAGTemplate {
89
89
  * @returns The compiled template function
90
90
  */
91
91
  getTemplate(templateName) {
92
- if (this.templateCache.has(templateName)) {
93
- return this.templateCache.get(templateName);
92
+ const cached = this.templateCache.get(templateName);
93
+ if (cached) {
94
+ return cached;
94
95
  }
95
- let templateContent = '';
96
- if (this.config[templateName]) {
97
- templateContent = this.config[templateName];
98
- }
99
- else {
100
- const templatePath = path.join(path.join(__dirname, 'templates') || '', `${templateName}.md`);
96
+ let templateContent = this.config[templateName];
97
+ if (!templateContent) {
98
+ const templatePath = path.join(__dirname, 'templates', `${templateName}.md`);
101
99
  if (!fs.existsSync(templatePath))
102
100
  throw new Error(`Template '${templateName}' not found at ${templatePath}`);
103
- templateContent = this.config[templateName] || fs.readFileSync(templatePath, 'utf-8');
101
+ templateContent = fs.readFileSync(templatePath, 'utf-8');
104
102
  }
105
103
  const compiled = (0, lodash_1.template)(templateContent);
106
104
  this.templateCache.set(templateName, compiled);
@@ -1,11 +1,11 @@
1
1
  <% if (error === 'no_uag') { %>
2
- There are no `uag` fields present within the provided form. In order to use the `agent_provide_data` tool, you must first flag certain fields as the **Criteria** and **Agent Fields**.
3
- - **Criteria**: A content component with properties `uag="<%= persona %>"` and `uagField="criteria"`. Within the content of this component, provide detailed instructions (criteria) for the agent to follow when analyzing the data.
4
- - **Agent Fields**: Any fields you wish the agent to populate with its own data, you must flag those fields with the property `uag="<%= persona %>"`. These fields will then be handed to the AI Agent as required fields for them to populate according to the provided **Criteria**.
2
+ There are no `uag` fields present within the provided form. In order to use the `agent_provide` tool, you must first flag certain fields as the **Criteria** and **Agent Fields**.
3
+ - **Criteria**: A content component with properties `uag="<%= task %>"` and `uagField="criteria"`. Within the content of this component, provide detailed instructions (criteria) for the agent to follow when analyzing the data.
4
+ - **Agent Fields**: Any fields you wish the agent to populate with its own data, you must flag those fields with the property `uag="<%= task %>"`. These fields will then be handed to the AI Agent as required fields for them to populate according to the provided **Criteria**.
5
5
  <% } %>
6
6
  <% if (error === 'no_criteria') { %>
7
- No `criteria` field was found within this form with the properties `uag="<%= persona %>"` and `uagField="criteria"`. To add `criteria`, add a Content component to the form and then give it the following properties: `uag="<%= persona %>"` and `uagField="criteria"`. Within the content of this component, provide detailed instructions (the criteria) for the agent to follow when analyzing the data.
7
+ No `criteria` field was found within this form with the properties `uag="<%= task %>"` and `uagField="criteria"`. To add `criteria`, add a Content component to the form and then give it the following properties: `uag="<%= task %>"` and `uagField="criteria"`. Within the content of this component, provide detailed instructions (the criteria) for the agent to follow when analyzing the data.
8
8
  <% } %>
9
9
  <% if (error === 'no_fields') { %>
10
- There are no `uag` fields within this form under the provided `persona=<%= persona %>` that are available for the AI Agent to provide values for. To flag a field as fillable by the AI Agent, you must add the property and value `uag="<%= persona %>"`
10
+ There are no `uag` fields within this form under the provided `task=<%= task %>` that are available for the AI Agent to provide values for. To flag a field as fillable by the AI Agent, you must add the property and value `uag="<%= task %>"`
11
11
  <% } %>
@@ -109,8 +109,13 @@ export declare class SchemaBuilder {
109
109
  */
110
110
  parent_path(): this;
111
111
  /**
112
- * The agent persona "type" to use when processing the existing submission data. The persona provides context to the agent on how to interpret and understand the data within the form submission. If not provided, the default persona defined within the form's UAG settings will be used.
113
- * @returns
112
+ * The task to work on: the name of a task the form defines (the value of its
113
+ * components' `uag` property).
114
+ */
115
+ task(): this;
116
+ /**
117
+ * The legacy name for `task`. Kept in the schema so clients that still send
118
+ * it are not silently given the default task.
114
119
  */
115
120
  persona(): this;
116
121
  /**
@@ -168,11 +168,19 @@ class SchemaBuilder {
168
168
  return this;
169
169
  }
170
170
  /**
171
- * The agent persona "type" to use when processing the existing submission data. The persona provides context to the agent on how to interpret and understand the data within the form submission. If not provided, the default persona defined within the form's UAG settings will be used.
172
- * @returns
171
+ * The task to work on: the name of a task the form defines (the value of its
172
+ * components' `uag` property).
173
+ */
174
+ task() {
175
+ this.schema.task = zod_1.default.string().optional().describe('The name of a task defined on the form: the value of its components\' `uag` property, such as "triage". Not a description of the work. If not provided, the form\'s first task is used.');
176
+ return this;
177
+ }
178
+ /**
179
+ * The legacy name for `task`. Kept in the schema so clients that still send
180
+ * it are not silently given the default task.
173
181
  */
174
182
  persona() {
175
- this.schema.persona = zod_1.default.string().optional().describe('The agent persona "type" to use when processing the existing submission data. The persona provides context to the agent on how to interpret and understand the data within the form submission. If not provided, the default persona defined within the form\'s UAG settings will be used.');
183
+ this.schema.persona = zod_1.default.string().optional().describe('Deprecated: the former name for `task`. Send `task` instead.');
176
184
  return this;
177
185
  }
178
186
  /**
@@ -0,0 +1,8 @@
1
+ import { UAGProjectInterface } from "../UAGProjectInterface";
2
+ import { ToolInfo } from "./utils";
3
+ /**
4
+ * Returns a task's decision questions as structured JSON, so any decision
5
+ * model — an enabled engine, or one the MCP client calls itself — can answer
6
+ * them. No model is called here.
7
+ */
8
+ export declare const agentDecide: (project: UAGProjectInterface) => Promise<ToolInfo>;
@@ -0,0 +1,57 @@
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.agentDecide = void 0;
7
+ const lodash_1 = require("lodash");
8
+ const zod_1 = __importDefault(require("zod"));
9
+ const template_1 = require("../template");
10
+ const formflow_1 = require("../formflow");
11
+ const SchemaBuilder_1 = require("./SchemaBuilder");
12
+ const annotations_1 = require("./annotations");
13
+ /**
14
+ * Returns a task's decision questions as structured JSON, so any decision
15
+ * model — an enabled engine, or one the MCP client calls itself — can answer
16
+ * them. No model is called here.
17
+ */
18
+ const agentDecide = async (project) => {
19
+ return (0, lodash_1.defaultsDeep)({}, project.config?.toolOverrides?.agent_decide, {
20
+ name: 'agent_decide',
21
+ title: 'Agent Decide',
22
+ description: 'Return the decision questions for a task\'s closed-set fields (radio, select, checkbox, select boxes, survey and small resource selects) as structured JSON: the state to decide on, every question, and how each answer maps back to a field value. Answer the questions with a decision model, then write the values with `submission_update`. Provide either `submission_id` or `form_data`.',
23
+ annotations: (0, annotations_1.reads)('Get decision questions'),
24
+ inputSchema: {
25
+ ...(new SchemaBuilder_1.SchemaBuilder(project)).form_name().submission_id().task().persona().schema,
26
+ form_data: zod_1.default.record(zod_1.default.string(), zod_1.default.any()).optional().describe('The current form data as a submission data object (nested by data path). Used when `submission_id` is not given.')
27
+ },
28
+ execute: async (args, extra) => {
29
+ const { form_name, submission_id, form_data } = args;
30
+ const form = await project.getForm(form_name);
31
+ if (!form) {
32
+ return project.mcpResponse(template_1.ResponseTemplate.formNotFound, { formName: form_name }, true);
33
+ }
34
+ let data = form_data || {};
35
+ if (submission_id) {
36
+ const submission = await form.loadSubmission(submission_id, extra.authInfo);
37
+ if (!submission) {
38
+ return project.mcpResponse(template_1.ResponseTemplate.submissionNotFound, { form: form.form, submissionId: submission_id }, true);
39
+ }
40
+ data = submission.data || {};
41
+ }
42
+ try {
43
+ const task = (0, formflow_1.resolveTaskName)(args, 'agent_decide');
44
+ const out = await (0, formflow_1.formflow)(project).questions(form, { task, data, submissionId: submission_id }, extra.authInfo);
45
+ return project.mcpJSONResponse({ form: form_name, ...out });
46
+ }
47
+ catch (err) {
48
+ // A request that cannot be planned, or Agentic Formflow without its license.
49
+ if (err instanceof formflow_1.FormflowError) {
50
+ return project.mcpJSONResponse({ error: err.message, ...(err.code && { code: err.code }) }, true);
51
+ }
52
+ throw err;
53
+ }
54
+ }
55
+ });
56
+ };
57
+ exports.agentDecide = agentDecide;