@exulu/backend 3.1.0 → 3.3.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.
@@ -4,13 +4,13 @@ import { createAgenticRetrievalTool, parsePreselectedItems } from "./index";
4
4
  jest.mock("@EE/entitlements", () => ({ checkLicense: () => ({ "agentic-retrieval": true }) }));
5
5
  jest.mock("@SRC/exulu/resolve-reranker", () => ({ resolveReranker: jest.fn(async () => ({ model: "m", rerank: async (_q: any, c: any) => c })) }));
6
6
  jest.mock("@SRC/exulu/resolve-model", () => ({ resolveModel: jest.fn() }));
7
- jest.mock("@SRC/exulu/app/singleton", () => ({ exuluApp: { get: () => ({ providers: [] }) } }));
7
+ jest.mock("@SRC/exulu/app/singleton", () => ({ exuluApp: { get: () => ({}) } }));
8
8
  jest.mock("./routing", () => ({ runRoutingPhase: jest.fn(async () => ({
9
9
  mainContexts: ["docs"], fallbackContexts: [], userPinnedItemIdsByContext: new Map(),
10
10
  userRequestedPage: null, hasExplicitDocAndPage: false, steps: [{ text: "routed" }] })) }));
11
11
  jest.mock("./memory", () => ({ runMemoryPhase: jest.fn(async () => ({
12
12
  memoryChunksForAnswer: [], memoryOverride: { active: false, chunks: [], reason: "" },
13
- memoryPinnedItemIds: new Set(), updatedQuestion: "q", updatedKeywords: ["k"],
13
+ memoryPinnedItemIdsByContext: new Map(), updatedQuestion: "q", updatedKeywords: ["k"],
14
14
  updatedImportantKeyword: "k", steps: [] })) }));
15
15
  jest.mock("./prefilter", () => ({ resolveIdentifierPins: jest.fn(async () => ({
16
16
  pinsByContext: new Map(), exactPinsByContext: new Map(), steps: [] })) }));
