@exulu/backend 1.67.0 → 1.69.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-ZPZKOT6I.js → chunk-IVC2M56U.js} +1562 -163
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-4B7BQ5G2.js → convert-exulu-tools-to-ai-sdk-tools-ET6UI7YG.js} +1 -1
- package/dist/index.cjs +24847 -22818
- package/dist/index.d.cts +315 -101
- package/dist/index.d.ts +315 -101
- package/dist/index.js +2766 -2232
- package/ee/agentic-retrieval/v3/agent-loop.ts +4 -4
- package/ee/agentic-retrieval/v3/index.ts +20 -6
- package/ee/python/documents/processing/doc_processor.ts +136 -40
- package/ee/python/documents/processing/split_pdf.py +97 -0
- package/ee/queues/queues.ts +10 -0
- package/ee/queues/redis-startup.ts +121 -0
- package/ee/workers.ts +9 -17
- package/package.json +1 -1
- package/ee/agentic-retrieval/v4/agent-loop.ts +0 -208
- package/ee/agentic-retrieval/v4/context-sampler.ts +0 -79
- package/ee/agentic-retrieval/v4/index.ts +0 -690
- package/ee/agentic-retrieval/v4/types.ts +0 -58
package/ee/workers.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import IORedis from "ioredis";
|
|
2
2
|
import { redisServer } from "@EE/queues/server.ts";
|
|
3
|
+
import { guardRedisStartup, logRedisErrors } from "@EE/queues/redis-startup.ts";
|
|
3
4
|
import { Job, Worker, type JobState } from "bullmq";
|
|
4
5
|
import { bullmq } from "@SRC/validators/bullmq.ts";
|
|
5
6
|
import { getEnabledTools } from "@SRC/utils/enabled-tools.ts";
|
|
@@ -7,7 +8,6 @@ import { ExuluStorage } from "@SRC/exulu/storage.ts";
|
|
|
7
8
|
import type { ExuluAgent } from "@EXULU_TYPES/models/agent.ts";
|
|
8
9
|
import type { ExuluQueueConfig } from "@EXULU_TYPES/queue-config.ts";
|
|
9
10
|
import { getTableName, type ExuluContext } from "@SRC/exulu/context.ts";
|
|
10
|
-
import type { ExuluReranker } from "@SRC/exulu/reranker.ts";
|
|
11
11
|
import type { ExuluEval } from "@SRC/exulu/evals.ts";
|
|
12
12
|
import type { ExuluTool } from "@SRC/exulu/tool.ts";
|
|
13
13
|
import { resolveModel } from "@SRC/exulu/resolve-model.ts";
|
|
@@ -115,7 +115,6 @@ export const createWorkers = async (
|
|
|
115
115
|
queues: ExuluQueueConfig[],
|
|
116
116
|
config: ExuluConfig,
|
|
117
117
|
contexts: ExuluContext[],
|
|
118
|
-
rerankers: ExuluReranker[],
|
|
119
118
|
evals: ExuluEval[],
|
|
120
119
|
tools: ExuluTool[],
|
|
121
120
|
tracer?: Tracer,
|
|
@@ -160,6 +159,11 @@ export const createWorkers = async (
|
|
|
160
159
|
},
|
|
161
160
|
maxRetriesPerRequest: null,
|
|
162
161
|
});
|
|
162
|
+
// Surface connection errors and FAIL FAST instead of hanging silently when Redis is down:
|
|
163
|
+
// confirm the connection is actually reachable (bounded by REDIS_STARTUP_TIMEOUT_MS) before
|
|
164
|
+
// handing it to the workers, which would otherwise block on their first operation forever.
|
|
165
|
+
logRedisErrors(redisConnection, "worker");
|
|
166
|
+
await guardRedisStartup("workers", () => redisConnection.ping().then(() => undefined), redisConnection);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
const workers = queues.map((queue) => {
|
|
@@ -284,14 +288,8 @@ export const createWorkers = async (
|
|
|
284
288
|
throw new Error(`Context ${data.context} not found in the registry.`);
|
|
285
289
|
}
|
|
286
290
|
|
|
287
|
-
if (!
|
|
288
|
-
throw new Error(`No embedder
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const embedder = contexts.find((context) => context.embedder?.id === data.embedder);
|
|
292
|
-
|
|
293
|
-
if (!embedder) {
|
|
294
|
-
throw new Error(`Embedder ${data.embedder} not found in the registry.`);
|
|
291
|
+
if (!context.embedder) {
|
|
292
|
+
throw new Error(`No embedder configured for context ${data.context}.`);
|
|
295
293
|
}
|
|
296
294
|
|
|
297
295
|
const result = await context.createAndUpsertEmbeddings(
|
|
@@ -299,7 +297,7 @@ export const createWorkers = async (
|
|
|
299
297
|
config,
|
|
300
298
|
data.user,
|
|
301
299
|
{
|
|
302
|
-
label: embedder.
|
|
300
|
+
label: context.embedder.model,
|
|
303
301
|
trigger: data.trigger,
|
|
304
302
|
},
|
|
305
303
|
data.role,
|
|
@@ -520,7 +518,6 @@ export const createWorkers = async (
|
|
|
520
518
|
provider,
|
|
521
519
|
inputMessages,
|
|
522
520
|
contexts,
|
|
523
|
-
rerankers,
|
|
524
521
|
user,
|
|
525
522
|
tools,
|
|
526
523
|
config,
|
|
@@ -623,7 +620,6 @@ export const createWorkers = async (
|
|
|
623
620
|
provider,
|
|
624
621
|
inputMessages,
|
|
625
622
|
contexts,
|
|
626
|
-
rerankers,
|
|
627
623
|
user,
|
|
628
624
|
tools,
|
|
629
625
|
config,
|
|
@@ -1324,7 +1320,6 @@ export const processUiMessagesFlow = async ({
|
|
|
1324
1320
|
provider,
|
|
1325
1321
|
inputMessages,
|
|
1326
1322
|
contexts,
|
|
1327
|
-
rerankers,
|
|
1328
1323
|
user,
|
|
1329
1324
|
tools,
|
|
1330
1325
|
config,
|
|
@@ -1336,7 +1331,6 @@ export const processUiMessagesFlow = async ({
|
|
|
1336
1331
|
provider: ExuluProvider;
|
|
1337
1332
|
inputMessages: UIMessage[];
|
|
1338
1333
|
contexts: ExuluContext[];
|
|
1339
|
-
rerankers: ExuluReranker[];
|
|
1340
1334
|
user: User;
|
|
1341
1335
|
tools: ExuluTool[];
|
|
1342
1336
|
config: ExuluConfig;
|
|
@@ -1376,7 +1370,6 @@ export const processUiMessagesFlow = async ({
|
|
|
1376
1370
|
agent,
|
|
1377
1371
|
tools,
|
|
1378
1372
|
contexts,
|
|
1379
|
-
rerankers,
|
|
1380
1373
|
disabledTools,
|
|
1381
1374
|
providers,
|
|
1382
1375
|
user,
|
|
@@ -1495,7 +1488,6 @@ export const processUiMessagesFlow = async ({
|
|
|
1495
1488
|
try {
|
|
1496
1489
|
const result = await provider.generateStream({
|
|
1497
1490
|
contexts,
|
|
1498
|
-
rerankers,
|
|
1499
1491
|
agent: agent,
|
|
1500
1492
|
user,
|
|
1501
1493
|
approvedTools: tools.map((tool) => "tool-" + sanitizeToolName(tool.name)),
|
package/package.json
CHANGED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { generateText, stepCountIs, tool } from "ai";
|
|
2
|
-
import type { LanguageModel, Tool as AITool, ModelMessage } from "ai";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { withRetry } from "@SRC/utils/with-retry";
|
|
5
|
-
import type { ExuluReranker } from "@SRC/exulu/reranker";
|
|
6
|
-
import type { AgenticRetrievalOutput, ChunkResult } from "./types";
|
|
7
|
-
import { DEFAULT_MAX_STEPS, type AgenticRetrievalLog, type ContextRetrievalConfig } from ".";
|
|
8
|
-
|
|
9
|
-
const FINISH_TOOL_NAME = "finish_retrieval";
|
|
10
|
-
|
|
11
|
-
const finishRetrievalTool = tool({
|
|
12
|
-
description:
|
|
13
|
-
"Call this tool when you have retrieved sufficient information and no further searches are needed. " +
|
|
14
|
-
"You MUST call this tool to signal that retrieval is complete — do not write a text conclusion.",
|
|
15
|
-
inputSchema: z.object({
|
|
16
|
-
reasoning: z.string().describe("One sentence explaining why retrieval is complete"),
|
|
17
|
-
}),
|
|
18
|
-
execute: async ({ reasoning }) => JSON.stringify({ finished: true, reasoning }),
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
function extractChunksFromToolResults(toolResults: any[]): ChunkResult[] {
|
|
22
|
-
const chunks: ChunkResult[] = [];
|
|
23
|
-
for (const result of toolResults ?? []) {
|
|
24
|
-
// AI SDK v6 uses `output` (not `result`) for tool result values
|
|
25
|
-
const rawOutput = result.output ?? result.result;
|
|
26
|
-
let parsed: any;
|
|
27
|
-
try {
|
|
28
|
-
parsed = typeof rawOutput === "string" ? JSON.parse(rawOutput) : rawOutput;
|
|
29
|
-
} catch {
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (Array.isArray(parsed)) {
|
|
34
|
-
for (const item of parsed) {
|
|
35
|
-
if (item?.item_id && item?.context) {
|
|
36
|
-
chunks.push({
|
|
37
|
-
item_name: item.item_name,
|
|
38
|
-
item_id: item.item_id,
|
|
39
|
-
context: item.context?.id ?? item.context,
|
|
40
|
-
chunk_id: item.chunk_id,
|
|
41
|
-
chunk_index: item.chunk_index,
|
|
42
|
-
chunk_content: item.chunk_content,
|
|
43
|
-
metadata: item.metadata,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return chunks;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Core agent loop: one generateText call per step.
|
|
54
|
-
*
|
|
55
|
-
* Unlike v2 (which split each step into a reasoning call + a separate tool
|
|
56
|
-
* execution call), here a single call with toolChoice: "auto" lets the model
|
|
57
|
-
* reason and call tools in one pass. The model sees tool results from the
|
|
58
|
-
* previous step via the conversation history (messages array).
|
|
59
|
-
*
|
|
60
|
-
* The loop stops when:
|
|
61
|
-
* - The model makes no tool calls (it's satisfied), OR
|
|
62
|
-
* - The strategy's stepBudget is exhausted
|
|
63
|
-
*/
|
|
64
|
-
export async function* runAgentLoop(params: {
|
|
65
|
-
config: ContextRetrievalConfig;
|
|
66
|
-
userQuery: string;
|
|
67
|
-
log: AgenticRetrievalLog;
|
|
68
|
-
todos: {
|
|
69
|
-
status: "planned" | "completed";
|
|
70
|
-
description: string;
|
|
71
|
-
current: boolean;
|
|
72
|
-
}[];
|
|
73
|
-
tools: Record<string, AITool>;
|
|
74
|
-
model: LanguageModel;
|
|
75
|
-
reranker?: ExuluReranker;
|
|
76
|
-
sessionID?: string;
|
|
77
|
-
onStepComplete?: (step: AgenticRetrievalOutput["steps"][0]) => void;
|
|
78
|
-
}): AsyncGenerator<AgenticRetrievalOutput> {
|
|
79
|
-
const { userQuery, tools, model, reranker, sessionID, onStepComplete, config, log, todos } = params;
|
|
80
|
-
|
|
81
|
-
const output: AgenticRetrievalOutput = {
|
|
82
|
-
steps: [],
|
|
83
|
-
reasoning: [],
|
|
84
|
-
chunks: [],
|
|
85
|
-
usage: [],
|
|
86
|
-
totalTokens: 0,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const messages: ModelMessage[] = [{ role: "user", content: userQuery }];
|
|
90
|
-
|
|
91
|
-
const stepBudget = config.maxSteps || DEFAULT_MAX_STEPS
|
|
92
|
-
|
|
93
|
-
const SYSTEM_PROMPT = `
|
|
94
|
-
You are a helpful assistant that can search the knowledge base and retrieve information.
|
|
95
|
-
|
|
96
|
-
You are searching for information that is relevant to the following question:
|
|
97
|
-
<user_query>
|
|
98
|
-
${userQuery}
|
|
99
|
-
</user_query>
|
|
100
|
-
|
|
101
|
-
You have the following instructions for this knowledge base:
|
|
102
|
-
<instructions>
|
|
103
|
-
${config.instructions}
|
|
104
|
-
</instructions>
|
|
105
|
-
|
|
106
|
-
A first search strategy was drafted as a todo list:
|
|
107
|
-
<todo_list>
|
|
108
|
-
${todos.map((todo, index) => `${index + 1}. ${todo.status} - ${todo.description}`).join("\n")}
|
|
109
|
-
</todo_list>
|
|
110
|
-
|
|
111
|
-
`;
|
|
112
|
-
|
|
113
|
-
for (let step = 0; step < stepBudget; step++) {
|
|
114
|
-
|
|
115
|
-
log.entries.push({
|
|
116
|
-
label: "Agent loop step",
|
|
117
|
-
timestamp: new Date().toISOString(),
|
|
118
|
-
message: `[EXULU] v3 agent loop — step ${step + 1}/${stepBudget}`,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
let result: Awaited<ReturnType<typeof generateText>>;
|
|
122
|
-
|
|
123
|
-
const stepTools = { ...tools, [FINISH_TOOL_NAME]: finishRetrievalTool };
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
result = await withRetry(() =>
|
|
127
|
-
generateText({
|
|
128
|
-
model,
|
|
129
|
-
temperature: 0,
|
|
130
|
-
system: SYSTEM_PROMPT,
|
|
131
|
-
messages,
|
|
132
|
-
tools: stepTools,
|
|
133
|
-
toolChoice: "required",
|
|
134
|
-
stopWhen: stepCountIs(1),
|
|
135
|
-
}),
|
|
136
|
-
);
|
|
137
|
-
} catch (err) {
|
|
138
|
-
console.error("[EXULU] v3 generateText failed:", err);
|
|
139
|
-
throw err;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Carry conversation forward: assistant message + tool results go into history
|
|
143
|
-
// so the model sees them on the next iteration.
|
|
144
|
-
messages.push(...(result.response.messages as ModelMessage[]));
|
|
145
|
-
|
|
146
|
-
// Extract chunks from tool results
|
|
147
|
-
let stepChunks: any[] = extractChunksFromToolResults(result.toolResults as any[]);
|
|
148
|
-
|
|
149
|
-
// Deduplicate by chunk_id within this step (parallel tool calls can return the same chunk
|
|
150
|
-
// if the agent searches the same context twice, or the same chunk is indexed in two contexts).
|
|
151
|
-
const seenChunkIds = new Set<string>();
|
|
152
|
-
stepChunks = stepChunks.filter((c) => {
|
|
153
|
-
if (!c.chunk_id) return true;
|
|
154
|
-
if (seenChunkIds.has(c.chunk_id)) return false;
|
|
155
|
-
seenChunkIds.add(c.chunk_id);
|
|
156
|
-
return true;
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Record step
|
|
160
|
-
const stepRecord = {
|
|
161
|
-
stepNumber: step + 1,
|
|
162
|
-
text: result.text ?? "",
|
|
163
|
-
toolCalls: (result.toolCalls as any[])?.map((tc) => ({
|
|
164
|
-
name: tc.toolName,
|
|
165
|
-
id: tc.toolCallId,
|
|
166
|
-
input: tc.input,
|
|
167
|
-
})) ?? [],
|
|
168
|
-
chunks: stepChunks,
|
|
169
|
-
tokens: result.usage?.totalTokens ?? 0,
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
log.entries.push({
|
|
173
|
-
label: "Step completed",
|
|
174
|
-
timestamp: new Date().toISOString(),
|
|
175
|
-
message: JSON.stringify(stepRecord),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
output.steps.push(stepRecord);
|
|
179
|
-
output.reasoning.push({
|
|
180
|
-
text: result.text ?? "",
|
|
181
|
-
tools: (result.toolCalls as any[])?.map((tc) => ({
|
|
182
|
-
name: tc.toolName,
|
|
183
|
-
id: tc.toolCallId,
|
|
184
|
-
input: tc.input,
|
|
185
|
-
output: stepChunks,
|
|
186
|
-
})) ?? [],
|
|
187
|
-
});
|
|
188
|
-
// Deduplicate against chunks already accumulated from prior steps
|
|
189
|
-
const existingChunkIds = new Set(output.chunks.map((c) => c.chunk_id).filter(Boolean));
|
|
190
|
-
output.chunks.push(...stepChunks.filter((c) => !c.chunk_id || !existingChunkIds.has(c.chunk_id)));
|
|
191
|
-
output.usage.push(result.usage);
|
|
192
|
-
|
|
193
|
-
onStepComplete?.(stepRecord);
|
|
194
|
-
|
|
195
|
-
yield { ...output };
|
|
196
|
-
|
|
197
|
-
// Stop if the model called finish_retrieval AND no forced continuation is needed
|
|
198
|
-
const calledFinish = (result.toolCalls as any[])?.some(
|
|
199
|
-
(tc) => tc.toolName === FINISH_TOOL_NAME,
|
|
200
|
-
);
|
|
201
|
-
if (calledFinish) {
|
|
202
|
-
console.log(`[EXULU] v3 model called finish_retrieval after step ${step + 1}`);
|
|
203
|
-
break;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
output.totalTokens = output.usage.reduce((sum, u) => sum + (u?.totalTokens ?? 0), 0);
|
|
208
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { ExuluContext, getTableName } from "@SRC/exulu/context";
|
|
2
|
-
import { postgresClient } from "@SRC/postgres/client";
|
|
3
|
-
import { applyAccessControl } from "@SRC/graphql/utilities/access-control";
|
|
4
|
-
import { convertContextToTableDefinition } from "@SRC/graphql/utilities/convert-context-to-table-definition";
|
|
5
|
-
import type { User } from "@EXULU_TYPES/models/user";
|
|
6
|
-
|
|
7
|
-
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
8
|
-
|
|
9
|
-
export interface ContextSample {
|
|
10
|
-
contextId: string;
|
|
11
|
-
contextName: string;
|
|
12
|
-
/** All field names available on items (standard + custom) */
|
|
13
|
-
fields: string[];
|
|
14
|
-
/** Up to 2 example item records */
|
|
15
|
-
exampleItems: Array<Record<string, any>>;
|
|
16
|
-
sampledAt: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Pulls 1–2 example item records per context at agent initialization and caches
|
|
21
|
-
* them in memory. These samples are injected into the classifier prompt so the
|
|
22
|
-
* model understands what data is actually stored (not just field names).
|
|
23
|
-
*/
|
|
24
|
-
export class ContextSampler {
|
|
25
|
-
private cache = new Map<string, ContextSample>();
|
|
26
|
-
|
|
27
|
-
async getSamples(
|
|
28
|
-
contexts: ExuluContext[],
|
|
29
|
-
user?: User,
|
|
30
|
-
role?: string,
|
|
31
|
-
): Promise<ContextSample[]> {
|
|
32
|
-
return Promise.all(contexts.map((ctx) => this.getSample(ctx, user, role)));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
private async getSample(
|
|
36
|
-
ctx: ExuluContext,
|
|
37
|
-
user?: User,
|
|
38
|
-
role?: string,
|
|
39
|
-
): Promise<ContextSample> {
|
|
40
|
-
const cached = this.cache.get(ctx.id);
|
|
41
|
-
if (cached && Date.now() - cached.sampledAt < CACHE_TTL_MS) {
|
|
42
|
-
return cached;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const { db } = await postgresClient();
|
|
46
|
-
const tableName = getTableName(ctx.id);
|
|
47
|
-
const tableDefinition = convertContextToTableDefinition(ctx);
|
|
48
|
-
|
|
49
|
-
const customFieldNames = ctx.fields.map((f) => f.name);
|
|
50
|
-
const selectFields = ["id", "name", "external_id", ...customFieldNames];
|
|
51
|
-
|
|
52
|
-
let exampleItems: Record<string, any>[] = [];
|
|
53
|
-
try {
|
|
54
|
-
let query = db(tableName).select(selectFields).whereNull("archived").limit(2);
|
|
55
|
-
query = applyAccessControl(tableDefinition, query, user, tableName);
|
|
56
|
-
exampleItems = await query;
|
|
57
|
-
} catch {
|
|
58
|
-
// If table doesn't exist yet or column mismatch, return empty samples
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const sample: ContextSample = {
|
|
62
|
-
contextId: ctx.id,
|
|
63
|
-
contextName: ctx.name,
|
|
64
|
-
fields: ["name", "external_id", ...customFieldNames],
|
|
65
|
-
exampleItems,
|
|
66
|
-
sampledAt: Date.now(),
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
this.cache.set(ctx.id, sample);
|
|
70
|
-
|
|
71
|
-
// Refresh in background after TTL without blocking the caller
|
|
72
|
-
return sample;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Evict a context from cache so it's re-sampled on next use */
|
|
76
|
-
invalidate(contextId: string): void {
|
|
77
|
-
this.cache.delete(contextId);
|
|
78
|
-
}
|
|
79
|
-
}
|