@exulu/backend 1.55.0 → 1.56.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/index.cjs +297 -146
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +297 -146
- package/ee/agentic-retrieval/v3/classifier.ts +19 -5
- package/ee/agentic-retrieval/v3/context-sampler.ts +10 -1
- package/ee/agentic-retrieval/v3/index.ts +110 -28
- package/ee/agentic-retrieval/v3/tools.ts +13 -5
- package/ee/agentic-retrieval/v4/agent-loop.ts +208 -0
- package/ee/agentic-retrieval/v4/context-sampler.ts +79 -0
- package/ee/agentic-retrieval/v4/index.ts +690 -0
- package/ee/agentic-retrieval/v4/types.ts +58 -0
- package/package.json +1 -1
|
@@ -20,11 +20,25 @@ export async function classifyQuery(
|
|
|
20
20
|
.map((ctx) => {
|
|
21
21
|
const sample = samples.find((s) => s.contextId === ctx.id);
|
|
22
22
|
const fieldList = sample?.fields.join(", ") ?? "name, external_id";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
return `
|
|
24
|
+
<context>
|
|
25
|
+
<id>
|
|
26
|
+
${ctx.id}
|
|
27
|
+
</id>
|
|
28
|
+
<name>
|
|
29
|
+
${ctx.name}
|
|
30
|
+
</name>
|
|
31
|
+
<description>
|
|
32
|
+
${ctx.description}
|
|
33
|
+
</description>
|
|
34
|
+
<fields>
|
|
35
|
+
${fieldList}
|
|
36
|
+
</fields>
|
|
37
|
+
<example_items>
|
|
38
|
+
${sample?.exampleItems.map((item) => JSON.stringify(item)).join("\n")}
|
|
39
|
+
</example_items>
|
|
40
|
+
</context>
|
|
41
|
+
`;
|
|
28
42
|
})
|
|
29
43
|
.join("\n\n");
|
|
30
44
|
|
|
@@ -3,10 +3,19 @@ import { postgresClient } from "@SRC/postgres/client";
|
|
|
3
3
|
import { applyAccessControl } from "@SRC/graphql/utilities/access-control";
|
|
4
4
|
import { convertContextToTableDefinition } from "@SRC/graphql/utilities/convert-context-to-table-definition";
|
|
5
5
|
import type { User } from "@EXULU_TYPES/models/user";
|
|
6
|
-
import type { ContextSample } from "./types";
|
|
7
6
|
|
|
8
7
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
9
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
|
+
|
|
10
19
|
/**
|
|
11
20
|
* Pulls 1–2 example item records per context at agent initialization and caches
|
|
12
21
|
* them in memory. These samples are injected into the classifier prompt so the
|
|
@@ -6,13 +6,14 @@ import type { ExuluReranker } from "@SRC/exulu/reranker";
|
|
|
6
6
|
import { ExuluTool } from "@SRC/exulu/tool";
|
|
7
7
|
import type { User } from "@EXULU_TYPES/models/user";
|
|
8
8
|
import { checkLicense } from "@EE/entitlements";
|
|
9
|
-
import { ContextSampler } from "./context-sampler";
|
|
10
9
|
import { classifyQuery } from "./classifier";
|
|
11
10
|
import { createRetrievalTools, parseGlobalItemIds } from "./tools";
|
|
12
11
|
import { STRATEGIES } from "./strategies";
|
|
13
12
|
import { runAgentLoop } from "./agent-loop";
|
|
14
13
|
import { TrajectoryLogger } from "./trajectory";
|
|
15
14
|
import type { AgenticRetrievalOutput, QueryType } from "./types";
|
|
15
|
+
import type { ExuluItem } from "@SRC/index";
|
|
16
|
+
import { ContextSampler } from "./context-sampler";
|
|
16
17
|
|
|
17
18
|
// Module-level sampler — shared across all tool instances so the cache is warm
|
|
18
19
|
// across requests within the same process.
|
|
@@ -23,6 +24,7 @@ async function* executeV3({
|
|
|
23
24
|
contexts,
|
|
24
25
|
reranker,
|
|
25
26
|
model,
|
|
27
|
+
toolVariablesConfig,
|
|
26
28
|
user,
|
|
27
29
|
role,
|
|
28
30
|
customInstructions,
|
|
@@ -33,6 +35,7 @@ async function* executeV3({
|
|
|
33
35
|
query: string;
|
|
34
36
|
contexts: ExuluContext[];
|
|
35
37
|
reranker?: ExuluReranker;
|
|
38
|
+
toolVariablesConfig?: Record<string, any>;
|
|
36
39
|
model: LanguageModel;
|
|
37
40
|
user?: User;
|
|
38
41
|
role?: string;
|
|
@@ -72,22 +75,45 @@ async function* executeV3({
|
|
|
72
75
|
|
|
73
76
|
// ── 4. Select strategy ────────────────────────────────────────────────────
|
|
74
77
|
const strategy = STRATEGIES[classification.queryType];
|
|
75
|
-
|
|
78
|
+
const contextSpecificInstructions = activeContexts.map(ctx => {
|
|
79
|
+
const instructions = toolVariablesConfig?.[`${ctx.id}_|_instructions`] ?? "";
|
|
80
|
+
if (instructions) {
|
|
81
|
+
return `
|
|
82
|
+
<${ctx.id}>
|
|
83
|
+
${instructions}
|
|
84
|
+
</${ctx.id}>
|
|
85
|
+
`;
|
|
86
|
+
} else {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}).filter(Boolean).join("\n");
|
|
76
90
|
// Build context guidance: the classifier is a priority hint, not a hard filter.
|
|
77
91
|
// All contexts remain available so the agent can fall back if suggested ones miss.
|
|
78
92
|
const suggestedIds = classification.suggestedContextIds;
|
|
79
93
|
const fallbackIds = activeContexts
|
|
80
94
|
.filter((c) => !suggestedIds.includes(c.id))
|
|
81
95
|
.map((c) => c.id);
|
|
82
|
-
|
|
96
|
+
let contextBase =
|
|
83
97
|
suggestedIds.length > 0
|
|
84
|
-
? `
|
|
98
|
+
? `
|
|
99
|
+
Suggested priority contexts: [${suggestedIds.join(", ")}].
|
|
100
|
+
|
|
101
|
+
Also available: [${fallbackIds.join(", ")}].
|
|
102
|
+
|
|
103
|
+
Custom instructions may require searching additional or all contexts — follow them.`
|
|
85
104
|
: `All contexts available: [${activeContexts.map((c) => c.id).join(", ")}].`;
|
|
86
105
|
|
|
87
106
|
const preselectedNote = preselectedByContext?.size
|
|
88
107
|
? `\nSCOPE CONSTRAINT: Retrieval is scoped to preselected items/contexts. Per context: ${[...preselectedByContext.entries()].map(([ctx, ids]) => ids === null ? `${ctx} (full context)` : `${ctx} (${ids.length} item${ids.length === 1 ? "" : "s"})`).join(", ")}. All tools enforce this scope automatically. For full-context entries you may search freely; for item-restricted entries do NOT use search_items_by_name for discovery — go directly to search_content or save_search_results.`
|
|
89
108
|
: "";
|
|
90
109
|
|
|
110
|
+
if (contextSpecificInstructions?.length) {
|
|
111
|
+
contextBase += `
|
|
112
|
+
Context specific instructions:
|
|
113
|
+
${contextSpecificInstructions}
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
const contextGuidance = contextBase + preselectedNote;
|
|
92
118
|
|
|
93
119
|
// ── 5. Initialize tools ───────────────────────────────────────────────────
|
|
@@ -95,6 +121,7 @@ async function* executeV3({
|
|
|
95
121
|
|
|
96
122
|
const retrievalTools = createRetrievalTools({
|
|
97
123
|
contexts: activeContexts,
|
|
124
|
+
toolVariablesConfig,
|
|
98
125
|
user,
|
|
99
126
|
role,
|
|
100
127
|
updateVirtualFiles: (files) => bashToolkit.sandbox.writeFiles(files),
|
|
@@ -166,7 +193,8 @@ export function createAgenticRetrievalToolV3({
|
|
|
166
193
|
user,
|
|
167
194
|
role,
|
|
168
195
|
model,
|
|
169
|
-
|
|
196
|
+
preselected,
|
|
197
|
+
memoryItems
|
|
170
198
|
}: {
|
|
171
199
|
contexts: ExuluContext[];
|
|
172
200
|
rerankers: ExuluReranker[];
|
|
@@ -174,7 +202,8 @@ export function createAgenticRetrievalToolV3({
|
|
|
174
202
|
role?: string;
|
|
175
203
|
model?: LanguageModel;
|
|
176
204
|
instructions?: string;
|
|
177
|
-
|
|
205
|
+
preselected?: string[];
|
|
206
|
+
memoryItems?: ExuluItem[];
|
|
178
207
|
}): ExuluTool | undefined {
|
|
179
208
|
const license = checkLicense();
|
|
180
209
|
if (!license["agentic-retrieval"]) {
|
|
@@ -229,20 +258,51 @@ export function createAgenticRetrievalToolV3({
|
|
|
229
258
|
default: false,
|
|
230
259
|
},
|
|
231
260
|
{
|
|
232
|
-
name: "
|
|
261
|
+
name: "logging",
|
|
233
262
|
description: "Save a detailed markdown + JSON log of every retrieval execution to disk. Useful for debugging and evaluation.",
|
|
234
263
|
type: "boolean",
|
|
235
264
|
default: false,
|
|
236
265
|
},
|
|
237
266
|
...contexts.map((ctx) => ({
|
|
238
|
-
name: ctx.id,
|
|
267
|
+
name: ctx.id + "_|_enabled",
|
|
239
268
|
description: `Enable search in "${ctx.name}". ${ctx.description}`,
|
|
240
269
|
type: "boolean" as const,
|
|
241
270
|
default: true,
|
|
271
|
+
}
|
|
272
|
+
)),
|
|
273
|
+
...contexts.map((ctx) => ({
|
|
274
|
+
name: `${ctx.id}_|_instructions`,
|
|
275
|
+
description: `Instructions for the retrieval agent about how to search in the ${ctx.name} context`,
|
|
276
|
+
type: "string" as const,
|
|
277
|
+
default: "",
|
|
242
278
|
})),
|
|
279
|
+
...contexts.map((ctx) => ({
|
|
280
|
+
name: `${ctx.id}_|_priority`,
|
|
281
|
+
description: `Defines in which order the context should be searched in, the higher the number the higher the priority, if contexts have the same priority they are searched in parallel`,
|
|
282
|
+
type: "number" as const,
|
|
283
|
+
default: 0,
|
|
284
|
+
})),
|
|
285
|
+
...contexts.map((ctx) => ({
|
|
286
|
+
name: `${ctx.id}_|_max_results`,
|
|
287
|
+
description: `Defines the maximum number of results to return for the ${ctx.name} context`,
|
|
288
|
+
type: "number" as const,
|
|
289
|
+
default: 0,
|
|
290
|
+
})),
|
|
291
|
+
...contexts.map((ctx) => ({
|
|
292
|
+
name: `${ctx.id}_|_max_steps`,
|
|
293
|
+
description: `Defines the maximum number of steps the agent is allowed to take when searching the ${ctx.name} context`,
|
|
294
|
+
type: "number" as const,
|
|
295
|
+
default: 0,
|
|
296
|
+
})),
|
|
297
|
+
...contexts.map((ctx) => ({
|
|
298
|
+
name: `${ctx.id}_|_expand_chunks`,
|
|
299
|
+
description: `Defines if the agent automatically retrieves nearby chunks around the matched chunks, usefull if relevant content might be split up`,
|
|
300
|
+
type: "number" as const,
|
|
301
|
+
default: 0,
|
|
302
|
+
}))
|
|
243
303
|
],
|
|
244
304
|
inputSchema: z.object({
|
|
245
|
-
|
|
305
|
+
userQuery: z.string().describe("The original unaltered question from the user"),
|
|
246
306
|
userInstructions: z
|
|
247
307
|
.string()
|
|
248
308
|
.optional()
|
|
@@ -256,24 +316,24 @@ export function createAgenticRetrievalToolV3({
|
|
|
256
316
|
)
|
|
257
317
|
}),
|
|
258
318
|
execute: async function* ({
|
|
259
|
-
|
|
319
|
+
userQuery,
|
|
260
320
|
userInstructions,
|
|
261
321
|
confirmedContextIds,
|
|
262
322
|
toolVariablesConfig,
|
|
263
323
|
sessionID,
|
|
264
324
|
}: {
|
|
265
|
-
|
|
325
|
+
userQuery: string;
|
|
266
326
|
userInstructions?: string;
|
|
267
327
|
confirmedContextIds?: string[];
|
|
268
328
|
toolVariablesConfig?: Record<string, any>;
|
|
269
329
|
sessionID?: string;
|
|
270
330
|
}) {
|
|
271
|
-
|
|
331
|
+
|
|
272
332
|
/* ROADMAP:
|
|
273
333
|
const app = exuluApp.get();
|
|
274
334
|
let reasoningModel: LanguageModel | undefined = model;
|
|
275
335
|
let searchModel: LanguageModel | undefined = model;
|
|
276
|
-
|
|
336
|
+
|
|
277
337
|
|
|
278
338
|
if (toolVariablesConfig?.reasoning_model) {
|
|
279
339
|
reasoningModel = app.provider(toolVariablesConfig.reasoning_model)?.model?.create({});
|
|
@@ -281,7 +341,7 @@ export function createAgenticRetrievalToolV3({
|
|
|
281
341
|
throw new Error("Reasoning model not found");
|
|
282
342
|
}
|
|
283
343
|
}
|
|
284
|
-
|
|
344
|
+
|
|
285
345
|
if (toolVariablesConfig?.search_model) {
|
|
286
346
|
searchModel = app.provider(toolVariablesConfig.search_model);
|
|
287
347
|
if (!searchModel) {
|
|
@@ -304,38 +364,38 @@ export function createAgenticRetrievalToolV3({
|
|
|
304
364
|
if (toolVariablesConfig) {
|
|
305
365
|
configInstructions = toolVariablesConfig["instructions"] ?? "";
|
|
306
366
|
logTrajectory =
|
|
307
|
-
toolVariablesConfig["
|
|
308
|
-
toolVariablesConfig["
|
|
367
|
+
toolVariablesConfig["logging"] === true ||
|
|
368
|
+
toolVariablesConfig["logging"] === "true";
|
|
309
369
|
|
|
310
370
|
managedContextEnabled = toolVariablesConfig["managed_context"] === true || toolVariablesConfig["managed_context"] === "true";
|
|
311
371
|
|
|
312
372
|
activeContexts = contexts.filter(
|
|
313
373
|
(ctx) =>
|
|
314
|
-
toolVariablesConfig[ctx.id] === true ||
|
|
315
|
-
toolVariablesConfig[ctx.id] === "true" ||
|
|
316
|
-
toolVariablesConfig[ctx.id] === 1,
|
|
374
|
+
toolVariablesConfig[ctx.id + "_|_enabled"] === true ||
|
|
375
|
+
toolVariablesConfig[ctx.id + "_|_enabled"] === "true" ||
|
|
376
|
+
toolVariablesConfig[ctx.id + "_|_enabled"] === 1,
|
|
317
377
|
);
|
|
318
378
|
if (activeContexts.length === 0) activeContexts = contexts;
|
|
319
379
|
|
|
320
380
|
requiresPreselectedContexts = toolVariablesConfig["require_preselected_contexts"] === true || toolVariablesConfig["require_preselected_contexts"] === "true";
|
|
321
381
|
|
|
322
382
|
const rerankerId = toolVariablesConfig["reranker"];
|
|
323
|
-
|
|
383
|
+
|
|
324
384
|
if (rerankerId && rerankerId !== "none") {
|
|
325
385
|
configuredReranker = rerankers.find((r) => r.id === rerankerId);
|
|
326
386
|
}
|
|
327
387
|
}
|
|
328
388
|
|
|
329
389
|
console.log("[EXULU] Managed context enabled:", managedContextEnabled);
|
|
330
|
-
console.log("[EXULU] Preselected item IDs:",
|
|
390
|
+
console.log("[EXULU] Preselected item IDs:", preselected);
|
|
331
391
|
|
|
332
|
-
if (managedContextEnabled && !
|
|
392
|
+
if (managedContextEnabled && !preselected?.length) {
|
|
333
393
|
console.log("[EXULU] Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool.");
|
|
334
394
|
yield { result: "Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool." };
|
|
335
395
|
return;
|
|
336
396
|
}
|
|
337
397
|
|
|
338
|
-
if (requiresPreselectedContexts && !confirmedContextIds?.length && !
|
|
398
|
+
if (requiresPreselectedContexts && !confirmedContextIds?.length && !preselected?.length) {
|
|
339
399
|
console.log("[EXULU] The user must choose between the available contexts before executing the tool. The available contexts are: " + activeContexts.map((c) => c.id).join(", ") + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text.");
|
|
340
400
|
yield { result: "The user must choose between the available contexts before executing the tool, the available contexts are: " + activeContexts.map((c) => c.id).join(", ") + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text." };
|
|
341
401
|
return;
|
|
@@ -348,24 +408,46 @@ export function createAgenticRetrievalToolV3({
|
|
|
348
408
|
}
|
|
349
409
|
|
|
350
410
|
const combinedInstructions = [
|
|
351
|
-
configInstructions ? `
|
|
352
|
-
|
|
353
|
-
|
|
411
|
+
configInstructions ? `
|
|
412
|
+
Configuration instructions:
|
|
413
|
+
<configuration_instructions>
|
|
414
|
+
${configInstructions}
|
|
415
|
+
</configuration_instructions>
|
|
416
|
+
` : "",
|
|
417
|
+
adminInstructions ? `
|
|
418
|
+
Admin instructions:
|
|
419
|
+
<admin_instructions>
|
|
420
|
+
${adminInstructions}
|
|
421
|
+
</admin_instructions>
|
|
422
|
+
` : "",
|
|
423
|
+
userInstructions ? `
|
|
424
|
+
User instructions:
|
|
425
|
+
<user_instructions>
|
|
426
|
+
${userInstructions}
|
|
427
|
+
</user_instructions>
|
|
428
|
+
` : "",
|
|
429
|
+
memoryItems ? `
|
|
430
|
+
Relevant memories (these are items that the agent has retrieved from the memory context and are relevant to the query):
|
|
431
|
+
<relevant_memories>
|
|
432
|
+
${memoryItems?.map(item => JSON.stringify(item)).join("\n")}
|
|
433
|
+
</relevant_memories>
|
|
434
|
+
` : "",
|
|
354
435
|
]
|
|
355
436
|
.filter(Boolean)
|
|
356
437
|
.join("\n");
|
|
357
438
|
|
|
358
439
|
for await (const output of executeV3({
|
|
359
|
-
query,
|
|
440
|
+
query: userQuery,
|
|
360
441
|
contexts: activeContexts,
|
|
361
442
|
reranker: configuredReranker,
|
|
443
|
+
toolVariablesConfig,
|
|
362
444
|
model,
|
|
363
445
|
user,
|
|
364
446
|
role,
|
|
365
447
|
customInstructions: combinedInstructions || undefined,
|
|
366
448
|
logTrajectory,
|
|
367
449
|
sessionId: sessionID,
|
|
368
|
-
preselectedItemIds,
|
|
450
|
+
preselectedItemIds: preselected,
|
|
369
451
|
})) {
|
|
370
452
|
yield { result: JSON.stringify(output) };
|
|
371
453
|
}
|
|
@@ -77,6 +77,7 @@ export function parseGlobalItemIds(globalIds: string[]): Map<string, string[] |
|
|
|
77
77
|
|
|
78
78
|
export type RetrievalToolParams = {
|
|
79
79
|
contexts: ExuluContext[];
|
|
80
|
+
toolVariablesConfig?: Record<string, any>;
|
|
80
81
|
user?: User;
|
|
81
82
|
role?: string;
|
|
82
83
|
updateVirtualFiles: (files: Array<{ path: string; content: string }>) => Promise<void>;
|
|
@@ -94,7 +95,7 @@ export type RetrievalToolParams = {
|
|
|
94
95
|
* and filtered per strategy.
|
|
95
96
|
*/
|
|
96
97
|
export function createRetrievalTools(params: RetrievalToolParams) {
|
|
97
|
-
const { contexts, user, role, updateVirtualFiles, preselectedItemsByContext } = params;
|
|
98
|
+
const { contexts, toolVariablesConfig, user, role, updateVirtualFiles, preselectedItemsByContext } = params;
|
|
98
99
|
const ctxEnum = buildContextEnum(contexts);
|
|
99
100
|
|
|
100
101
|
// ──────────────────────────────────────────────────────────
|
|
@@ -278,7 +279,7 @@ Use includeContent: true when you need the ACTUAL text to answer a question.
|
|
|
278
279
|
|
|
279
280
|
For listing queries: always start with includeContent: false, then use dynamic tools to fetch specific pages.`,
|
|
280
281
|
inputSchema: z.object({
|
|
281
|
-
|
|
282
|
+
userQuery: z.string().describe("The original unaltered question from the user"),
|
|
282
283
|
knowledge_base_id: z
|
|
283
284
|
.enum(contexts.map((c) => c.id) as [string, ...string[]])
|
|
284
285
|
.describe(
|
|
@@ -318,7 +319,7 @@ For listing queries: always start with includeContent: false, then use dynamic t
|
|
|
318
319
|
.describe("Max chunks with content (max 20). Without content, up to 200 are returned."),
|
|
319
320
|
}),
|
|
320
321
|
execute: async ({
|
|
321
|
-
|
|
322
|
+
userQuery,
|
|
322
323
|
knowledge_base_id,
|
|
323
324
|
keywords,
|
|
324
325
|
searchMethod,
|
|
@@ -329,7 +330,8 @@ For listing queries: always start with includeContent: false, then use dynamic t
|
|
|
329
330
|
limit,
|
|
330
331
|
}) => {
|
|
331
332
|
const [ctx] = resolveContexts([knowledge_base_id], contexts) as [ExuluContext];
|
|
332
|
-
const
|
|
333
|
+
const maxResults = toolVariablesConfig?.[`${ctx.id}_|_max_results`] || 20;
|
|
334
|
+
const effectiveLimit = includeContent ? Math.min(limit ?? maxResults, maxResults) : Math.min((limit ?? maxResults) * maxResults, 400);
|
|
333
335
|
|
|
334
336
|
const itemFilters: SearchFilters = [];
|
|
335
337
|
|
|
@@ -361,7 +363,7 @@ For listing queries: always start with includeContent: false, then use dynamic t
|
|
|
361
363
|
itemFilters.push({ name: { or: item_names.map((n) => ({ contains: n })) } });
|
|
362
364
|
if (item_external_ids) itemFilters.push({ external_id: { in: item_external_ids } });
|
|
363
365
|
|
|
364
|
-
const effectiveQuery =
|
|
366
|
+
const effectiveQuery = userQuery || keywords?.join(" ") || "";
|
|
365
367
|
|
|
366
368
|
let method = mapSearchMethod(searchMethod ?? "hybrid");
|
|
367
369
|
|
|
@@ -372,6 +374,8 @@ For listing queries: always start with includeContent: false, then use dynamic t
|
|
|
372
374
|
}
|
|
373
375
|
}
|
|
374
376
|
|
|
377
|
+
const expandChunks = toolVariablesConfig?.[`${ctx.id}_|_expand_chunks`] || 0;
|
|
378
|
+
|
|
375
379
|
try {
|
|
376
380
|
const { chunks } = await ctx.search({
|
|
377
381
|
query: effectiveQuery,
|
|
@@ -385,6 +389,10 @@ For listing queries: always start with includeContent: false, then use dynamic t
|
|
|
385
389
|
user,
|
|
386
390
|
role,
|
|
387
391
|
trigger: "tool",
|
|
392
|
+
expand: expandChunks > 0 ? {
|
|
393
|
+
before: expandChunks,
|
|
394
|
+
after: expandChunks,
|
|
395
|
+
} : undefined,
|
|
388
396
|
});
|
|
389
397
|
|
|
390
398
|
return JSON.stringify(
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
}
|