@@ -75,7 +75,7 @@ describe("createAgenticRetrievalTool", () => {
75
75
  runMemoryPhase.mockResolvedValueOnce({
76
76
  memoryChunksForAnswer: [{ chunk_id: "m1" }],
77
77
  memoryOverride: { active: false, chunks: [], reason: "" },
78
- memoryPinnedItemIds: new Set(),
78
+ memoryPinnedItemIdsByContext: new Map(),
79
79
  updatedQuestion: "q",
80
80
  updatedKeywords: ["k"],
81
81
  updatedImportantKeyword: "k",
@@ -114,7 +114,7 @@ describe("payload deduplication", () => {
114
114
  runMemoryPhase.mockResolvedValueOnce({
115
115
  memoryChunksForAnswer: [memChunk],
116
116
  memoryOverride: { active: false, chunks: [], reason: "" },
117
- memoryPinnedItemIds: new Set(), updatedQuestion: "q", updatedKeywords: ["k"],
117
+ memoryPinnedItemIdsByContext: new Map(), updatedQuestion: "q", updatedKeywords: ["k"],
118
118
  updatedImportantKeyword: "k",
119
119
  steps: [{ text: "memory step", chunks: [memChunk] }],
120
120
  });
@@ -274,7 +274,6 @@ export function createAgenticRetrievalTool(opts: {
274
274
  const resolved = await resolveModel({
275
275
  modelId: cfg.utilityModel,
276
276
  user,
277
- providers: exuluApp.get().providers,
278
277
  rbacBypass: true,
279
278
  });
280
279
  utilityModel = resolved.languageModel ?? model;
@@ -426,7 +425,7 @@ export function createAgenticRetrievalTool(opts: {
426
425
  updatedQuestion,
427
426
  updatedKeywords,
428
427
  updatedImportantKeyword,
429
- memoryPinnedItemIds,
428
+ memoryPinnedItemIdsByContext,
430
429
  memoryOverride,
431
430
  } = memResult;
432
431
 
@@ -465,7 +464,7 @@ export function createAgenticRetrievalTool(opts: {
465
464
  model: utilityModel,
466
465
  preselectedItems,
467
466
  identifierPinsByContext,
468
- memoryPinnedItemIds,
467
+ memoryPinnedItemIdsByContext,
469
468
  userPinnedItemIdsByContext,
470
469
  scopedItemsByContext: resolvedProject?.scopedItemsByContext,
471
470
  rewrites: cfg.vocabulary.rewrites,
@@ -486,7 +485,7 @@ export function createAgenticRetrievalTool(opts: {
486
485
  model: utilityModel,
487
486
  preselectedItems,
488
487
  identifierPinsByContext,
489
- memoryPinnedItemIds,
488
+ memoryPinnedItemIdsByContext,
490
489
  userPinnedItemIdsByContext,
491
490
  scopedItemsByContext: resolvedProject?.scopedItemsByContext,
492
491
  rewrites: cfg.vocabulary.rewrites,
@@ -500,7 +499,9 @@ export function createAgenticRetrievalTool(opts: {
500
499
  // ── Build rerank state ────────────────────────────────────────────────
501
500
  // pinnedItemIds = memory ∪ exact identifier pins ∪ user pins ∪ project pins
502
501
  const pinnedItemIds = new Set<string>([
503
- ...memoryPinnedItemIds,
502
+ ...(function* () {
503
+ for (const s of memoryPinnedItemIdsByContext.values()) yield* s;
504
+ })(),
504
505
  ...(function* () {
505
506
  for (const s of exactPinsByContext.values()) yield* s;
506
507
  })(),
@@ -74,7 +74,7 @@ describe("runMemoryPhase", () => {
74
74
  expect(r.updatedImportantKeyword).toBe("FST-2XT");
75
75
  });
76
76
 
77
- it("resolves file-prioritization pins across all document contexts", async () => {
77
+ it("resolves file-prioritization pins keyed by their document context", async () => {
78
78
  (generateText as jest.Mock)
79
79
  .mockResolvedValueOnce({ output: { relevantChunkIds: ["1"] } })
80
80
  .mockResolvedValueOnce({ output: { shouldPrioritizeFiles: true, fileNameHints: ["PROJECT_NOTES"] } });
@@ -82,7 +82,9 @@ describe("runMemoryPhase", () => {
82
82
  const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "always check PROJECT_NOTES")],
83
83
  memoryContext: undefined, documentContexts: [{ id: "docs" }],
84
84
  memoryConfig: { enabled: true, override: false, filePrioritization: true, queryAugmentation: false } });
85
- expect([...r.memoryPinnedItemIds]).toEqual(["d1"]);
85
+ // Pins are keyed by the context they were resolved in, so a consumer can apply them
86
+ // only to that context (no cross-context leak). See search.ts rule 2b.
87
+ expect([...(r.memoryPinnedItemIdsByContext.get("docs") ?? [])]).toEqual(["d1"]);
86
88
  });
87
89
 
88
90
  it("never throws even when post-Promise.all processing encounters runtime errors", async () => {
@@ -129,7 +129,7 @@ function neutralResult(
129
129
  return {
130
130
  memoryChunksForAnswer: [],
131
131
  memoryOverride: { active: false, chunks: [], reason: "" },
132
- memoryPinnedItemIds: new Set(),
132
+ memoryPinnedItemIdsByContext: new Map(),
133
133
  updatedQuestion: question,
134
134
  updatedKeywords: keywords,
135
135
  updatedImportantKeyword: importantKeyword,
@@ -272,7 +272,7 @@ export async function runMemoryPhase({
272
272
  chunks: [],
273
273
  reason: "",
274
274
  };
275
- let memoryPinnedItemIds = new Set<string>();
275
+ const memoryPinnedItemIdsByContext = new Map<string, Set<string>>();
276
276
  let updatedQuestion = question;
277
277
  let updatedKeywords = keywords;
278
278
  let updatedImportantKeyword = importantKeyword;
@@ -466,12 +466,15 @@ export async function runMemoryPhase({
466
466
  };
467
467
  }
468
468
 
469
- // File prioritization: resolve hints via fuzzyPrefilter against EVERY documentContexts entry
469
+ // File prioritization: resolve hints via fuzzyPrefilter against EVERY documentContexts entry.
470
+ // Pins are kept keyed by the context they were resolved in, so the search phase can apply a
471
+ // pin only to its home context — a tech_doc file must never filter a software-docs search to 0.
470
472
  if (fileResult.output?.shouldPrioritizeFiles && fileResult.output?.fileNameHints?.length) {
471
473
  const hints = fileResult.output.fileNameHints;
472
474
  const pinResults = await Promise.all(
473
- documentContexts.map((ctx) =>
474
- fuzzyPrefilter({
475
+ documentContexts.map(async (ctx) => ({
476
+ ctxId: ctx.id as string,
477
+ matches: await fuzzyPrefilter({
475
478
  cacheKey: `memory-pin:${ctx.id}`,
476
479
  relevantKeywords: hints,
477
480
  context: ctx,
@@ -479,17 +482,21 @@ export async function runMemoryPhase({
479
482
  normalize: (item: any) =>
480
483
  item.external_id ? normalizeFileName(item.external_id) : item.name,
481
484
  }).catch(() => []),
482
- ),
485
+ })),
483
486
  );
484
- for (const results of pinResults) {
485
- for (const r of results) {
486
- memoryPinnedItemIds.add(r.id);
487
+ const pinnedNames: string[] = [];
488
+ for (const { ctxId, matches } of pinResults) {
489
+ if (!matches.length) continue;
490
+ const set = memoryPinnedItemIdsByContext.get(ctxId) ?? new Set<string>();
491
+ for (const m of matches) {
492
+ set.add(m.id);
493
+ pinnedNames.push(m.name);
487
494
  }
495
+ memoryPinnedItemIdsByContext.set(ctxId, set);
488
496
  }
489
- if (memoryPinnedItemIds.size > 0) {
490
- const names = pinResults.flat().map((i) => i.name).join(", ");
497
+ if (pinnedNames.length > 0) {
491
498
  steps.push({
492
- text: `Memory prioritizes specific document(s); pinning ${memoryPinnedItemIds.size} file(s) into the search: ${names}`,
499
+ text: `Memory prioritizes specific document(s); pinning ${pinnedNames.length} file(s) into the search: ${pinnedNames.join(", ")}`,
493
500
  });
494
501
  }
495
502
  }
@@ -520,7 +527,7 @@ export async function runMemoryPhase({
520
527
  return {
521
528
  memoryChunksForAnswer,
522
529
  memoryOverride,
523
- memoryPinnedItemIds,
530
+ memoryPinnedItemIdsByContext,
524
531
  updatedQuestion,
525
532
  updatedKeywords,
526
533
  updatedImportantKeyword,
@@ -20,7 +20,7 @@ const base = {
20
20
  } as any,
21
21
  question: "how to fix door error E42", keywords: ["door", "E42"], importantKeyword: "E42",
22
22
  user: {}, role: "r", model: {},
23
- preselectedItems: new Map(), identifierPinsByContext: new Map(), memoryPinnedItemIds: new Set<string>(),
23
+ preselectedItems: new Map(), identifierPinsByContext: new Map(), memoryPinnedItemIdsByContext: new Map<string, Set<string>>(),
24
24
  userPinnedItemIdsByContext: new Map(), rewrites: [{ find: "fix", replace: "repair" }],
25
25
  styleHint: "", maxQueries: 5, skipPrefilter: false,
26
26
  };
@@ -55,7 +55,7 @@ describe("searchContexts", () => {
55
55
  await searchContexts({
56
56
  ...base, contextIds: ["docs"],
57
57
  identifierPinsByContext: new Map([["docs", new Set(["i1"])]]),
58
- memoryPinnedItemIds: new Set(["m1"]),
58
+ memoryPinnedItemIdsByContext: new Map([["docs", new Set(["m1"])]]),
59
59
  });
60
60
  expect(new Set((multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds)).toEqual(new Set(["i1", "m1"]));
61
61
 
@@ -63,12 +63,27 @@ describe("searchContexts", () => {
63
63
  await searchContexts({
64
64
  ...base, contextIds: ["docs"],
65
65
  identifierPinsByContext: new Map([["docs", new Set(["i1"])]]),
66
- memoryPinnedItemIds: new Set(["m1"]),
66
+ memoryPinnedItemIdsByContext: new Map([["docs", new Set(["m1"])]]),
67
67
  userPinnedItemIdsByContext: new Map([["docs", new Set(["u1"])]]),
68
68
  });
69
69
  expect((multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds).toEqual(["u1"]);
70
70
  });
71
71
 
72
+ it("memory pins apply ONLY to their own context (no cross-context leak)", async () => {
73
+ // Regression: a memory pin resolved in another context (e.g. tech_doc's FST-Miscel-Secrets)
74
+ // must not become a hard id-whitelist on THIS context, which would filter it down to 0 chunks.
75
+ await searchContexts({
76
+ ...base, contextIds: ["docs"],
77
+ memoryPinnedItemIdsByContext: new Map([
78
+ ["docs", new Set(["m_docs"])],
79
+ ["other", new Set(["m_other"])],
80
+ ]),
81
+ });
82
+ const pins = (multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds;
83
+ expect(pins).toEqual(["m_docs"]); // own-context pin applied
84
+ expect(pins).not.toContain("m_other"); // foreign-context pin did NOT leak in
85
+ });
86
+
72
87
  it("preselected items win over everything and skip prefilters", async () => {
73
88
  await searchContexts({
74
89
  ...base, contextIds: ["docs"],
@@ -95,7 +110,7 @@ describe("searchContexts", () => {
95
110
  await searchContexts({
96
111
  ...base, contextIds: ["docs"], skipPrefilter: true,
97
112
  identifierPinsByContext: new Map([["docs", new Set(["i1"])]]),
98
- memoryPinnedItemIds: new Set(["m1"]),
113
+ memoryPinnedItemIdsByContext: new Map([["docs", new Set(["m1"])]]),
99
114
  });
100
115
  expect((multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds).toEqual([]);
101
116
  });
@@ -25,7 +25,7 @@ export async function searchContexts(opts: {
25
25
  preselectedItems: Map<string, string[] | null>;
26
26
  scopedItemsByContext?: Map<string, string[] | null>; // Project-added sources: hard item filter per context (null = whole context).
27
27
  identifierPinsByContext: Map<string, Set<string>>; // from resolveIdentifierPins
28
- memoryPinnedItemIds: Set<string>; // from memory phase (documents kind only)
28
+ memoryPinnedItemIdsByContext: Map<string, Set<string>>; // from memory phase (documents kind only), keyed by home context
29
29
  userPinnedItemIdsByContext: Map<string, Set<string>>; // from routing phase
30
30
  rewrites: { find: string; replace: string }[];
31
31
  styleHint: string;
@@ -45,7 +45,7 @@ export async function searchContexts(opts: {
45
45
  preselectedItems,
46
46
  scopedItemsByContext,
47
47
  identifierPinsByContext,
48
- memoryPinnedItemIds,
48
+ memoryPinnedItemIdsByContext,
49
49
  userPinnedItemIdsByContext,
50
50
  rewrites,
51
51
  styleHint,
@@ -94,9 +94,13 @@ export async function searchContexts(opts: {
94
94
  const identifierPins = identifierPinsByContext.get(ctxId) ?? new Set<string>();
95
95
  let pins = new Set<string>(identifierPins);
96
96
 
97
- // 2b: documents kind only — UNION with memoryPinnedItemIds
97
+ // 2b: documents kind only — UNION with memory pins that belong to THIS context.
98
+ // Memory pins are keyed by their home context so a pin resolved elsewhere (e.g. a
99
+ // tech_doc file) never becomes a hard id-whitelist on a context that lacks it, which
100
+ // would prefilter that context down to 0 chunks.
98
101
  if (kind === "documents") {
99
- for (const id of memoryPinnedItemIds) pins.add(id);
102
+ const memPins = memoryPinnedItemIdsByContext.get(ctxId);
103
+ if (memPins) for (const id of memPins) pins.add(id);
100
104
  }
101
105
 
102
106
  // 2c: user pins REPLACE everything (authoritative)
@@ -21,7 +21,7 @@ export type RoutingPhaseResult = {
21
21
  export type MemoryPhaseResult = {
22
22
  memoryChunksForAnswer: ChunkWithScore[];
23
23
  memoryOverride: { active: boolean; chunks: ChunkWithScore[]; reason: string };
24
- memoryPinnedItemIds: Set<string>;
24
+ memoryPinnedItemIdsByContext: Map<string, Set<string>>;
25
25
  updatedQuestion: string;
26
26
  updatedKeywords: string[];
27
27
  updatedImportantKeyword: string;
@@ -146,7 +146,6 @@ async function resolveVlmModel(
146
146
 
147
147
  const { languageModel } = await resolveModel({
148
148
  modelId,
149
- providers: [], // unused in LiteLLM mode; resolveModel ignores it there
150
149
  user: config?.attribution?.user,
151
150
  project: config?.attribution?.project,
152
151
  agent: config?.attribution?.agent,
@@ -5,6 +5,19 @@ import { BullMQOtel } from "bullmq-otel";
5
5
  import type { ExuluQueueConfig } from "@EXULU_TYPES/queue-config";
6
6
  import { checkLicense } from "@EE/entitlements";
7
7
 
8
+ /**
9
+ * Built-in/system queues that ExuluApp registers automatically. They back
10
+ * internal processing (eval runs, inbound email intake) and must never be
11
+ * offered to users as a routine's run queue — the `queues` GraphQL query
12
+ * excludes them (see resolveAvailableQueues). Underscore, not hyphen:
13
+ * registered queue names are interpolated verbatim into the GraphQL QueueEnum,
14
+ * where "-" is illegal.
15
+ */
16
+ export const global_queues = {
17
+ eval_runs: "eval_runs",
18
+ email_intake: "email_intake",
19
+ };
20
+
8
21
  // Used for workflows and embedders
9
22
  class ExuluQueues {
10
23
  queues: {
package/ee/schemas.ts CHANGED
@@ -419,6 +419,10 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
419
419
  type: "boolean",
420
420
  default: false,
421
421
  },
422
+ {
423
+ name: "queue",
424
+ type: "text"
425
+ }
422
426
  ],
423
427
  };
424
428
 
@@ -454,16 +458,13 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
454
458
  type: "boolean",
455
459
  default: false,
456
460
  },
457
- {
458
- // Generated server-side: {routine-slug}-{8 hex}@{inbound_domain}.
459
- // Real UNIQUE column (not JSON) because the webhook resolves
460
- // triggers by recipient address.
461
- name: "address",
462
- type: "text",
463
- required: true,
464
- unique: true,
465
- index: true,
466
- },
461
+ // Secret capability URL key: base64url randomBytes(32). Both routes
462
+ // and authorizes the webhook (POST /webhooks/routine/:secret).
463
+ { name: "secret", type: "text", required: true, unique: true, index: true },
464
+ // Optional per-trigger HMAC shared secret, AES-encrypted at rest.
465
+ { name: "signing_secret", type: "text" },
466
+ // Stamped on every verified webhook hit; per-trigger setup aid.
467
+ { name: "last_fired_at", type: "date" },
467
468
  {
468
469
  // allowed_senders / filters / filtered_run_retention /
469
470
  // rate_limit_per_hour / sender_rate_limit_per_hour (spec §3.1).
package/ee/workers.ts CHANGED
@@ -16,7 +16,7 @@ import type { BullMqJobData } from "@EE/queues/decorator.ts";
16
16
  import { maybePruneJobResults } from "@EE/queues/prune-job-results.ts";
17
17
  import { type Tracer } from "@opentelemetry/api";
18
18
  import { v4 as uuidv4 } from "uuid";
19
- import { type UIMessage } from "ai";
19
+ import { createIdGenerator, type UIMessage } from "ai";
20
20
  import CryptoJS from "crypto-js";
21
21
  import { STATISTICS_TYPE_ENUM, type STATISTICS_TYPE } from "@EXULU_TYPES/enums/statistics";
22
22
  import type { User } from "@EXULU_TYPES/models/user";
@@ -29,14 +29,15 @@ import type { STATISTICS_LABELS } from "@EXULU_TYPES/statistics.ts";
29
29
  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
- import type { ExuluProvider } from "@SRC/exulu/provider.ts";
33
- import { saveChat, getAgentMessages } from "@SRC/exulu/provider.ts";
32
+ import { saveChat, getAgentMessages, generateStream } from "@SRC/exulu/generate-stream";
34
33
  import { exuluApp } from "@SRC/exulu/app/singleton";
35
34
  import { handleEmailIntake } from "@SRC/exulu/email-inbound/intake";
36
35
  import { markStreamActive, clearStreamActive } from "@SRC/exulu/active-streams.ts";
37
36
  import { messageHasPendingApproval, substituteVariablesInMessage } from "@SRC/exulu/routines/flow-steps.ts";
38
37
  import { createRunSession } from "@SRC/exulu/routines/run-session.ts";
39
38
  import { casJobResultState, parseRunMetadata, upsertWorkflowRunStart } from "@SRC/exulu/routines/run-state.ts";
39
+ import { findLiteLLMModel } from "@SRC/exulu/litellm/catalog.ts";
40
+ import { computeRunCostUsd } from "@SRC/exulu/routines/run-cost.ts";
40
41
 
41
42
  /**
42
43
  * Session-backed runs persist messages at each step boundary, so retries must
@@ -132,7 +133,6 @@ const installGlobalErrorHandlers = () => {
132
133
  let isShuttingDown = false;
133
134
 
134
135
  export const createWorkers = async (
135
- providers: ExuluProvider[],
136
136
  queues: ExuluQueueConfig[],
137
137
  config: ExuluConfig,
138
138
  contexts: ExuluContext[],
@@ -525,11 +525,10 @@ export const createWorkers = async (
525
525
 
526
526
  const {
527
527
  agent,
528
- provider,
529
528
  user,
530
529
  workflow,
531
530
  messages: inputMessages,
532
- } = await validateWorkflowPayload(data, providers);
531
+ } = await validateWorkflowPayload(data);
533
532
 
534
533
  // Session-backed runs (spec §3.4): reuse the session provided by
535
534
  // the enqueuer (email intake / continuation / retry / previous
@@ -576,9 +575,7 @@ export const createWorkers = async (
576
575
  // + substituted text) — pass a fresh deep copy each attempt
577
576
  // so a retry/resume never reuses the mutated array.
578
577
  const messages = await processUiMessagesFlow({
579
- providers,
580
578
  agent,
581
- provider,
582
579
  inputMessages: structuredClone(inputMessages),
583
580
  contexts,
584
581
  user,
@@ -658,6 +655,22 @@ export const createWorkers = async (
658
655
  (priorTokens?.cachedInputTokens ?? 0) + metadata.tokens.cachedInputTokens,
659
656
  };
660
657
 
658
+ // Approximate per-run $ cost (spec 2026-07-29): recompute from the
659
+ // cumulative token totals × the run model's catalog list price on every
660
+ // persist, so it stays correct across pause/resume. Null when the model
661
+ // has no catalog price — the UI shows "—", not a fabricated $0.
662
+ const modelPrice = await findLiteLLMModel(agent.model ?? "");
663
+ (tokens as Record<string, number | null>).costUsd = computeRunCostUsd(
664
+ tokens.inputTokens,
665
+ tokens.outputTokens,
666
+ modelPrice
667
+ ? {
668
+ input_cost_per_million_tokens: modelPrice.input_cost_per_million_tokens,
669
+ output_cost_per_million_tokens: modelPrice.output_cost_per_million_tokens,
670
+ }
671
+ : null,
672
+ );
673
+
661
674
  if (result.pausedAtStepIndex !== undefined) {
662
675
  // Pause is success (spec §5.3): persist progress and flip to
663
676
  // waiting_approval synchronously BEFORE returning — the
@@ -741,17 +754,15 @@ export const createWorkers = async (
741
754
 
742
755
  const {
743
756
  agent,
744
- provider,
745
757
  user,
746
758
  evalRun,
747
759
  testCase,
748
760
  messages: inputMessages,
749
- } = await validateEvalPayload(data, providers);
761
+ } = await validateEvalPayload(data);
750
762
 
751
763
  const retries = 3;
752
764
  let attempts = 0;
753
765
 
754
- // todo allow setting queue on agent Provider and then create a job with type "agent"
755
766
  const promise = new Promise<{
756
767
  messages: UIMessage[];
757
768
  metadata: {
@@ -768,9 +779,7 @@ export const createWorkers = async (
768
779
  while (attempts < retries) {
769
780
  try {
770
781
  const messages = await processUiMessagesFlow({
771
- providers,
772
782
  agent,
773
- provider,
774
783
  inputMessages,
775
784
  contexts,
776
785
  user,
@@ -877,7 +886,6 @@ export const createWorkers = async (
877
886
  } else {
878
887
  result = await evalMethod.run(
879
888
  agent,
880
- provider,
881
889
  testCase,
882
890
  messages,
883
891
  evalFunction.config || {},
@@ -971,10 +979,9 @@ export const createWorkers = async (
971
979
  const {
972
980
  evalRun,
973
981
  agent,
974
- provider,
975
982
  testCase,
976
983
  messages: inputMessages,
977
- } = await validateEvalPayload(data, providers);
984
+ } = await validateEvalPayload(data);
978
985
 
979
986
  const evalFunctions: {
980
987
  id: string;
@@ -995,7 +1002,6 @@ export const createWorkers = async (
995
1002
 
996
1003
  result = await evalMethod.run(
997
1004
  agent,
998
- provider,
999
1005
  testCase,
1000
1006
  inputMessages,
1001
1007
  evalFunction.config || {},
@@ -1085,24 +1091,19 @@ export const createWorkers = async (
1085
1091
  }
1086
1092
 
1087
1093
  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.`);
1094
+ console.log("[EXULU] running a routine webhook intake job.", bullmqJob.name);
1095
+ if (!data.inputs?.s3Key || !data.inputs?.triggerId) {
1096
+ throw new Error(`Missing s3Key/triggerId for email intake job.`);
1092
1097
  }
1093
-
1094
1098
  const result = await handleEmailIntake(
1095
1099
  {
1096
1100
  s3Key: data.inputs.s3Key,
1097
- recipient: data.inputs.recipient,
1101
+ triggerId: data.inputs.triggerId,
1102
+ format: data.inputs.format === "json" ? "json" : "eml",
1098
1103
  },
1099
- { config, providers },
1104
+ { config },
1100
1105
  );
1101
-
1102
- return {
1103
- result,
1104
- metadata: {},
1105
- };
1106
+ return { result, metadata: {} };
1106
1107
  }
1107
1108
 
1108
1109
  throw new Error(`Invalid job type: ${data.type} for job ${bullmqJob.name}.`);
@@ -1313,10 +1314,8 @@ export const createWorkers = async (
1313
1314
 
1314
1315
  export const validateWorkflowPayload = async (
1315
1316
  data: BullMqJobData,
1316
- providers: ExuluProvider[],
1317
1317
  ): Promise<{
1318
1318
  agent: ExuluAgent;
1319
- provider: ExuluProvider;
1320
1319
  user: User;
1321
1320
  workflow: ExuluWorkflow;
1322
1321
  variables: Record<string, any>;
@@ -1348,12 +1347,6 @@ export const validateWorkflowPayload = async (
1348
1347
  throw new Error(`Agent ${workflow.agent} not found in the database.`);
1349
1348
  }
1350
1349
 
1351
- const provider = providers.find((a) => a.id === agent.provider);
1352
-
1353
- if (!provider) {
1354
- throw new Error(`Provider ${agent.provider} not found in the database.`);
1355
- }
1356
-
1357
1350
  const user = await db.from("users").where({ id: data.user }).first();
1358
1351
 
1359
1352
  if (!user) {
@@ -1362,7 +1355,6 @@ export const validateWorkflowPayload = async (
1362
1355
 
1363
1356
  return {
1364
1357
  agent,
1365
- provider,
1366
1358
  user,
1367
1359
  workflow,
1368
1360
  variables: data.inputs,
@@ -1372,10 +1364,8 @@ export const validateWorkflowPayload = async (
1372
1364
 
1373
1365
  const validateEvalPayload = async (
1374
1366
  data: BullMqJobData,
1375
- providers: ExuluProvider[],
1376
1367
  ): Promise<{
1377
1368
  agent: ExuluAgent;
1378
- provider: ExuluProvider;
1379
1369
  user: User;
1380
1370
  testCase: TestCase;
1381
1371
  evalRun: EvalRun;
@@ -1419,12 +1409,6 @@ const validateEvalPayload = async (
1419
1409
  throw new Error(`Agent ${evalRun.agent_id} not found in the database.`);
1420
1410
  }
1421
1411
 
1422
- const provider = providers.find((a) => a.id === agent.provider);
1423
-
1424
- if (!provider) {
1425
- throw new Error(`Provider ${agent.provider} not found in the database.`);
1426
- }
1427
-
1428
1412
  const user = await db.from("users").where({ id: data.user }).first();
1429
1413
 
1430
1414
  if (!user) {
@@ -1439,7 +1423,6 @@ const validateEvalPayload = async (
1439
1423
 
1440
1424
  return {
1441
1425
  agent,
1442
- provider,
1443
1426
  user,
1444
1427
  testCase,
1445
1428
  evalRun,
@@ -1503,9 +1486,7 @@ const pollJobResult = async ({
1503
1486
  };
1504
1487
 
1505
1488
  export const processUiMessagesFlow = async ({
1506
- providers,
1507
1489
  agent,
1508
- provider,
1509
1490
  inputMessages,
1510
1491
  contexts,
1511
1492
  user,
@@ -1517,9 +1498,7 @@ export const processUiMessagesFlow = async ({
1517
1498
  resumeFromIndex,
1518
1499
  respectToolApprovals,
1519
1500
  }: {
1520
- providers: ExuluProvider[];
1521
1501
  agent: ExuluAgent;
1522
- provider: ExuluProvider;
1523
1502
  inputMessages: UIMessage[];
1524
1503
  contexts: ExuluContext[];
1525
1504
  user: User;
@@ -1579,7 +1558,6 @@ export const processUiMessagesFlow = async ({
1579
1558
  tools,
1580
1559
  contexts,
1581
1560
  disabledTools,
1582
- providers,
1583
1561
  user,
1584
1562
  );
1585
1563
 
@@ -1597,11 +1575,10 @@ export const processUiMessagesFlow = async ({
1597
1575
  const resolved = await resolveModel({
1598
1576
  modelId: agent.model,
1599
1577
  user,
1600
- providers,
1601
1578
  agent: agent,
1602
1579
  routine,
1603
1580
  });
1604
- const providerapikey = resolved.apiKey;
1581
+
1605
1582
  const resolvedLanguageModel = resolved.languageModel;
1606
1583
 
1607
1584
  // Remove placeholder agent response before sending
@@ -1698,7 +1675,7 @@ export const processUiMessagesFlow = async ({
1698
1675
  const startTime = Date.now();
1699
1676
 
1700
1677
  try {
1701
- const result = await provider.generateStream({
1678
+ const result = await generateStream({
1702
1679
  contexts,
1703
1680
  agent: agent,
1704
1681
  user,
@@ -1714,7 +1691,6 @@ export const processUiMessagesFlow = async ({
1714
1691
  currentTools: enabledTools,
1715
1692
  allExuluTools: tools,
1716
1693
  languageModel: resolvedLanguageModel,
1717
- providerapikey,
1718
1694
  toolConfigs: agent.tools,
1719
1695
  exuluConfig: config,
1720
1696
  });
@@ -1737,6 +1713,16 @@ export const processUiMessagesFlow = async ({
1737
1713
  originalMessages: result.originalMessages,
1738
1714
  sendReasoning: true,
1739
1715
  sendSources: true,
1716
+ // Give each assistant message a real unique id (matches the live
1717
+ // chat path in routes.ts). Without this the SDK assigns id "",
1718
+ // and saveChat's global message_id upsert collapses every empty-id
1719
+ // message onto one frozen-createdAt row — which sorts the tool
1720
+ // approval to the top of the transcript and leaves it out of the
1721
+ // last-message slot the approval handler acts on (buttons inert).
1722
+ generateMessageId: createIdGenerator({
1723
+ prefix: "msg_",
1724
+ size: 16,
1725
+ }),
1740
1726
  onError: (error) => {
1741
1727
  console.error("[EXULU] Ui message stream error.", error);
1742
1728
  reject(new Error(error instanceof Error ? error.message : String(error)));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "3.1.0",
4
+ "version": "3.3.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {