@exulu/backend 2.3.0 → 3.1.0
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/dist/{chunk-2242AI5L.js → chunk-ZDH5S2WF.js} +875 -277
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-C7L4PY6P.js → convert-exulu-tools-to-ai-sdk-tools-FN6WZSIQ.js} +1 -1
- package/dist/index.cjs +9337 -5830
- package/dist/index.d.cts +45 -9
- package/dist/index.d.ts +45 -9
- package/dist/index.js +8080 -5291
- package/ee/agentic-retrieval/pipeline/hyde.test.ts +1 -1
- package/ee/agentic-retrieval/pipeline/hyde.ts +6 -3
- package/ee/agentic-retrieval/pipeline/memory.test.ts +5 -1
- package/ee/agentic-retrieval/pipeline/memory.ts +71 -104
- package/ee/agentic-retrieval/pipeline/micro-call.test.ts +112 -0
- package/ee/agentic-retrieval/pipeline/micro-call.ts +98 -0
- package/ee/agentic-retrieval/pipeline/prefilter.test.ts +1 -0
- package/ee/agentic-retrieval/pipeline/prefilter.ts +11 -20
- package/ee/agentic-retrieval/pipeline/routing.test.ts +44 -1
- package/ee/agentic-retrieval/pipeline/routing.ts +31 -56
- package/ee/queues/decorator.ts +11 -0
- package/ee/queues/prune-job-results.test.ts +41 -0
- package/ee/queues/prune-job-results.ts +5 -4
- package/ee/schemas.ts +96 -1
- package/ee/workers.flow.test.ts +236 -0
- package/ee/workers.ts +409 -168
- package/package.json +6 -1
package/ee/workers.ts
CHANGED
|
@@ -30,7 +30,28 @@ import { sanitizeToolName } from "@SRC/utils/sanitize-tool-name.ts";
|
|
|
30
30
|
import type { ExuluConfig } from "@SRC/exulu/app/index.ts";
|
|
31
31
|
import { updateStatistic } from "@SRC/exulu/statistics";
|
|
32
32
|
import type { ExuluProvider } from "@SRC/exulu/provider.ts";
|
|
33
|
+
import { saveChat, getAgentMessages } from "@SRC/exulu/provider.ts";
|
|
33
34
|
import { exuluApp } from "@SRC/exulu/app/singleton";
|
|
35
|
+
import { handleEmailIntake } from "@SRC/exulu/email-inbound/intake";
|
|
36
|
+
import { markStreamActive, clearStreamActive } from "@SRC/exulu/active-streams.ts";
|
|
37
|
+
import { messageHasPendingApproval, substituteVariablesInMessage } from "@SRC/exulu/routines/flow-steps.ts";
|
|
38
|
+
import { createRunSession } from "@SRC/exulu/routines/run-session.ts";
|
|
39
|
+
import { casJobResultState, parseRunMetadata, upsertWorkflowRunStart } from "@SRC/exulu/routines/run-state.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Session-backed runs persist messages at each step boundary, so retries must
|
|
43
|
+
* resume AT the failed step instead of re-running (and re-persisting) earlier
|
|
44
|
+
* ones (spec §5.4). This wrapper carries the failing step index to the
|
|
45
|
+
* workflow handler's retry loop.
|
|
46
|
+
*/
|
|
47
|
+
export class FlowStepError extends Error {
|
|
48
|
+
public readonly stepIndex: number;
|
|
49
|
+
constructor(stepIndex: number, cause: unknown) {
|
|
50
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
51
|
+
this.name = "FlowStepError";
|
|
52
|
+
this.stepIndex = stepIndex;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
34
55
|
|
|
35
56
|
let redisConnection: IORedis;
|
|
36
57
|
|
|
@@ -476,14 +497,31 @@ export const createWorkers = async (
|
|
|
476
497
|
|
|
477
498
|
const label = `workflow-run-${data.workflow}`;
|
|
478
499
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
500
|
+
// Bookkeeping persisted in job_results.metadata so cancel /
|
|
501
|
+
// retry / approval-resume can re-enqueue without the ephemeral
|
|
502
|
+
// Redis payload (spec §5).
|
|
503
|
+
const runBookkeeping = {
|
|
504
|
+
run_as: { user: data.user, role: data.role },
|
|
505
|
+
inputs: (data.inputs ?? {}) as Record<string, unknown>,
|
|
506
|
+
queue_name: bullmqJob.queueName,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Row first (before validation) so a payload/agent failure
|
|
510
|
+
// still surfaces as a failed run row — same as today.
|
|
511
|
+
const started = await upsertWorkflowRunStart(db, {
|
|
512
|
+
jobId: bullmqJob.id!,
|
|
513
|
+
jobResultId: data.jobResultId,
|
|
514
|
+
label,
|
|
482
515
|
state: await bullmqJob.getState(),
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
516
|
+
workflow: data.workflow!,
|
|
517
|
+
session: data.session ?? null,
|
|
518
|
+
trigger: data.triggerSource ?? null,
|
|
519
|
+
triggerMetadata: data.triggerMetadata ?? null,
|
|
520
|
+
bookkeeping: runBookkeeping,
|
|
521
|
+
resumeFromIndex: data.resumeFromIndex ?? 0,
|
|
486
522
|
});
|
|
523
|
+
const jobResultId = started.jobResultId;
|
|
524
|
+
let resumeFromIndex = started.resumeFromIndex;
|
|
487
525
|
|
|
488
526
|
const {
|
|
489
527
|
agent,
|
|
@@ -493,10 +531,31 @@ export const createWorkers = async (
|
|
|
493
531
|
messages: inputMessages,
|
|
494
532
|
} = await validateWorkflowPayload(data, providers);
|
|
495
533
|
|
|
534
|
+
// Session-backed runs (spec §3.4): reuse the session provided by
|
|
535
|
+
// the enqueuer (email intake / continuation / retry / previous
|
|
536
|
+
// BullMQ attempt), otherwise create one with the routine's rbac
|
|
537
|
+
// snapshot under the run identity.
|
|
538
|
+
let sessionId = started.session ?? undefined;
|
|
539
|
+
if (!sessionId) {
|
|
540
|
+
sessionId = await createRunSession({
|
|
541
|
+
db,
|
|
542
|
+
workflow: {
|
|
543
|
+
id: workflow.id,
|
|
544
|
+
name: workflow.name,
|
|
545
|
+
agent: workflow.agent,
|
|
546
|
+
rights_mode: workflow.rights_mode,
|
|
547
|
+
},
|
|
548
|
+
userId: user.id,
|
|
549
|
+
title: `${workflow.name} — ${new Date().toISOString()}`,
|
|
550
|
+
trigger: data.triggerSource ?? "api",
|
|
551
|
+
jobResultId,
|
|
552
|
+
});
|
|
553
|
+
await db.from("job_results").where({ id: jobResultId }).update({ session: sessionId });
|
|
554
|
+
}
|
|
555
|
+
|
|
496
556
|
const retries = 3;
|
|
497
557
|
let attempts = 0;
|
|
498
558
|
|
|
499
|
-
// todo allow setting queue on agent provider and then create a job with type "agent"
|
|
500
559
|
const promise = new Promise<{
|
|
501
560
|
messages: UIMessage[];
|
|
502
561
|
metadata: {
|
|
@@ -509,14 +568,18 @@ export const createWorkers = async (
|
|
|
509
568
|
};
|
|
510
569
|
duration: number;
|
|
511
570
|
};
|
|
571
|
+
pausedAtStepIndex?: number;
|
|
512
572
|
}>(async (resolve, reject) => {
|
|
513
573
|
while (attempts < retries) {
|
|
514
574
|
try {
|
|
575
|
+
// processUiMessagesFlow mutates inputMessages in place (ids
|
|
576
|
+
// + substituted text) — pass a fresh deep copy each attempt
|
|
577
|
+
// so a retry/resume never reuses the mutated array.
|
|
515
578
|
const messages = await processUiMessagesFlow({
|
|
516
579
|
providers,
|
|
517
580
|
agent,
|
|
518
581
|
provider,
|
|
519
|
-
inputMessages,
|
|
582
|
+
inputMessages: structuredClone(inputMessages),
|
|
520
583
|
contexts,
|
|
521
584
|
user,
|
|
522
585
|
tools,
|
|
@@ -524,6 +587,11 @@ export const createWorkers = async (
|
|
|
524
587
|
variables: data.inputs,
|
|
525
588
|
// Tag LLM spend to this routine (cron + ad-hoc share this path).
|
|
526
589
|
routine: { id: workflow.id, name: workflow.name },
|
|
590
|
+
sessionId,
|
|
591
|
+
resumeFromIndex,
|
|
592
|
+
// Approval-gated tools pause unless the routine opted
|
|
593
|
+
// back into blanket pre-approval (spec §5.2).
|
|
594
|
+
respectToolApprovals: workflow.auto_approve_tools !== true,
|
|
527
595
|
});
|
|
528
596
|
resolve(messages);
|
|
529
597
|
break;
|
|
@@ -532,11 +600,34 @@ export const createWorkers = async (
|
|
|
532
600
|
`[EXULU] error processing UI messages flow for agent ${agent.name} (${agent.id}).`,
|
|
533
601
|
error instanceof Error ? error.message : String(error),
|
|
534
602
|
);
|
|
603
|
+
if (error instanceof FlowStepError) {
|
|
604
|
+
// Completed steps already persisted their messages —
|
|
605
|
+
// resume at the failed step (spec §5.4).
|
|
606
|
+
resumeFromIndex = error.stepIndex;
|
|
607
|
+
}
|
|
535
608
|
attempts++;
|
|
536
609
|
if (attempts >= retries) {
|
|
610
|
+
// Persist progress so BullMQ attempt-level retries and
|
|
611
|
+
// retryRoutineRun resume from the failed step.
|
|
612
|
+
try {
|
|
613
|
+
await db
|
|
614
|
+
.from("job_results")
|
|
615
|
+
.where({ id: jobResultId })
|
|
616
|
+
.update({
|
|
617
|
+
metadata: JSON.stringify({
|
|
618
|
+
...runBookkeeping,
|
|
619
|
+
current_step_index: resumeFromIndex,
|
|
620
|
+
}),
|
|
621
|
+
});
|
|
622
|
+
} catch (persistError) {
|
|
623
|
+
console.error(
|
|
624
|
+
`[EXULU] failed to persist run progress for job ${bullmqJob.id}.`,
|
|
625
|
+
persistError,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
537
628
|
reject(new Error(error instanceof Error ? error.message : String(error)));
|
|
538
629
|
}
|
|
539
|
-
await new Promise((resolve) => setTimeout((
|
|
630
|
+
await new Promise((resolve) => setTimeout(() => resolve(true), 2000));
|
|
540
631
|
}
|
|
541
632
|
}
|
|
542
633
|
});
|
|
@@ -545,11 +636,73 @@ export const createWorkers = async (
|
|
|
545
636
|
const messages = result.messages;
|
|
546
637
|
const metadata = result.metadata;
|
|
547
638
|
|
|
639
|
+
// Token accumulation across pause/resume (spec §5.7): a resumed
|
|
640
|
+
// continuation only counted its own steps — sum with any
|
|
641
|
+
// pre-pause token counts persisted on the row (kept there by
|
|
642
|
+
// upsertWorkflowRunStart's metadata merge). Fresh runs have no
|
|
643
|
+
// prior tokens and pass through unchanged.
|
|
644
|
+
const rowBeforeWrite = await db
|
|
645
|
+
.from("job_results")
|
|
646
|
+
.where({ id: jobResultId })
|
|
647
|
+
.first();
|
|
648
|
+
const priorTokens = parseRunMetadata(rowBeforeWrite?.metadata).tokens as
|
|
649
|
+
| Record<string, number>
|
|
650
|
+
| undefined;
|
|
651
|
+
const tokens = {
|
|
652
|
+
totalTokens: (priorTokens?.totalTokens ?? 0) + metadata.tokens.totalTokens,
|
|
653
|
+
reasoningTokens:
|
|
654
|
+
(priorTokens?.reasoningTokens ?? 0) + metadata.tokens.reasoningTokens,
|
|
655
|
+
inputTokens: (priorTokens?.inputTokens ?? 0) + metadata.tokens.inputTokens,
|
|
656
|
+
outputTokens: (priorTokens?.outputTokens ?? 0) + metadata.tokens.outputTokens,
|
|
657
|
+
cachedInputTokens:
|
|
658
|
+
(priorTokens?.cachedInputTokens ?? 0) + metadata.tokens.cachedInputTokens,
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
if (result.pausedAtStepIndex !== undefined) {
|
|
662
|
+
// Pause is success (spec §5.3): persist progress and flip to
|
|
663
|
+
// waiting_approval synchronously BEFORE returning — the
|
|
664
|
+
// completed-handler CAS (state = active) can then never
|
|
665
|
+
// clobber it. CAS keeps an admin cancel-during-pause intact.
|
|
666
|
+
await db
|
|
667
|
+
.from("job_results")
|
|
668
|
+
.where({ id: jobResultId })
|
|
669
|
+
.update({
|
|
670
|
+
result:
|
|
671
|
+
messages.length > 0 ? JSON.stringify(messages[messages.length - 1]) : null,
|
|
672
|
+
metadata: JSON.stringify({
|
|
673
|
+
messages,
|
|
674
|
+
...metadata,
|
|
675
|
+
tokens,
|
|
676
|
+
...runBookkeeping,
|
|
677
|
+
current_step_index: result.pausedAtStepIndex,
|
|
678
|
+
}),
|
|
679
|
+
});
|
|
680
|
+
await casJobResultState(
|
|
681
|
+
db,
|
|
682
|
+
jobResultId,
|
|
683
|
+
[JOB_STATUS_ENUM.active, JOB_STATUS_ENUM.waiting],
|
|
684
|
+
JOB_STATUS_ENUM.waiting_approval,
|
|
685
|
+
);
|
|
686
|
+
return {
|
|
687
|
+
result: messages[messages.length - 1],
|
|
688
|
+
metadata: {
|
|
689
|
+
messages,
|
|
690
|
+
...metadata,
|
|
691
|
+
tokens,
|
|
692
|
+
...runBookkeeping,
|
|
693
|
+
current_step_index: result.pausedAtStepIndex,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
548
698
|
return {
|
|
549
699
|
result: messages[messages.length - 1], // last message
|
|
550
700
|
metadata: {
|
|
551
701
|
messages,
|
|
552
702
|
...metadata,
|
|
703
|
+
tokens,
|
|
704
|
+
...runBookkeeping,
|
|
705
|
+
current_step_index: inputMessages.length - 1,
|
|
553
706
|
},
|
|
554
707
|
};
|
|
555
708
|
}
|
|
@@ -931,6 +1084,27 @@ export const createWorkers = async (
|
|
|
931
1084
|
};
|
|
932
1085
|
}
|
|
933
1086
|
|
|
1087
|
+
if (data.type === "email_intake") {
|
|
1088
|
+
console.log("[EXULU] running an email intake job.", bullmqJob.name);
|
|
1089
|
+
|
|
1090
|
+
if (!data.inputs?.s3Key) {
|
|
1091
|
+
throw new Error(`No s3Key set for email intake job.`);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const result = await handleEmailIntake(
|
|
1095
|
+
{
|
|
1096
|
+
s3Key: data.inputs.s3Key,
|
|
1097
|
+
recipient: data.inputs.recipient,
|
|
1098
|
+
},
|
|
1099
|
+
{ config, providers },
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
result,
|
|
1104
|
+
metadata: {},
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
934
1108
|
throw new Error(`Invalid job type: ${data.type} for job ${bullmqJob.name}.`);
|
|
935
1109
|
} catch (error: unknown) {
|
|
936
1110
|
console.error(
|
|
@@ -1001,9 +1175,12 @@ export const createWorkers = async (
|
|
|
1001
1175
|
|
|
1002
1176
|
const { db } = await postgresClient();
|
|
1003
1177
|
|
|
1178
|
+
// CAS (spec §5.3): a paused run returns from the handler with state
|
|
1179
|
+
// already flipped to waiting_approval, and cancel may have won a race
|
|
1180
|
+
// — only an active row may be completed.
|
|
1004
1181
|
await db
|
|
1005
1182
|
.from("job_results")
|
|
1006
|
-
.where({ job_id: job.id })
|
|
1183
|
+
.where({ job_id: job.id, state: JOB_STATUS_ENUM.active })
|
|
1007
1184
|
.update({
|
|
1008
1185
|
state: JOB_STATUS_ENUM.completed,
|
|
1009
1186
|
result: returnvalue.result != null ? JSON.stringify(returnvalue.result) : null,
|
|
@@ -1021,10 +1198,21 @@ export const createWorkers = async (
|
|
|
1021
1198
|
|
|
1022
1199
|
console.error(`[EXULU] failed job ${job.id}.`, error);
|
|
1023
1200
|
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1201
|
+
// CAS: never clobber a pause (success), an admin cancel, or a
|
|
1202
|
+
// completed row — e.g. a BullMQ lock-expiry "failure" arriving after
|
|
1203
|
+
// the run already paused for approval.
|
|
1204
|
+
await db
|
|
1205
|
+
.from("job_results")
|
|
1206
|
+
.where({ job_id: job.id })
|
|
1207
|
+
.whereNotIn("state", [
|
|
1208
|
+
JOB_STATUS_ENUM.waiting_approval,
|
|
1209
|
+
JOB_STATUS_ENUM.cancelled,
|
|
1210
|
+
JOB_STATUS_ENUM.completed,
|
|
1211
|
+
])
|
|
1212
|
+
.update({
|
|
1213
|
+
state: JOB_STATUS_ENUM.failed,
|
|
1214
|
+
error,
|
|
1215
|
+
});
|
|
1028
1216
|
|
|
1029
1217
|
// Cap the table as rows become terminal (every Nth, idempotent).
|
|
1030
1218
|
void maybePruneJobResults(db);
|
|
@@ -1325,6 +1513,9 @@ export const processUiMessagesFlow = async ({
|
|
|
1325
1513
|
config,
|
|
1326
1514
|
variables,
|
|
1327
1515
|
routine,
|
|
1516
|
+
sessionId,
|
|
1517
|
+
resumeFromIndex,
|
|
1518
|
+
respectToolApprovals,
|
|
1328
1519
|
}: {
|
|
1329
1520
|
providers: ExuluProvider[];
|
|
1330
1521
|
agent: ExuluAgent;
|
|
@@ -1343,6 +1534,21 @@ export const processUiMessagesFlow = async ({
|
|
|
1343
1534
|
* callers leave this undefined — they have no routine context.
|
|
1344
1535
|
*/
|
|
1345
1536
|
routine?: { id: string; name: string };
|
|
1537
|
+
/**
|
|
1538
|
+
* Session-backed runs (spec §5.1): persist each step's messages to
|
|
1539
|
+
* agent_messages at the step boundary, pass the session to generateStream
|
|
1540
|
+
* (which reloads history from the DB per step), and hold the
|
|
1541
|
+
* stream-active flag for the session while executing.
|
|
1542
|
+
*/
|
|
1543
|
+
sessionId?: string;
|
|
1544
|
+
/** Skip steps before this index (approval resume / retry-from-step). Default 0. */
|
|
1545
|
+
resumeFromIndex?: number;
|
|
1546
|
+
/**
|
|
1547
|
+
* When true, do NOT blanket-approve every tool — approval-gated tools pause
|
|
1548
|
+
* the run (pausedAtStepIndex). Routines with auto_approve_tools = true and
|
|
1549
|
+
* all legacy callers keep the blanket pre-approval (spec §5.2).
|
|
1550
|
+
*/
|
|
1551
|
+
respectToolApprovals?: boolean;
|
|
1346
1552
|
}): Promise<{
|
|
1347
1553
|
messages: UIMessage[];
|
|
1348
1554
|
metadata: {
|
|
@@ -1355,6 +1561,8 @@ export const processUiMessagesFlow = async ({
|
|
|
1355
1561
|
};
|
|
1356
1562
|
duration: number;
|
|
1357
1563
|
};
|
|
1564
|
+
/** Set when the run paused on an approval-requested tool part (spec §5.3). */
|
|
1565
|
+
pausedAtStepIndex?: number;
|
|
1358
1566
|
}> => {
|
|
1359
1567
|
console.log("[EXULU] processing UI messages flow for agent.");
|
|
1360
1568
|
console.log("[EXULU] input messages", inputMessages);
|
|
@@ -1403,8 +1611,6 @@ export const processUiMessagesFlow = async ({
|
|
|
1403
1611
|
|
|
1404
1612
|
console.log("[EXULU] messages without placeholder", messagesWithoutPlaceholder);
|
|
1405
1613
|
|
|
1406
|
-
// Iterate through the conversation
|
|
1407
|
-
let index = 0;
|
|
1408
1614
|
let messageHistory: {
|
|
1409
1615
|
messages: UIMessage[];
|
|
1410
1616
|
metadata: {
|
|
@@ -1432,171 +1638,206 @@ export const processUiMessagesFlow = async ({
|
|
|
1432
1638
|
};
|
|
1433
1639
|
|
|
1434
1640
|
console.log("[EXULU] variables", variables);
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1641
|
+
|
|
1642
|
+
const startIndex = resumeFromIndex ?? 0;
|
|
1643
|
+
|
|
1644
|
+
// Resume: prior steps already persisted their messages — reload them so the
|
|
1645
|
+
// returned transcript is complete. generateStream reloads its own copy from
|
|
1646
|
+
// the session per step; this keeps messageHistory (the return value +
|
|
1647
|
+
// previousMessages for headless callers) consistent with it.
|
|
1648
|
+
if (sessionId && startIndex > 0) {
|
|
1649
|
+
const priorRows = await getAgentMessages({ session: sessionId, includeAllUsers: true });
|
|
1650
|
+
messageHistory.messages = priorRows.map(
|
|
1651
|
+
(row: { content: string }) => JSON.parse(row.content) as UIMessage,
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
if (sessionId) markStreamActive(sessionId);
|
|
1656
|
+
try {
|
|
1657
|
+
for (let stepIndex = 0; stepIndex < messagesWithoutPlaceholder.length; stepIndex++) {
|
|
1658
|
+
const currentMessage = messagesWithoutPlaceholder[stepIndex]!;
|
|
1659
|
+
if (stepIndex < startIndex) {
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
console.log("[EXULU] running through the conversation");
|
|
1663
|
+
console.log("[EXULU] current index", stepIndex);
|
|
1664
|
+
console.log("[EXULU] current message", currentMessage);
|
|
1665
|
+
console.log("[EXULU] message history", messageHistory);
|
|
1666
|
+
|
|
1667
|
+
// steps_json message ids repeat across runs of the same routine, and
|
|
1668
|
+
// agent_messages.message_id is globally unique (saveChat merges on it) —
|
|
1669
|
+
// persisted run messages need a fresh id per run.
|
|
1670
|
+
if (sessionId) {
|
|
1671
|
+
currentMessage.id = `wfmsg-${uuidv4()}`;
|
|
1465
1672
|
}
|
|
1466
|
-
}
|
|
1467
1673
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1674
|
+
// Identify {variable_name} in the current message parts and replace them
|
|
1675
|
+
// with the values in variables. Throws when a required value is missing;
|
|
1676
|
+
// the auto-provided email variables are empty-safe (spec §4.5).
|
|
1677
|
+
substituteVariablesInMessage(currentMessage, variables);
|
|
1472
1678
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
tokens: {
|
|
1477
|
-
totalTokens: number;
|
|
1478
|
-
reasoningTokens: number;
|
|
1479
|
-
inputTokens: number;
|
|
1480
|
-
outputTokens: number;
|
|
1481
|
-
cachedInputTokens: number;
|
|
1482
|
-
};
|
|
1483
|
-
duration: number;
|
|
1679
|
+
const statistics = {
|
|
1680
|
+
label: agent.name,
|
|
1681
|
+
trigger: "agent" as STATISTICS_LABELS,
|
|
1484
1682
|
};
|
|
1485
|
-
}>(async (resolve, reject) => {
|
|
1486
|
-
const startTime = Date.now();
|
|
1487
1683
|
|
|
1488
1684
|
try {
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
exuluConfig: config,
|
|
1504
|
-
});
|
|
1685
|
+
messageHistory = await new Promise<{
|
|
1686
|
+
messages: UIMessage[];
|
|
1687
|
+
metadata: {
|
|
1688
|
+
tokens: {
|
|
1689
|
+
totalTokens: number;
|
|
1690
|
+
reasoningTokens: number;
|
|
1691
|
+
inputTokens: number;
|
|
1692
|
+
outputTokens: number;
|
|
1693
|
+
cachedInputTokens: number;
|
|
1694
|
+
};
|
|
1695
|
+
duration: number;
|
|
1696
|
+
};
|
|
1697
|
+
}>(async (resolve, reject) => {
|
|
1698
|
+
const startTime = Date.now();
|
|
1505
1699
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1700
|
+
try {
|
|
1701
|
+
const result = await provider.generateStream({
|
|
1702
|
+
contexts,
|
|
1703
|
+
agent: agent,
|
|
1704
|
+
user,
|
|
1705
|
+
// Legacy blanket pre-approval unless this run respects
|
|
1706
|
+
// approvals (spec §5.2 — auto_approve_tools = false routines).
|
|
1707
|
+
approvedTools: respectToolApprovals
|
|
1708
|
+
? undefined
|
|
1709
|
+
: tools.map((tool) => "tool-" + sanitizeToolName(tool.name)),
|
|
1710
|
+
instructions: agent.instructions,
|
|
1711
|
+
session: sessionId,
|
|
1712
|
+
previousMessages: messageHistory.messages,
|
|
1713
|
+
message: currentMessage,
|
|
1714
|
+
currentTools: enabledTools,
|
|
1715
|
+
allExuluTools: tools,
|
|
1716
|
+
languageModel: resolvedLanguageModel,
|
|
1717
|
+
providerapikey,
|
|
1718
|
+
toolConfigs: agent.tools,
|
|
1719
|
+
exuluConfig: config,
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
console.log("[EXULU] consuming stream for agent.");
|
|
1723
|
+
const stream = result.stream.toUIMessageStream({
|
|
1724
|
+
messageMetadata: ({ part }) => {
|
|
1725
|
+
console.log("[EXULU] part", part.type);
|
|
1726
|
+
if (part.type === "finish") {
|
|
1727
|
+
return {
|
|
1728
|
+
totalTokens: part.totalUsage.totalTokens,
|
|
1729
|
+
reasoningTokens: part.totalUsage.reasoningTokens,
|
|
1730
|
+
inputTokens: part.totalUsage.inputTokens,
|
|
1731
|
+
outputTokens: part.totalUsage.outputTokens,
|
|
1732
|
+
cachedInputTokens: part.totalUsage.cachedInputTokens,
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
return undefined;
|
|
1736
|
+
},
|
|
1737
|
+
originalMessages: result.originalMessages,
|
|
1738
|
+
sendReasoning: true,
|
|
1739
|
+
sendSources: true,
|
|
1740
|
+
onError: (error) => {
|
|
1741
|
+
console.error("[EXULU] Ui message stream error.", error);
|
|
1742
|
+
reject(new Error(error instanceof Error ? error.message : String(error)));
|
|
1743
|
+
return `Ui message stream error: ${error instanceof Error ? error.message : String(error)}`;
|
|
1744
|
+
},
|
|
1745
|
+
onFinish: async ({ messages }) => {
|
|
1746
|
+
const metadata = messages[messages.length - 1]?.metadata as any;
|
|
1747
|
+
console.log("[EXULU] Stream finished with messages:", messages);
|
|
1748
|
+
console.log("[EXULU] Stream metadata", metadata);
|
|
1749
|
+
await Promise.all([
|
|
1750
|
+
updateStatistic({
|
|
1751
|
+
name: "count",
|
|
1752
|
+
label: statistics.label,
|
|
1753
|
+
type: STATISTICS_TYPE_ENUM.AGENT_RUN as STATISTICS_TYPE,
|
|
1754
|
+
trigger: statistics.trigger,
|
|
1755
|
+
count: 1,
|
|
1756
|
+
user: user.id,
|
|
1757
|
+
role: user?.role?.id,
|
|
1758
|
+
}),
|
|
1759
|
+
...(metadata?.inputTokens
|
|
1760
|
+
? [
|
|
1761
|
+
updateStatistic({
|
|
1762
|
+
name: "inputTokens",
|
|
1763
|
+
label: statistics.label,
|
|
1764
|
+
type: STATISTICS_TYPE_ENUM.AGENT_RUN as STATISTICS_TYPE,
|
|
1765
|
+
trigger: statistics.trigger,
|
|
1766
|
+
count: metadata?.inputTokens,
|
|
1767
|
+
user: user.id,
|
|
1768
|
+
role: user?.role?.id,
|
|
1769
|
+
}),
|
|
1770
|
+
]
|
|
1771
|
+
: []),
|
|
1772
|
+
...(metadata?.outputTokens
|
|
1773
|
+
? [
|
|
1774
|
+
updateStatistic({
|
|
1775
|
+
name: "outputTokens",
|
|
1776
|
+
label: statistics.label,
|
|
1777
|
+
type: STATISTICS_TYPE_ENUM.AGENT_RUN as STATISTICS_TYPE,
|
|
1778
|
+
trigger: statistics.trigger,
|
|
1779
|
+
count: metadata?.outputTokens,
|
|
1780
|
+
}),
|
|
1781
|
+
]
|
|
1782
|
+
: []),
|
|
1783
|
+
]);
|
|
1784
|
+
resolve({
|
|
1785
|
+
messages,
|
|
1786
|
+
metadata: {
|
|
1787
|
+
tokens: {
|
|
1788
|
+
totalTokens:
|
|
1789
|
+
messageHistory.metadata.tokens.totalTokens + metadata?.totalTokens,
|
|
1790
|
+
reasoningTokens:
|
|
1791
|
+
messageHistory.metadata.tokens.reasoningTokens + metadata?.reasoningTokens,
|
|
1792
|
+
inputTokens:
|
|
1793
|
+
messageHistory.metadata.tokens.inputTokens + metadata?.inputTokens,
|
|
1794
|
+
outputTokens:
|
|
1795
|
+
messageHistory.metadata.tokens.outputTokens + metadata?.outputTokens,
|
|
1796
|
+
cachedInputTokens:
|
|
1797
|
+
messageHistory.metadata.tokens.cachedInputTokens +
|
|
1798
|
+
metadata?.cachedInputTokens,
|
|
1799
|
+
},
|
|
1800
|
+
duration: messageHistory.metadata.duration + (Date.now() - startTime),
|
|
1801
|
+
},
|
|
1802
|
+
});
|
|
1582
1803
|
},
|
|
1583
1804
|
});
|
|
1584
|
-
|
|
1805
|
+
|
|
1806
|
+
// Consume the stream to ensure it runs to completion & triggers onFinish
|
|
1807
|
+
for await (const message of stream) {
|
|
1808
|
+
console.log("[EXULU] message", message);
|
|
1809
|
+
}
|
|
1810
|
+
} catch (error: unknown) {
|
|
1811
|
+
console.error(
|
|
1812
|
+
`[EXULU] error generating stream for agent ${agent.name} (${agent.id}).`,
|
|
1813
|
+
error,
|
|
1814
|
+
);
|
|
1815
|
+
reject(new Error(error instanceof Error ? error.message : String(error)));
|
|
1816
|
+
}
|
|
1585
1817
|
});
|
|
1818
|
+
} catch (error: unknown) {
|
|
1819
|
+
// Carry the failing step so the workflow handler's retry loop resumes
|
|
1820
|
+
// here instead of re-running (and re-persisting) earlier steps.
|
|
1821
|
+
throw new FlowStepError(stepIndex, error);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if (sessionId) {
|
|
1825
|
+
// Step boundary (spec §5.1): persist the accumulated transcript.
|
|
1826
|
+
// saveChat merges on message_id, so re-saving prior messages is
|
|
1827
|
+
// idempotent (no duplicates on resume or re-save).
|
|
1828
|
+
await saveChat({ session: sessionId, user: user.id, messages: messageHistory.messages });
|
|
1829
|
+
}
|
|
1586
1830
|
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1831
|
+
if (respectToolApprovals && sessionId) {
|
|
1832
|
+
const lastMessage = messageHistory.messages[messageHistory.messages.length - 1];
|
|
1833
|
+
if (messageHasPendingApproval(lastMessage)) {
|
|
1834
|
+
console.log("[EXULU] run paused for tool approval at step", stepIndex);
|
|
1835
|
+
return { ...messageHistory, pausedAtStepIndex: stepIndex };
|
|
1590
1836
|
}
|
|
1591
|
-
} catch (error: unknown) {
|
|
1592
|
-
console.error(
|
|
1593
|
-
`[EXULU] error generating stream for agent ${agent.name} (${agent.id}).`,
|
|
1594
|
-
error,
|
|
1595
|
-
);
|
|
1596
|
-
reject(new Error(error instanceof Error ? error.message : String(error)));
|
|
1597
1837
|
}
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1838
|
+
}
|
|
1839
|
+
} finally {
|
|
1840
|
+
if (sessionId) clearStreamActive(sessionId);
|
|
1600
1841
|
}
|
|
1601
1842
|
console.log(
|
|
1602
1843
|
"[EXULU] finished processing UI messages flow for agent, messages result",
|