@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.
- package/README.md +37 -0
- package/dist/{chunk-ZDH5S2WF.js → chunk-5FTX543Z.js} +879 -489
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-FN6WZSIQ.js → convert-exulu-tools-to-ai-sdk-tools-WQWYMU7G.js} +1 -1
- package/dist/index.cjs +3958 -5159
- package/dist/index.d.cts +321 -389
- package/dist/index.d.ts +321 -389
- package/dist/index.js +1844 -3493
- package/ee/agentic-retrieval/pipeline/index.test.ts +4 -4
- package/ee/agentic-retrieval/pipeline/index.ts +6 -5
- package/ee/agentic-retrieval/pipeline/memory.test.ts +4 -2
- package/ee/agentic-retrieval/pipeline/memory.ts +20 -13
- package/ee/agentic-retrieval/pipeline/search.test.ts +19 -4
- package/ee/agentic-retrieval/pipeline/search.ts +8 -4
- package/ee/agentic-retrieval/pipeline/types.ts +1 -1
- package/ee/python/documents/processing/doc_processor.ts +0 -1
- package/ee/queues/queues.ts +13 -0
- package/ee/schemas.ts +11 -10
- package/ee/workers.ts +42 -56
- package/package.json +1 -1
- package/ee/workers.flow.test.ts +0 -236
|
@@ -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: () => ({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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 (
|
|
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 ${
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/ee/queues/queues.ts
CHANGED
|
@@ -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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1101
|
+
triggerId: data.inputs.triggerId,
|
|
1102
|
+
format: data.inputs.format === "json" ? "json" : "eml",
|
|
1098
1103
|
},
|
|
1099
|
-
{ config
|
|
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
|
-
|
|
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
|
|
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)));
|