@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/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
- await db.from("job_results").insert({
480
- job_id: bullmqJob.id,
481
- label: label,
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
- result: null,
484
- metadata: {},
485
- tries: 1,
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((resolve) => resolve(true), 2000));
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
- await db.from("job_results").where({ job_id: job.id }).update({
1025
- state: JOB_STATUS_ENUM.failed,
1026
- error,
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
- for (const currentMessage of messagesWithoutPlaceholder) {
1436
- console.log("[EXULU] running through the conversation");
1437
- console.log("[EXULU] current index", index);
1438
- console.log("[EXULU] current message", currentMessage);
1439
- console.log("[EXULU] message history", messageHistory);
1440
-
1441
- // Identify {variable_name} in the current message parts
1442
- // Replace them with the values in variables
1443
- // If any are missing, throw an error
1444
- for (const part of currentMessage.parts) {
1445
- if (part.type === "text") {
1446
- const text = part.text;
1447
- const variableNames = [...text.matchAll(/{([^}]+)}/g)].map((match) => match[1]);
1448
- if (variableNames) {
1449
- for (const variableName of variableNames) {
1450
- if (!variableName) {
1451
- continue;
1452
- }
1453
- console.log("[EXULU] variableName", variableName);
1454
- const variableValue = variables?.[variableName];
1455
- console.log("[EXULU] variableValue", variableValue);
1456
- if (variableValue) {
1457
- part.text = part.text.replaceAll(`{${variableName}}`, variableValue);
1458
- } else {
1459
- throw new Error(
1460
- `Value for variable ${variableName} not provided in variables for processing message flow. Either remove it from the messages, or provide it as an argument.`,
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
- const statistics = {
1469
- label: agent.name,
1470
- trigger: "agent" as STATISTICS_LABELS,
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
- messageHistory = await new Promise<{
1474
- messages: UIMessage[];
1475
- metadata: {
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
- const result = await provider.generateStream({
1490
- contexts,
1491
- agent: agent,
1492
- user,
1493
- approvedTools: tools.map((tool) => "tool-" + sanitizeToolName(tool.name)),
1494
- instructions: agent.instructions,
1495
- session: undefined,
1496
- previousMessages: messageHistory.messages,
1497
- message: currentMessage,
1498
- currentTools: enabledTools,
1499
- allExuluTools: tools,
1500
- languageModel: resolvedLanguageModel,
1501
- providerapikey,
1502
- toolConfigs: agent.tools,
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
- console.log("[EXULU] consuming stream for agent.");
1507
- const stream = result.stream.toUIMessageStream({
1508
- messageMetadata: ({ part }) => {
1509
- console.log("[EXULU] part", part.type);
1510
- if (part.type === "finish") {
1511
- return {
1512
- totalTokens: part.totalUsage.totalTokens,
1513
- reasoningTokens: part.totalUsage.reasoningTokens,
1514
- inputTokens: part.totalUsage.inputTokens,
1515
- outputTokens: part.totalUsage.outputTokens,
1516
- cachedInputTokens: part.totalUsage.cachedInputTokens,
1517
- };
1518
- }
1519
- return undefined;
1520
- },
1521
- originalMessages: result.originalMessages,
1522
- sendReasoning: true,
1523
- sendSources: true,
1524
- onError: (error) => {
1525
- console.error("[EXULU] Ui message stream error.", error);
1526
- reject(new Error(error instanceof Error ? error.message : String(error)));
1527
- return `Ui message stream error: ${error instanceof Error ? error.message : String(error)}`;
1528
- },
1529
- onFinish: async ({ messages }) => {
1530
- const metadata = messages[messages.length - 1]?.metadata as any;
1531
- console.log("[EXULU] Stream finished with messages:", messages);
1532
- console.log("[EXULU] Stream metadata", metadata);
1533
- await Promise.all([
1534
- updateStatistic({
1535
- name: "count",
1536
- label: statistics.label,
1537
- type: STATISTICS_TYPE_ENUM.AGENT_RUN as STATISTICS_TYPE,
1538
- trigger: statistics.trigger,
1539
- count: 1,
1540
- user: user.id,
1541
- role: user?.role?.id,
1542
- }),
1543
- ...(metadata?.inputTokens
1544
- ? [
1545
- updateStatistic({
1546
- name: "inputTokens",
1547
- label: statistics.label,
1548
- type: STATISTICS_TYPE_ENUM.AGENT_RUN as STATISTICS_TYPE,
1549
- trigger: statistics.trigger,
1550
- count: metadata?.inputTokens,
1551
- user: user.id,
1552
- role: user?.role?.id,
1553
- }),
1554
- ]
1555
- : []),
1556
- ...(metadata?.outputTokens
1557
- ? [
1558
- updateStatistic({
1559
- name: "outputTokens",
1560
- label: statistics.label,
1561
- type: STATISTICS_TYPE_ENUM.AGENT_RUN as STATISTICS_TYPE,
1562
- trigger: statistics.trigger,
1563
- count: metadata?.outputTokens,
1564
- }),
1565
- ]
1566
- : []),
1567
- ]);
1568
- resolve({
1569
- messages,
1570
- metadata: {
1571
- tokens: {
1572
- totalTokens: messageHistory.metadata.tokens.totalTokens + metadata?.totalTokens,
1573
- reasoningTokens:
1574
- messageHistory.metadata.tokens.reasoningTokens + metadata?.reasoningTokens,
1575
- inputTokens: messageHistory.metadata.tokens.inputTokens + metadata?.inputTokens,
1576
- outputTokens:
1577
- messageHistory.metadata.tokens.outputTokens + metadata?.outputTokens,
1578
- cachedInputTokens:
1579
- messageHistory.metadata.tokens.cachedInputTokens + metadata?.cachedInputTokens,
1580
- },
1581
- duration: messageHistory.metadata.duration + (Date.now() - startTime),
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
- // Consume the stream to ensure it runs to completion & triggers onFinish
1588
- for await (const message of stream) {
1589
- console.log("[EXULU] message", message);
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
- index++;
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",