@exulu/backend 1.55.0 → 1.57.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.
@@ -0,0 +1,690 @@
1
+
2
+ import { z } from "zod";
3
+ import { getTableName, type ExuluContext } from "@SRC/exulu/context";
4
+ import type { ExuluReranker } from "@SRC/exulu/reranker";
5
+ import { ExuluTool } from "@SRC/exulu/tool";
6
+ import type { User } from "@EXULU_TYPES/models/user";
7
+ import type { LanguageModel } from "ai";
8
+ import { checkLicense } from "@EE/entitlements";
9
+ import { ContextSampler, type ContextSample } from "./context-sampler";
10
+ import { generateText, stepCountIs, tool, Output } from "ai";
11
+ import type { ExuluItem } from "@SRC/index";
12
+ import { withRetry } from "@SRC/utils/with-retry";
13
+ import { runAgentLoop } from "./agent-loop";
14
+ import type { AgenticRetrievalOutput } from "./types";
15
+ import { postgresClient } from "@SRC/postgres/client";
16
+ import type { SearchFilters } from "@SRC/graphql/types";
17
+ import path from "path";
18
+ import fs from "fs";
19
+
20
+ export interface ChunkResult {
21
+ item_name: string;
22
+ item_id: string;
23
+ context?: {
24
+ name: string;
25
+ id: string;
26
+ };
27
+ chunk_id?: string;
28
+ chunk_index?: number;
29
+ chunk_content?: string;
30
+ metadata?: Record<string, any>;
31
+ }
32
+
33
+ export type ContextRetrievalConfig = {
34
+ context: ExuluContext;
35
+ maxResults: number;
36
+ maxSteps: number;
37
+ expandChunks: number;
38
+ items: { id: string; name: string; description: string; }[] | null;
39
+ instructions: string | null;
40
+ priority: number;
41
+ }
42
+
43
+ const DEFAULT_MAX_RESULTS = 10;
44
+ export const DEFAULT_MAX_STEPS = 5;
45
+
46
+ export type AgenticRetrievalLog = {
47
+ start_tsp: string;
48
+ end_tsp?: string;
49
+ session: string;
50
+ userQuery: string;
51
+ entries: {
52
+ label: string;
53
+ timestamp: string;
54
+ message: string;
55
+ }[]
56
+ }
57
+
58
+ // Module-level sampler — shared across all tool instances so the cache is warm
59
+ // across requests within the same process.
60
+ const sampler = new ContextSampler();
61
+ const itemsCache = new Map<string, {
62
+ item: ExuluItem;
63
+ expiresAt: number;
64
+ }>();
65
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
66
+
67
+ const getItem = async (contextId: string, itemId: string): Promise<ExuluItem | undefined> => {
68
+ const gid = contextId + "/" + itemId;
69
+
70
+ // Check cache first
71
+ const cached = itemsCache.get(gid);
72
+ if (cached && Date.now() < cached.expiresAt) {
73
+ return cached.item;
74
+ }
75
+
76
+ // Fetch from database if not cached or expired
77
+ const { db } = await postgresClient();
78
+ const tableName = getTableName(contextId);
79
+ const item = await db.from(tableName).where({ id: itemId }).first() as ExuluItem;
80
+
81
+ // Store in cache with expiry
82
+ if (item) {
83
+ itemsCache.set(gid, {
84
+ item,
85
+ expiresAt: Date.now() + CACHE_TTL_MS
86
+ });
87
+ }
88
+
89
+ return item;
90
+ }
91
+
92
+ export async function parsePreselectedItems(preselected: string[]): Promise<Map<string, {
93
+ id: string;
94
+ name: string;
95
+ description: string;
96
+ }[] | null>> {
97
+ // Returns a map of context ids to item ids, if
98
+ // the entire context is selected the context
99
+ // id is mapped to null or [].
100
+ const map = new Map<string, { id: string; name: string; description: string; }[] | null>();
101
+ for (const gid of preselected) {
102
+ const slashIdx = gid.lastIndexOf("/");
103
+ if (slashIdx === -1) {
104
+ // No slash → entire context selected
105
+ if (gid) map.set(gid, null);
106
+ continue;
107
+ }
108
+ const contextId = gid.slice(0, slashIdx);
109
+ const itemId = gid.slice(slashIdx + 1);
110
+ if (!contextId || !itemId) continue;
111
+ // Full-context entry already wins — don't downgrade to specific items
112
+ if (map.get(contextId) === null) continue;
113
+ const existing: { id: string; name: string; description: string; }[] = map.get(contextId) ?? [];
114
+ const item = await getItem(contextId, itemId);
115
+ existing.push({
116
+ id: itemId,
117
+ name: item?.name ?? "",
118
+ description: item?.description ?? ""
119
+ });
120
+ map.set(contextId, existing);
121
+ }
122
+ return map;
123
+ }
124
+
125
+ /**
126
+ * Creates the v4 ExuluTool for agentic context retrieval.
127
+ */
128
+ export function createAgenticRetrievalToolV4({
129
+ contexts,
130
+ rerankers,
131
+ user,
132
+ role,
133
+ model,
134
+ preselected, // can be either entire contexts (<context_id>) or specific items (<context_id>/<item_id>)
135
+ memoryItems // items retrieved from the agent's memory with a high relevance to the query passed to the agentic retrieval tool
136
+ }: {
137
+ contexts: ExuluContext[];
138
+ rerankers: ExuluReranker[];
139
+ user?: User;
140
+ role?: string;
141
+ model?: LanguageModel;
142
+ preselected?: string[];
143
+ memoryItems?: ExuluItem[]
144
+ }): ExuluTool | undefined {
145
+
146
+ const license = checkLicense();
147
+
148
+ if (!license["agentic-retrieval"]) {
149
+ console.warn("[EXULU] Not licensed for agentic retrieval");
150
+ return undefined;
151
+ }
152
+
153
+ const contextNames = contexts.map((c) => c.id).join(", ");
154
+
155
+ return new ExuluTool({
156
+ id: "agentic_context_search",
157
+ name: "Agentic Context Search",
158
+ description: `Intelligent context search with query classification, strategy-based retrieval, and virtual filesystem filtering. Searches: ${contextNames}`,
159
+ category: "contexts",
160
+ needsApproval: false,
161
+ type: "context",
162
+ config: [
163
+ {
164
+ name: "reranker",
165
+ description: "Reranker to use for result ranking",
166
+ type: "string",
167
+ default: "none",
168
+ },
169
+ {
170
+ name: "managed_context",
171
+ description: "Forces the user to explicitly define which items from which contexts the agentic retrieval tool will search in",
172
+ type: "boolean",
173
+ default: false,
174
+ },
175
+ {
176
+ name: "logging",
177
+ description: "Save a detailed markdown + JSON log of every retrieval execution to disk. Useful for debugging and evaluation.",
178
+ type: "boolean",
179
+ default: false,
180
+ },
181
+ ...contexts.map((ctx) => ({
182
+ name: ctx.id + "_|_enabled",
183
+ description: `Enable search in "${ctx.name}". ${ctx.description}`,
184
+ type: "boolean" as const,
185
+ default: true,
186
+ }
187
+ )),
188
+ ...contexts.map((ctx) => ({
189
+ name: `${ctx.id}_|_instructions`,
190
+ description: `Instructions for the retrieval agent about how to search in the ${ctx.name} context`,
191
+ type: "string" as const,
192
+ default: "",
193
+ })),
194
+ ...contexts.map((ctx) => ({
195
+ name: `${ctx.id}_|_priority`,
196
+ 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`,
197
+ type: "number" as const,
198
+ default: 0,
199
+ })),
200
+ ...contexts.map((ctx) => ({
201
+ name: `${ctx.id}_|_max_results`,
202
+ description: `Defines the maximum number of results to return for the ${ctx.name} context`,
203
+ type: "number" as const,
204
+ default: 0,
205
+ })),
206
+ ...contexts.map((ctx) => ({
207
+ name: `${ctx.id}_|_max_steps`,
208
+ description: `Defines the maximum number of steps the agent is allowed to take when searching the ${ctx.name} context`,
209
+ type: "number" as const,
210
+ default: 0,
211
+ })),
212
+ ...contexts.map((ctx) => ({
213
+ name: `${ctx.id}_|_expand_chunks`,
214
+ description: `Defines if the agent automatically retrieves nearby chunks around the matched chunks, usefull if relevant content might be split up`,
215
+ type: "number" as const,
216
+ default: 0,
217
+ }))
218
+ ],
219
+ inputSchema: z.object({
220
+ userQuery: z.string().describe("The original unaltered question from the user"),
221
+ userInstructions: z
222
+ .string()
223
+ .optional()
224
+ .describe("Additional instructions from the user to guide retrieval"),
225
+ }),
226
+ execute: async function* ({
227
+ userQuery,
228
+ userInstructions,
229
+ toolVariablesConfig,
230
+ sessionID,
231
+ }: {
232
+ userQuery: string;
233
+ userInstructions?: string;
234
+ toolVariablesConfig?: Record<string, any>;
235
+ sessionID?: string;
236
+ }) {
237
+
238
+ let log: AgenticRetrievalLog = {
239
+ start_tsp: new Date().toISOString(),
240
+ session: sessionID ?? "",
241
+ userQuery: userQuery,
242
+ entries: [],
243
+ }
244
+
245
+ if (!model) {
246
+ yield { result: "Model is required for executing the agentic retrieval tool" };
247
+ return;
248
+ }
249
+
250
+ if (!toolVariablesConfig) {
251
+ yield { result: "No tool variables config found, please check if the tool is enabled and configured correctly." };
252
+ return;
253
+ }
254
+
255
+ let reranker: ExuluReranker | undefined = toolVariablesConfig["reranker"] ? rerankers.find((r) => r.id === toolVariablesConfig["reranker"]) : undefined;
256
+ let logging: boolean = checkTrue(toolVariablesConfig["logging"]);
257
+ let managed: boolean = checkTrue(toolVariablesConfig["managed_context"]);
258
+
259
+ log.entries.push({
260
+ label: "Tool variables config",
261
+ timestamp: new Date().toISOString(),
262
+ message: JSON.stringify(toolVariablesConfig),
263
+ });
264
+
265
+ let active: Map<string, {
266
+ items: { id: string; name: string; description: string; }[] | null;
267
+ context: ExuluContext;
268
+ instructions: string | null;
269
+ maxResults: number;
270
+ maxSteps: number;
271
+ expandChunks: number;
272
+ priority: number;
273
+ } | null> = new Map(contexts.filter(
274
+ (ctx) => checkTrue(toolVariablesConfig[ctx.id + "_|_enabled"])
275
+ ).map(ctx => [ctx.id, {
276
+ items: null,
277
+ context: ctx,
278
+ maxResults: toolVariablesConfig[ctx.id + "_|_max_results"] ?? DEFAULT_MAX_RESULTS,
279
+ maxSteps: toolVariablesConfig[ctx.id + "_|_max_steps"] ?? DEFAULT_MAX_STEPS,
280
+ expandChunks: toolVariablesConfig[ctx.id + "_|_expand_chunks"] ?? 0,
281
+ instructions: toolVariablesConfig[ctx.id + "_|_instructions"] ?? "",
282
+ priority: toolVariablesConfig[ctx.id + "_|_priority"] ?? 0,
283
+ }]));
284
+
285
+ log.entries.push({
286
+ label: "Active contexts",
287
+ timestamp: new Date().toISOString(),
288
+ message: JSON.stringify(active),
289
+ });
290
+
291
+ if (!active.size) {
292
+ console.log("[EXULU] No contexts are enabled for the agentic retrieval tool, let the user know that the admin needs to enable at least one context before the agentic retrieval tool can be used.");
293
+ console.log("[EXULU] Log: ", log);
294
+ yield { result: "No contexts are enabled for the agentic retrieval tool, let the user know that the admin needs to enable at least one context before the agentic retrieval tool can be used." };
295
+ return;
296
+ };
297
+
298
+ if (
299
+ managed &&
300
+ !preselected?.length
301
+ ) {
302
+ 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.");
303
+ console.log("[EXULU] Log: ", log);
304
+ 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." };
305
+ return;
306
+ }
307
+
308
+ if (preselected?.length) {
309
+
310
+ const preselectedContextMap = await parsePreselectedItems(preselected);
311
+ active = new Map(contexts.filter(ctx => preselectedContextMap.has(ctx.id)).map(ctx => [ctx.id, {
312
+ items: preselectedContextMap.get(ctx.id) ?? null,
313
+ context: ctx,
314
+ maxResults: toolVariablesConfig[ctx.id + "_|_max_results"] ?? DEFAULT_MAX_RESULTS,
315
+ maxSteps: toolVariablesConfig[ctx.id + "_|_max_steps"] ?? DEFAULT_MAX_STEPS,
316
+ expandChunks: toolVariablesConfig[ctx.id + "_|_expand_chunks"] ?? 0,
317
+ instructions: toolVariablesConfig[ctx.id + "_|_instructions"] ?? "",
318
+ priority: toolVariablesConfig[ctx.id + "_|_priority"] ?? 0,
319
+ }]));
320
+
321
+ log.entries.push({
322
+ label: "Preselected contexts",
323
+ timestamp: new Date().toISOString(),
324
+ message: JSON.stringify(active),
325
+ });
326
+ }
327
+
328
+ for await (const output of execute({
329
+ userQuery,
330
+ log,
331
+ activeContexts: active,
332
+ reranker,
333
+ model,
334
+ user,
335
+ role,
336
+ sessionID: sessionID,
337
+ memoryItems: memoryItems,
338
+ })) {
339
+ yield { result: JSON.stringify(output) };
340
+ }
341
+
342
+ if (logging) {
343
+ log.end_tsp = new Date().toISOString();
344
+ log.entries.push({
345
+ label: "Log completed",
346
+ timestamp: new Date().toISOString(),
347
+ message: "Log completed, writing to file",
348
+ });
349
+ const logDir = path.join(process.cwd(), "logs", "agentic-retrieval");
350
+ const filePath = path.join(logDir, `${log.session}-${log.start_tsp}.json`);
351
+ // Ensure directory exists before writing
352
+ fs.mkdirSync(logDir, { recursive: true });
353
+ fs.writeFileSync(filePath, JSON.stringify(log, null, 2));
354
+ console.log(`[EXULU] Log written to ${filePath}`);
355
+ }
356
+ return;
357
+ },
358
+ });
359
+ }
360
+
361
+ const checkTrue = (value: any): boolean => {
362
+ return value === true || value === "true" || value === 1 || value === "1";
363
+ }
364
+
365
+ async function* execute({
366
+ userQuery,
367
+ activeContexts,
368
+ sessionID,
369
+ reranker,
370
+ model,
371
+ user,
372
+ role,
373
+ log,
374
+ memoryItems,
375
+ }: {
376
+ log: AgenticRetrievalLog;
377
+ userQuery: string;
378
+ sessionID?: string;
379
+ activeContexts: Map<string, {
380
+ context: ExuluContext;
381
+ items: { id: string; name: string; description: string; }[] | null;
382
+ maxResults: number;
383
+ maxSteps: number;
384
+ expandChunks: number;
385
+ instructions: string | null;
386
+ priority: number;
387
+ } | null>;
388
+ reranker?: ExuluReranker;
389
+ model: LanguageModel;
390
+ user?: User;
391
+ role?: string;
392
+ memoryItems?: ExuluItem[];
393
+ }): AsyncGenerator<AgenticRetrievalOutput> {
394
+
395
+ // ── Sample example records from each context (cached) ──────────────────
396
+ console.log("[EXULU] v3 — sampling contexts");
397
+
398
+ const samples: ContextSample[] = await sampler.getSamples(Array.from(
399
+ activeContexts.values()).map(
400
+ data => data?.context
401
+ ).filter(
402
+ ctx => ctx !== null
403
+ ) as ExuluContext[], user, role);
404
+
405
+ // -- Add sample records to each context's instructions --
406
+ for (const [contextId, data] of activeContexts.entries()) {
407
+ if (data?.context) {
408
+ const sample = samples.find(sample => sample.contextId === contextId);
409
+ log.entries.push({
410
+ label: "Context sample for " + contextId,
411
+ timestamp: new Date().toISOString(),
412
+ message: JSON.stringify(sample),
413
+ });
414
+ if (sample) {
415
+ data.instructions = `
416
+ Custom instructions for this knowledge base:
417
+ ${data.instructions ?? ""}
418
+
419
+ Available item fields for this knowledge base:
420
+ <item_fields>
421
+ ${sample.fields.join(", ")}
422
+ </item_fields>
423
+
424
+ Example records for this knowledge base:
425
+ <item_records>
426
+ ${sample.exampleItems.map(item => JSON.stringify(item)).join("\n")}
427
+ </item_records>
428
+
429
+ Relevant memories for this knowledge base (these are things the agent has learned from past conversations with the user):
430
+ <relevant_memories>
431
+ ${memoryItems?.map(item => JSON.stringify(item)).join("\n")}
432
+ </relevant_memories>
433
+ }
434
+ `; // todo find a way to add glossary and general company information to the instructions
435
+ }
436
+ log.entries.push({
437
+ label: "Updated context instructions for " + contextId,
438
+ timestamp: new Date().toISOString(),
439
+ message: data.instructions ?? "",
440
+ });
441
+ }
442
+ }
443
+
444
+ const prioritized = new Map<number, ContextRetrievalConfig[]>();
445
+
446
+ for (const [_contextId, data] of activeContexts.entries()) {
447
+ // Map the payload to a priority in the map, i.e. priority 1 could be
448
+ // 2 context payloads if they have the same priority, the higher the priority
449
+ // the higher the priority in the map
450
+ if (!data) continue;
451
+ const priority = data.priority || 0;
452
+ if (prioritized.has(priority)) {
453
+ prioritized.get(priority)?.push(data);
454
+ } else {
455
+ prioritized.set(priority, [data]);
456
+ }
457
+ }
458
+
459
+ for (const [_priority, contexts] of prioritized.entries()) {
460
+
461
+ // Process contexts sequentially to allow yielding results as they come
462
+ for (const data of contexts) {
463
+ const search = tool({
464
+ description: `
465
+ Search '${data.context.name}' knowledge base for relevant information based on the user's question.`,
466
+ inputSchema: z.object({
467
+ resultType: z.enum(["list", "count", "content"]).describe(`
468
+ The type of results to return:
469
+ - list: return a list of items with basic data such as their name and id
470
+ - count: return the number of items
471
+ - content: return the content of the items, this is the default if no result type is specified
472
+ `).default("content"),
473
+ userQuery: z.string().describe("The original unaltered question from the user."),
474
+ limit: z
475
+ .number()
476
+ .default(DEFAULT_MAX_RESULTS)
477
+ .describe(`The maximum number of results to return, can be a maximum of ${data.maxResults || DEFAULT_MAX_RESULTS}.`),
478
+ }),
479
+ execute: async ({
480
+ resultType,
481
+ userQuery,
482
+ limit,
483
+ }) => {
484
+
485
+ let effectiveLimit = Math.min(limit ?? data.maxResults, data.maxResults);
486
+ const ctx = data.context;
487
+ const includeContent = resultType === "content";
488
+
489
+ try {
490
+ let itemFilters: SearchFilters = [];
491
+
492
+ if (data.items) {
493
+ itemFilters.push({ id: { in: data.items.map(item => item.id) } });
494
+ }
495
+
496
+ const { chunks } = await ctx.search({
497
+ query: userQuery,
498
+ keywords: [],
499
+ method: "hybridSearch",
500
+ limit: effectiveLimit,
501
+ page: 1,
502
+ itemFilters: itemFilters,
503
+ chunkFilters: [],
504
+ sort: { field: "updatedAt", direction: "desc" },
505
+ user,
506
+ role,
507
+ trigger: "tool",
508
+ expand: data.expandChunks ? {
509
+ before: data.expandChunks,
510
+ after: data.expandChunks,
511
+ } : undefined,
512
+ });
513
+
514
+ let stepChunks =
515
+ chunks.map(
516
+ (chunk): ChunkResult => ({
517
+ item_name: chunk.item_name,
518
+ item_id: chunk.item_id,
519
+ context: {
520
+ name: chunk.context?.name ?? "",
521
+ id: chunk.context?.id ?? ctx.id
522
+ },
523
+ chunk_id: chunk.chunk_id,
524
+ chunk_index: chunk.chunk_index,
525
+ chunk_content: includeContent ? chunk.chunk_content : undefined,
526
+ metadata: {
527
+ ...chunk.chunk_metadata,
528
+ cosine_distance: chunk.chunk_cosine_distance,
529
+ fts_rank: chunk.chunk_fts_rank,
530
+ hybrid_score: chunk.chunk_hybrid_score,
531
+ },
532
+ }),
533
+ )
534
+
535
+ return JSON.stringify(stepChunks);
536
+ } catch (err) {
537
+ console.error(`[EXULU] search_content failed for context "${ctx.id}":`, err);
538
+ return JSON.stringify([]);
539
+ }
540
+ },
541
+ });
542
+
543
+ const system_prompt = `
544
+ You are an information retrieval assistant. Your job is to retrieve all relevant information from
545
+ the '${data.context.name}' knowledge base and return it. You do NOT answer the user's question yourself —
546
+ another agent will do that based on what you retrieve.
547
+ `;
548
+
549
+ const user_prompt = `
550
+ The user has asked the following question:
551
+ <user_query>
552
+ ${userQuery}
553
+ </user_query>
554
+
555
+ ${data.items ? `
556
+ The user has also preselected the following items to search in, these will be automatically included as a prefilter
557
+ in the search tool.
558
+ <preselected_items>
559
+ ${data.items?.map(item => `${item.id} - ${item.name} ${item.description ? `- ${item.description}` : ""}`).join("\n")}
560
+ </preselected_items>
561
+ ` : ""}
562
+
563
+ You have the following instructions for this context to help you understand what this knowledge base
564
+ and how to search it, these are very important, and should be followed strictly:
565
+ <instructions>
566
+ ${data.instructions}
567
+ </instructions>
568
+
569
+ Please come up with a plan / todo list of steps to retrieve the information
570
+ the user is looking for.
571
+
572
+ The plan / todo list should be a JSON array of objects with the following fields:
573
+
574
+ - status: "planned" | "completed"
575
+ - description: string
576
+
577
+ The description should be a short description of the step you need to take to retrieve the information
578
+ the user is looking for.
579
+
580
+ You have one tool available called "search" that you can use to search the knowledge base. It takes
581
+ the following parameters:
582
+
583
+ - resultType: "list" | "count" | "content"
584
+ - userQuery: string
585
+ - limit: number (currently set to a maximum of ${data.maxResults || DEFAULT_MAX_RESULTS})
586
+
587
+ The resultType determines the type of results you want to retrieve:
588
+ - list: return a list of items with basic data such as their name and id
589
+ - count: return the number of items
590
+ - content: return the content of the items, this is the default if no result type is specified
591
+
592
+ The final todo MUST be to call the finish_retrieval tool to signal that retrieval is complete.
593
+ `;
594
+
595
+ log.entries.push({
596
+ label: "Created plan prompt for context " + data.context.id,
597
+ timestamp: new Date().toISOString(),
598
+ message: `
599
+ ${system_prompt}
600
+ ${user_prompt}
601
+ `,
602
+ });
603
+
604
+ const { output } = await withRetry(() =>
605
+ generateText({
606
+ model,
607
+ temperature: 0,
608
+ system: system_prompt,
609
+ messages: [{ role: "user", content: user_prompt }],
610
+ output: Output.object({
611
+ schema: z.object({
612
+ todos: z.array(z.object({
613
+ status: z.enum(["planned", "completed"]),
614
+ description: z.string(),
615
+ tool: z.string(),
616
+ })),
617
+ }),
618
+ }),
619
+ stopWhen: stepCountIs(3),
620
+ }),
621
+ );
622
+
623
+ // todo add tokens to total
624
+
625
+ log.entries.push({
626
+ label: "Created plan for context " + data.context.id,
627
+ timestamp: new Date().toISOString(),
628
+ message: JSON.stringify(output.todos),
629
+ });
630
+
631
+ // 2. Loop the agent, execute a step, then evaluate the todo list, and move on to the next
632
+ // step until the todo list is completed or the max number of steps is reached.
633
+
634
+ let finalOutput: AgenticRetrievalOutput | undefined;
635
+ let executionError: Error | undefined;
636
+
637
+ try {
638
+ for await (const result of runAgentLoop({
639
+ userQuery,
640
+ sessionID,
641
+ log,
642
+ todos: output.todos.map((todo, index) => ({
643
+ status: todo.status,
644
+ description: todo.description,
645
+ current: index === 0,
646
+ })),
647
+ config: data,
648
+ tools: {
649
+ search: search,
650
+ },
651
+ model,
652
+ reranker,
653
+ onStepComplete: (step) => log.entries.push({
654
+ label: "Step completed",
655
+ timestamp: new Date().toISOString(),
656
+ message: JSON.stringify(step),
657
+ })
658
+ })) {
659
+ finalOutput = result;
660
+ yield result;
661
+ }
662
+
663
+ } catch (err) {
664
+ executionError = err as Error;
665
+ console.error("[EXULU] v3 — agent loop error:", err);
666
+ throw err;
667
+ } finally {
668
+ if (finalOutput) {
669
+ log.entries.push({
670
+ label: "Final output",
671
+ timestamp: new Date().toISOString(),
672
+ message: JSON.stringify(finalOutput),
673
+ });
674
+ }
675
+ if (executionError) {
676
+ log.entries.push({
677
+ label: "Execution error",
678
+ timestamp: new Date().toISOString(),
679
+ message: JSON.stringify(executionError),
680
+ });
681
+ }
682
+ }
683
+ }
684
+ }
685
+ }
686
+
687
+
688
+ // todos
689
+ // within the agentic loops use a global plimit to manage
690
+ // concurrent context search llm call rate limiting.