@exulu/backend 1.54.0 → 1.55.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.
@@ -1,1628 +0,0 @@
1
- import { z } from "zod";
2
- import {
3
- stepCountIs,
4
- tool,
5
- type LanguageModel,
6
- type Tool as AITool,
7
- Output,
8
- generateText,
9
- } from "ai";
10
- import { createBashTool } from "bash-tool";
11
- import { type ExuluContext } from "@SRC/exulu/context";
12
- import type { ExuluReranker } from "@SRC/exulu/reranker";
13
- import { ExuluTool } from "@SRC/exulu/tool";
14
- import { sanitizeToolName } from "@SRC/utils/sanitize-tool-name.ts";
15
- import type { User } from "@EXULU_TYPES/models/user";
16
- import { postgresClient } from "@SRC/postgres/client";
17
- import { zodToJsonSchema } from "zod-to-json-schema";
18
- import { preprocessQuery } from "@SRC/utils/query-preprocessing";
19
- import { getChunksTableName, getTableName } from "@SRC/exulu/context";
20
- import type { SearchFilters } from "@SRC/graphql/types";
21
- import type { VectorSearchChunkResult } from "@SRC/graphql/resolvers/vector-search";
22
- import { convertContextToTableDefinition } from "@SRC/graphql/utilities/convert-context-to-table-definition";
23
- import { applyFilters } from "@SRC/graphql/resolvers/apply-filters";
24
- import { applyAccessControl } from "@SRC/graphql/utilities/access-control";
25
- import { withRetry } from "@SRC/utils/with-retry";
26
- import { checkLicense } from "@EE/entitlements";
27
- import * as fs from "fs/promises";
28
- import * as path from "path";
29
-
30
- /**
31
- * Agentic Retrieval Tool V2
32
- *
33
- * Enhanced version with:
34
- * - Virtual bash environment for iterative filtering (bash-tool)
35
- * - COUNT and aggregation queries
36
- * - save_search_results for token-efficient large result handling
37
- *
38
- * The agent can:
39
- * - Search and save results to virtual filesystem for grep-based filtering
40
- * - Count items/chunks with advanced filters
41
- * - Iteratively refine results without loading into context
42
- */
43
-
44
- interface ToolResult {
45
- item_name: string;
46
- item_id: string;
47
- context: string;
48
- chunk_id?: string;
49
- chunk_index?: number;
50
- chunk_content?: string;
51
- metadata?: any;
52
- }
53
-
54
- interface AgenticRetrievalOutput {
55
- reasoning: {
56
- text: string;
57
- tools: {
58
- name: string;
59
- id: string;
60
- input: any;
61
- output: VectorSearchChunkResult[];
62
- }[];
63
- chunks: any[];
64
- }[];
65
- chunks: any[];
66
- usage: any[];
67
- totalTokens: number;
68
- }
69
-
70
- /**
71
- * Retrieval Trajectory - Full log of the retrieval process for analysis
72
- */
73
- interface RetrievalTrajectory {
74
- timestamp: string;
75
- initial_query: string;
76
- detected_language: string;
77
- available_contexts: string[];
78
- enabled_contexts: string[];
79
- reranker_used?: string;
80
- custom_instructions?: string;
81
-
82
- steps: {
83
- step_number: number;
84
- timestamp: string;
85
-
86
- // Reasoning phase
87
- reasoning: {
88
- text: string;
89
- finished: boolean;
90
- tokens_used: number;
91
- duration_ms: number;
92
- };
93
-
94
- // Tool execution phase
95
- tool_execution?: {
96
- tools_called: {
97
- tool_name: string;
98
- tool_id: string;
99
- input: any;
100
- output_summary: string; // Truncated output for readability
101
- output_length: number; // Full output length in characters
102
- success: boolean;
103
- error?: string;
104
- duration_ms: number;
105
- }[];
106
- chunks_retrieved: number;
107
- chunks_after_reranking?: number;
108
- total_tokens_used: number;
109
- };
110
-
111
- // Dynamic tools created in this step
112
- dynamic_tools_created: string[];
113
- }[];
114
-
115
- // Final results
116
- final_results: {
117
- total_chunks: number;
118
- total_steps: number;
119
- total_tokens: number;
120
- total_duration_ms: number;
121
- success: boolean;
122
- error?: string;
123
- };
124
-
125
- // Performance metrics
126
- performance: {
127
- tokens_per_step: number[];
128
- avg_tokens_per_step: number;
129
- chunks_per_step: number[];
130
- tool_usage_frequency: Record<string, number>;
131
- };
132
- }
133
-
134
- /**
135
- * Trajectory Logger - Manages logging of retrieval trajectories
136
- */
137
- class TrajectoryLogger {
138
- private trajectory: RetrievalTrajectory;
139
- private startTime: number;
140
- private logDir: string;
141
-
142
- constructor(query: string, language: string, contexts: string[], config: any) {
143
- this.startTime = Date.now();
144
- this.trajectory = {
145
- timestamp: new Date().toISOString(),
146
- initial_query: query,
147
- detected_language: language,
148
- available_contexts: contexts,
149
- enabled_contexts: config.enabledContexts || contexts,
150
- reranker_used: config.reranker,
151
- custom_instructions: config.instructions,
152
- steps: [],
153
- final_results: {
154
- total_chunks: 0,
155
- total_steps: 0,
156
- total_tokens: 0,
157
- total_duration_ms: 0,
158
- success: false,
159
- },
160
- performance: {
161
- tokens_per_step: [],
162
- avg_tokens_per_step: 0,
163
- chunks_per_step: [],
164
- tool_usage_frequency: {},
165
- },
166
- };
167
-
168
- // Log directory: ee/agentic-retrieval/logs/YYYY-MM-DD/
169
- this.logDir = path.join('./');
170
- }
171
-
172
- logStep(
173
- stepNumber: number,
174
- reasoning: { text: string; finished: boolean; tokens: number; durationMs: number },
175
- toolExecution?: {
176
- toolCalls: any[];
177
- toolResults: any[];
178
- chunks: any[];
179
- chunksAfterReranking?: number;
180
- tokens: number;
181
- },
182
- dynamicToolsCreated: string[] = []
183
- ) {
184
- const stepLog: RetrievalTrajectory['steps'][0] = {
185
- step_number: stepNumber,
186
- timestamp: new Date().toISOString(),
187
- reasoning: {
188
- text: reasoning.text,
189
- finished: reasoning.finished,
190
- tokens_used: reasoning.tokens,
191
- duration_ms: reasoning.durationMs,
192
- },
193
- dynamic_tools_created: dynamicToolsCreated,
194
- };
195
-
196
- if (toolExecution) {
197
- const toolsExecuted = toolExecution.toolCalls.map((call, idx) => {
198
- const result = toolExecution.toolResults[idx];
199
- let outputStr = '';
200
- try {
201
- outputStr = typeof result?.output === 'string'
202
- ? result.output
203
- : JSON.stringify(result?.output || {});
204
- } catch (e) {
205
- outputStr = '[Error serializing output]';
206
- }
207
-
208
- return {
209
- tool_name: call.toolName,
210
- tool_id: call.toolCallId,
211
- input: call.input,
212
- output_summary: outputStr.substring(0, 500) + (outputStr.length > 500 ? '...' : ''),
213
- output_length: outputStr.length,
214
- success: !result?.error,
215
- error: result?.error,
216
- duration_ms: 0, // Would need to track individually
217
- };
218
- });
219
-
220
- stepLog.tool_execution = {
221
- tools_called: toolsExecuted,
222
- chunks_retrieved: toolExecution.chunks.length,
223
- chunks_after_reranking: toolExecution.chunksAfterReranking,
224
- total_tokens_used: toolExecution.tokens,
225
- };
226
-
227
- // Update tool usage frequency
228
- toolsExecuted.forEach(tool => {
229
- this.trajectory.performance.tool_usage_frequency[tool.tool_name] =
230
- (this.trajectory.performance.tool_usage_frequency[tool.tool_name] || 0) + 1;
231
- });
232
-
233
- this.trajectory.performance.chunks_per_step.push(toolExecution.chunks.length);
234
- }
235
-
236
- this.trajectory.performance.tokens_per_step.push(reasoning.tokens + (toolExecution?.tokens || 0));
237
- this.trajectory.steps.push(stepLog);
238
- }
239
-
240
- async finalize(output: AgenticRetrievalOutput, success: boolean, error?: Error) {
241
- const duration = Date.now() - this.startTime;
242
-
243
- this.trajectory.final_results = {
244
- total_chunks: output.chunks.length,
245
- total_steps: this.trajectory.steps.length,
246
- total_tokens: output.totalTokens,
247
- total_duration_ms: duration,
248
- success: success,
249
- error: error?.message,
250
- };
251
-
252
- this.trajectory.performance.avg_tokens_per_step =
253
- this.trajectory.performance.tokens_per_step.reduce((a, b) => a + b, 0) /
254
- Math.max(this.trajectory.performance.tokens_per_step.length, 1);
255
-
256
- // Write to file
257
- await this.writeToFile();
258
-
259
- console.log(`[EXULU] V2 - Trajectory logged: trajectory.json`);
260
- console.log(`[EXULU] V2 - Log file: ${this.getLogFilePath()}`);
261
- }
262
-
263
- private getLogFilePath(): string {
264
- return "trajectory.json";
265
- }
266
-
267
- private async writeToFile() {
268
- try {
269
- // Ensure log directory exists
270
- await fs.mkdir(this.logDir, { recursive: true });
271
-
272
- const logFilePath = this.getLogFilePath();
273
-
274
- // Write trajectory as pretty JSON
275
- await fs.writeFile(
276
- logFilePath,
277
- JSON.stringify(this.trajectory, null, 2),
278
- 'utf-8'
279
- );
280
-
281
- } catch (error) {
282
- console.error('[EXULU] V2 - Failed to write trajectory log:', error);
283
- // Don't throw - logging failure shouldn't break retrieval
284
- }
285
- }
286
- }
287
-
288
- const baseInstructions = `
289
- You are an intelligent information retrieval assistant with access to multiple knowledge bases. You MUST do all your reasoning and
290
- outputs in the same language as the user query.
291
-
292
- Your goal is to efficiently retrieve the most relevant information to answer user queries. You don't answer the question yourself, you only
293
- retrieve the information and return it, another tool will answer the question based on the information you retrieve.
294
-
295
- CRITICAL: STRUCTURED REASONING PROCESS
296
- You MUST follow this structured thinking process for EVERY step. Keep outputs VERY SHORT and in the same language as the user query - ideally one line each:
297
-
298
- 1. BEFORE EACH TOOL CALL - Output your search strategy in ONE CONCISE LINE:
299
- Format: 🔍 [What you'll search]: [Tool(s)] [Method/Filters] → [Expected outcome]
300
-
301
- 2. AFTER RECEIVING RESULTS - Reflect in ONE CONCISE LINE:
302
- Format: 💭 [Count] results | [Relevance] | [Next action]
303
-
304
- IMPORTANT:
305
- - ONE LINE per reasoning block - be extremely concise
306
- - Use the same language as the user query
307
- - Focus only on: what, tool/method, outcome
308
-
309
- NEW CAPABILITIES IN V2:
310
-
311
- FOR COUNTING QUERIES (e.g., "how many documents...", "count items that...", "total number of..."):
312
- Use the count_items_or_chunks tool. You can count:
313
- - Total items in a context
314
- - Total chunks in a context
315
- - Items matching specific criteria (name contains, created after date, etc.)
316
- - Chunks containing specific content
317
-
318
- FOR LARGE RESULT SETS (e.g., "find all documents about X", "show me everything related to Y"):
319
- When you expect many results (>20) and need to filter iteratively:
320
- 1. Use save_search_results tool to save results to virtual filesystem (doesn't load into context)
321
- 2. Tool returns success message confirming results were saved to /search_results.txt
322
- 3. Use bash tools (grep, awk, head, tail) to filter and explore results
323
- 4. Only load specific chunks after identifying them via grep
324
- This saves tokens and allows iterative refinement.
325
-
326
- Example workflow for large results:
327
- - save_search_results → "Saved 100 results to /search_results.txt"
328
- - bash: "grep -i 'safety procedures' search_results.txt | head -20" → Shows matching lines
329
- - bash: "grep -B 3 'emergency' search_results.txt | grep 'CHUNK_ID:'" → Extract specific chunk IDs
330
- - get_content tool → Load only that specific chunk
331
-
332
- FOR TARGETED QUERIES (e.g., "how do I configure WPA-2 on my DPO-1 router"):
333
- Use the standard search workflow:
334
- 1. Find relevant items (search_items_by_name or search_content with includeContent: false)
335
- 2. Search within those items (search_content with hybrid method)
336
-
337
- Choose your strategy based on the query type:
338
-
339
- FOR LISTING QUERIES (e.g., "list all documents about X", "what items mention Y", "show me documents regarding Z"):
340
-
341
- CRITICAL DECISION - When to use which tool:
342
-
343
- ⚠️ Use search_items_by_name ONLY when:
344
- - User asks for documents BY TITLE/NAME (e.g., "find document named...", "show me file titled...")
345
- - Looking for specific filename patterns (e.g., "all items with 'report' in the name")
346
- - Query is explicitly about document TITLES, not their content
347
-
348
- ✅ Use search_content with includeContent: false for CONTENT-BASED listing queries:
349
- - "List all documents about [topic]" → Searches document CONTENT for the topic
350
- - "What documents mention [subject]" → Searches CONTENT for mentions of the subject
351
- - "Show me documents regarding [thing]" → Searches CONTENT for discussions of the thing
352
- - Any query asking about document TOPICS, SUBJECTS, or CONCEPTS → Search CONTENT
353
-
354
- KEY PRINCIPLE:
355
- - search_items_by_name = "Does the FILENAME contain this word?"
356
- - search_content = "Does the DOCUMENT DISCUSS this topic?"
357
-
358
- DEFAULT RULE: When in doubt, use search_content with includeContent: false for listing queries.
359
- This searches the actual content and returns matching item names/metadata without loading full text.
360
-
361
- IMPORTANT: For listing queries, NEVER set includeContent: true unless you need the actual text content to answer
362
-
363
- FOR TARGETED QUERIES (e.g., "how do I configure X", "what does the manual say about Y"):
364
- TWO-STEP PATTERN:
365
- 1. Find relevant documents: Use search_content with includeContent: false
366
- 2. Get specific information: Use search_content with includeContent: true to retrieve the answer
367
-
368
- ONLY say "no information found" if you have:
369
- ✓ Searched ALL available contexts
370
- ✓ Tried hybrid, keyword, AND semantic search
371
- ✓ Tried variations of the search terms
372
- ✓ Confirmed zero results across all attempts
373
-
374
- Search Method Selection:
375
- - Use 'hybrid' method by default (combines semantic + keyword matching)
376
- - Use 'keyword' method for exact terms (technical terms, product names, IDs)
377
- - Use 'semantic' method for conceptual queries (synonyms and paraphrasing)
378
-
379
- Filtering and Limits:
380
- - Limit results appropriately (don't retrieve more than needed)
381
- - Use count tools when user asks "how many" instead of retrieving all items
382
- `;
383
-
384
- // Copy the generator function from index.ts (keeping it the same)
385
- function createCustomAgenticRetrievalToolLoopAgent({
386
- tools,
387
- model,
388
- customInstructions,
389
- trajectoryLogger,
390
- }: {
391
- language?: string;
392
- tools: Record<string, AITool>;
393
- model: LanguageModel;
394
- customInstructions?: string;
395
- trajectoryLogger?: TrajectoryLogger;
396
- }): {
397
- generate: (args: {
398
- query: string;
399
- reranker?: ExuluReranker;
400
- onFinish: (output: AgenticRetrievalOutput) => void;
401
- trajectoryLogger?: TrajectoryLogger;
402
- }) => AsyncGenerator<AgenticRetrievalOutput>;
403
- } {
404
- return {
405
- generate: async function* ({
406
- reranker,
407
- query,
408
- onFinish,
409
- trajectoryLogger: trajectoryLoggerParam,
410
- }: {
411
- reranker?: ExuluReranker;
412
- query: string;
413
- onFinish: (output: any) => void;
414
- trajectoryLogger?: TrajectoryLogger;
415
- }): AsyncGenerator<any> {
416
- // Use the trajectory logger passed as parameter
417
- const trajectoryLogger = trajectoryLoggerParam || trajectoryLogger;
418
- let finished = false;
419
- let maxSteps = 2;
420
- let currentStep = 0;
421
- const output: AgenticRetrievalOutput = {
422
- reasoning: [],
423
- chunks: [],
424
- usage: [],
425
- totalTokens: 0,
426
- };
427
-
428
- let dynamicTools: Record<string, AITool> = {};
429
- let executionError: Error | undefined;
430
-
431
- while (!finished && currentStep < maxSteps) {
432
- currentStep++;
433
-
434
- console.log("[EXULU] Agentic retrieval v2 step", currentStep);
435
-
436
- const systemPrompt = `
437
- ${baseInstructions}
438
-
439
- AVAILABLE TOOLS:
440
- ${Object.keys({ ...tools, ...dynamicTools })
441
- .map(
442
- (tool) => `
443
- <tool_${tool}>
444
- <name>${tool}</name>
445
- <description>${tools[tool]?.description}</description>
446
- ${
447
- tools[tool]?.inputSchema
448
- ? `
449
- <inputSchema>${JSON.stringify(
450
- zodToJsonSchema(tools[tool]?.inputSchema as z.ZodObject<any>), null, 2
451
- )}</inputSchema>
452
- `
453
- : ""
454
- }
455
- </tool_${tool}>
456
- `,
457
- )
458
- .join("\n\n")}
459
-
460
- ${
461
- customInstructions
462
- ? `
463
- CUSTOM INSTRUCTIONS: ${customInstructions}`
464
- : ""
465
- }
466
- `;
467
-
468
- // First generateText call - reasoning
469
- let reasoningOutput: Awaited<ReturnType<typeof generateText>>;
470
- const reasoningStartTime = Date.now();
471
- try {
472
- reasoningOutput = await withRetry(async () => {
473
- console.log("[EXULU] Generating reasoning for step", currentStep);
474
- return await generateText({
475
- model: model,
476
- output: Output.object({
477
- schema: z.object({
478
- reasoning: z
479
- .string()
480
- .describe(
481
- "The reasoning for the next step and why the agent needs to take this step. It MUST start with 'I must call tool XYZ', and MUST include the inputs for that tool.",
482
- ),
483
- finished: z
484
- .boolean()
485
- .describe(
486
- "Whether the agent has finished meaning no further steps are needed, this should only be true if the agent believes no further tool calls are needed to get the relevant information for the query.",
487
- ),
488
- }),
489
- }),
490
- toolChoice: "none",
491
- system: systemPrompt,
492
- prompt: `
493
- Original query: ${query}
494
-
495
- Previous step reasoning and output:
496
-
497
- ${output.reasoning
498
- .map(
499
- (reasoning, index) => `
500
- <step_${index + 1}>
501
- <reasoning>
502
- ${reasoning.text}
503
- </reasoning>
504
-
505
- ${
506
- reasoning.chunks
507
- ? `<retrieved_chunks>
508
- ${reasoning.chunks
509
- .map(
510
- (chunk) => `
511
- ${chunk.item_name} - ${chunk.item_id} - ${chunk.context} - ${chunk.chunk_id} - ${chunk.chunk_index}
512
- `,
513
- )
514
- .join("\n")}
515
- </retrieved_chunks>`
516
- : ""
517
- }
518
-
519
- ${
520
- reasoning.tools
521
- ? `
522
- <used_tools>
523
- ${reasoning.tools
524
- .map(
525
- (tool) => `
526
- ${tool.name} - ${tool.id} - ${tool.input}
527
- `,
528
- )
529
- .join("\n")}
530
- </used_tools>
531
- `
532
- : ""
533
- }
534
-
535
- </step_${index + 1}>
536
- `,
537
- )
538
- .join("\n")}
539
- `,
540
- stopWhen: [stepCountIs(1)],
541
- });
542
- });
543
- console.log("[EXULU] Reasoning generated for step", currentStep);
544
- } catch (error) {
545
- console.error("[EXULU] Failed to generate reasoning after 3 retries:", error);
546
- executionError = error as Error;
547
- throw error;
548
- }
549
- const reasoningDuration = Date.now() - reasoningStartTime;
550
-
551
- const { reasoning: briefing, finished } = reasoningOutput?.output || {};
552
-
553
- const { usage: reasoningUsage } = reasoningOutput || {};
554
-
555
- output.usage.push(reasoningUsage);
556
-
557
- if (finished) {
558
- console.log("[EXULU] Agentic retrieval finished for step", currentStep);
559
-
560
- // Log this final reasoning step
561
- if (trajectoryLogger) {
562
- trajectoryLogger.logStep(
563
- currentStep,
564
- {
565
- text: briefing || "Finished - no further steps needed",
566
- finished: true,
567
- tokens: reasoningUsage?.totalTokens || 0,
568
- durationMs: reasoningDuration,
569
- }
570
- );
571
- }
572
-
573
- break;
574
- }
575
-
576
- // Second generateText call - tool execution
577
- let toolOutput: Awaited<ReturnType<typeof generateText>>;
578
- try {
579
- toolOutput = await withRetry(async () => {
580
- console.log("[EXULU] Generating tool output for step", currentStep);
581
- return await generateText({
582
- model: model,
583
- tools: { ...tools, ...dynamicTools },
584
- toolChoice: "required",
585
- prompt: `${briefing}`,
586
- stopWhen: [stepCountIs(1)],
587
- });
588
- });
589
- console.log("[EXULU] Tool output generated for step", currentStep);
590
- } catch (error) {
591
- console.error("[EXULU] Failed to generate tool output after 3 retries:", error);
592
- throw error;
593
- }
594
-
595
- const toolResults = toolOutput.toolResults;
596
- const toolCalls = toolOutput.toolCalls;
597
-
598
- let chunks: any[] = [];
599
- console.log("[EXULU] Processing tool results for step", currentStep);
600
- if (Array.isArray(toolResults)) {
601
- chunks = toolResults
602
- .map((result) => {
603
- let chunks: any[] = [];
604
- if (typeof result.output === "string") {
605
- try {
606
- chunks = JSON.parse(result.output);
607
- } catch (e) {
608
- // If parse fails, this might be bash output or count result
609
- console.log("[EXULU] Tool output is not JSON, skipping chunk processing");
610
- return [];
611
- }
612
- } else {
613
- chunks = result.output as any[];
614
- }
615
- console.log("[EXULU] Chunks", chunks);
616
- return chunks.map((chunk) => ({
617
- ...chunk,
618
- context: {
619
- name: chunk.context ? chunk.context.replaceAll("_", " ") : "",
620
- id: chunk.context,
621
- },
622
- }));
623
- })
624
- .flat();
625
- }
626
-
627
- if (chunks && chunks.length > 0 && reranker) {
628
- console.log(
629
- "[EXULU] Reranking chunks for step, using reranker",
630
- reranker.name + "(" + reranker.id + ")",
631
- "for step",
632
- currentStep,
633
- " for " + chunks?.length + " chunks",
634
- );
635
- chunks = await reranker.run(query, chunks);
636
- console.log(
637
- "[EXULU] Reranked chunks for step",
638
- currentStep,
639
- "using reranker",
640
- reranker.name + "(" + reranker.id + ")",
641
- " resulting in ",
642
- chunks?.length + " chunks",
643
- );
644
- }
645
-
646
- if (chunks && chunks.length > 0) {
647
- output.chunks.push(...chunks);
648
- }
649
-
650
- console.log("[EXULU] Pushing reasoning for step", currentStep);
651
-
652
- const exludedContent = toolCalls?.some(
653
- (toolCall) =>
654
- toolCall.input?.includeContent === false ||
655
- toolCall.toolName.startsWith("search_items_by_name"),
656
- );
657
-
658
- // Track dynamic tools created in this step
659
- const dynamicToolsCreatedThisStep: string[] = [];
660
-
661
- // Create chunk specific tools for context expansion
662
- for (const chunk of chunks) {
663
- const getMoreToolName = sanitizeToolName("get_more_content_from_" + chunk.item_name);
664
- const { db } = await postgresClient();
665
-
666
- if (!dynamicTools[getMoreToolName]) {
667
- const chunksTable = getChunksTableName(chunk.context.id);
668
- const countChunksQuery = await db
669
- .from(chunksTable)
670
- .where({ source: chunk.item_id })
671
- .count("id");
672
- const chunksCount = Number(countChunksQuery[0].count) || 0;
673
-
674
- if (chunksCount > 1) {
675
- dynamicToolsCreatedThisStep.push(getMoreToolName);
676
- dynamicTools[getMoreToolName] = tool({
677
- description: `The item ${chunk.item_name} has a total of ${chunksCount} chunks, this tool allows you to get more content from this item across all its pages / chunks.`,
678
- inputSchema: z.object({
679
- from_index: z
680
- .number()
681
- .default(1)
682
- .describe("The index of the chunk to start from."),
683
- to_index: z
684
- .number()
685
- .max(chunksCount)
686
- .describe("The index of the chunk to end at, max is " + chunksCount),
687
- }),
688
- execute: async ({ from_index, to_index }) => {
689
- const chunks = await db(chunksTable)
690
- .select("*")
691
- .where("source", chunk.item_id)
692
- .whereBetween("chunk_index", [from_index, to_index])
693
- .orderBy("chunk_index", "asc");
694
- return JSON.stringify(
695
- chunks.map((resultChunk) => ({
696
- chunk_content: resultChunk.content,
697
- chunk_index: resultChunk.chunk_index,
698
- chunk_id: resultChunk.id,
699
- chunk_source: resultChunk.source,
700
- chunk_metadata: resultChunk.metadata,
701
- item_id: chunk.item_id,
702
- item_name: chunk.item_name,
703
- context: chunk.context?.id,
704
- })),
705
- null,
706
- 2,
707
- );
708
- },
709
- });
710
- }
711
- }
712
-
713
- if (exludedContent) {
714
- const getContentToolName = sanitizeToolName(
715
- "get_" + chunk.item_name + "_page_" + chunk.chunk_index + "_content",
716
- );
717
- dynamicToolsCreatedThisStep.push(getContentToolName);
718
- dynamicTools[getContentToolName] = tool({
719
- description: `Get the content of the page ${chunk.chunk_index} for the item ${chunk.item_name}`,
720
- inputSchema: z.object({
721
- reasoning: z
722
- .string()
723
- .describe("The reasoning for why you need to get the content of the page."),
724
- }),
725
- execute: async ({ reasoning }) => {
726
- const { db } = await postgresClient();
727
- const chunksTable = getChunksTableName(chunk.context.id);
728
- const resultChunks = await db(chunksTable)
729
- .select("*")
730
- .where("id", chunk.chunk_id)
731
- .limit(1);
732
- if (!resultChunks || !resultChunks[0]) {
733
- return null;
734
- }
735
-
736
- return JSON.stringify(
737
- [
738
- {
739
- reasoning: reasoning,
740
- chunk_content: resultChunks[0].content,
741
- chunk_index: resultChunks[0].chunk_index,
742
- chunk_id: resultChunks[0].id,
743
- chunk_source: resultChunks[0].source,
744
- chunk_metadata: resultChunks[0].metadata,
745
- chunk_created_at: resultChunks[0].chunk_created_at,
746
- item_id: chunk.item_id,
747
- item_name: chunk.item_name,
748
- context: chunk.context?.id,
749
- },
750
- ],
751
- null,
752
- 2,
753
- );
754
- },
755
- });
756
- }
757
- }
758
-
759
- output.reasoning.push({
760
- text: briefing,
761
- chunks: chunks || [],
762
- tools:
763
- toolCalls?.length > 0
764
- ? toolCalls.map((toolCall) => ({
765
- name: toolCall.toolName,
766
- id: toolCall.toolCallId,
767
- input: toolCall.input,
768
- output: chunks,
769
- }))
770
- : [],
771
- });
772
-
773
- const { usage: toolUsage } = toolOutput || {};
774
- console.log("[EXULU] Pushing tool usage for step", currentStep);
775
- output.usage.push(toolUsage);
776
-
777
- // Log this step to trajectory
778
- if (trajectoryLogger) {
779
- trajectoryLogger.logStep(
780
- currentStep,
781
- {
782
- text: briefing || "",
783
- finished: false,
784
- tokens: reasoningUsage?.totalTokens || 0,
785
- durationMs: reasoningDuration,
786
- },
787
- {
788
- toolCalls: toolCalls || [],
789
- toolResults: toolResults || [],
790
- chunks: chunks,
791
- chunksAfterReranking: reranker && chunks.length > 0 ? chunks.length : undefined,
792
- tokens: toolUsage?.totalTokens || 0,
793
- },
794
- dynamicToolsCreatedThisStep
795
- );
796
- }
797
-
798
- console.log(`[EXULU] Agentic retrieval step ${currentStep} completed`);
799
- console.log("[EXULU] Agentic retrieval step output", output);
800
-
801
- yield output;
802
- }
803
-
804
- const totalTokens = output.usage.reduce((acc, usage) => acc + (usage.totalTokens || 0), 0);
805
- output.totalTokens = totalTokens;
806
-
807
- console.log("[EXULU] Agentic retrieval finished", output);
808
-
809
- // Finalize trajectory log
810
- if (trajectoryLogger) {
811
- await trajectoryLogger.finalize(output, !executionError, executionError);
812
- }
813
-
814
- onFinish(output);
815
- },
816
- };
817
- }
818
-
819
- /**
820
- * Creates an enhanced agentic retrieval agent with bash support and advanced capabilities
821
- */
822
- const createAgenticRetrievalAgent = async ({
823
- contexts,
824
- user,
825
- role,
826
- model,
827
- instructions: custom,
828
- projectRetrievalTool,
829
- language = "eng",
830
- }: {
831
- contexts: ExuluContext[];
832
- user?: User;
833
- role?: string;
834
- model: LanguageModel;
835
- instructions?: string;
836
- projectRetrievalTool?: ExuluTool;
837
- language?: string;
838
- }): Promise<{
839
- generate: (args: {
840
- query: string;
841
- reranker?: ExuluReranker;
842
- trajectoryLogger?: TrajectoryLogger;
843
- onFinish: (output: AgenticRetrievalOutput) => void;
844
- }) => AsyncGenerator<AgenticRetrievalOutput>;
845
- updateVirtualFiles: (files: Record<string, string>) => Promise<void>;
846
- }> => {
847
- // Initialize virtual bash environment
848
- const { tools: bashTools, updateFiles } = await createBashTool({
849
- files: {}, // Start with empty virtual filesystem
850
- });
851
-
852
- console.log("[EXULU] Created bash tools:", Object.keys(bashTools));
853
-
854
- // NEW TOOL 1: save_search_results - saves results to virtual filesystem for iterative filtering
855
- const saveSearchResultsTool = tool({
856
- description: `
857
- Execute a search and save results to the virtual filesystem instead of returning them directly.
858
- This is useful when you expect many results (>20) and want to iteratively filter them
859
- without consuming tokens by loading all content into context.
860
-
861
- After saving, you can use bash tools (grep, awk, head, tail) to find specific patterns.
862
- The file will be available in the virtual filesystem at /search_results.txt
863
-
864
- The results are formatted with clear separators so you can easily grep for:
865
- - ITEM_NAME: to find documents by name
866
- - CHUNK_ID: to extract specific chunk IDs
867
- - SCORE: to see relevance scores
868
- - Content between ---CONTENT START--- and ---CONTENT END---
869
-
870
- Example usage after saving:
871
- - grep -i "safety" search_results.txt | head -20
872
- - grep "ITEM_NAME: Manual" search_results.txt -A 15
873
- - grep "CHUNK_ID:" search_results.txt | head -10
874
- `,
875
- inputSchema: z.object({
876
- knowledge_base_ids: z.array(z.enum(contexts.map((ctx) => ctx.id) as [string, ...string[]]))
877
- .describe(`
878
- The available knowledge bases are:
879
- ${contexts
880
- .map(
881
- (ctx) => `
882
- <knowledge_base>
883
- <id>${ctx.id}</id>
884
- <name>${ctx.name}</name>
885
- <description>${ctx.description}</description>
886
- </knowledge_base>
887
- `,
888
- )
889
- .join("\n")}
890
- `),
891
- query: z.string().describe("The search query to find relevant chunks"),
892
- searchMethod: z
893
- .enum(["keyword", "semantic", "hybrid"])
894
- .default("hybrid")
895
- .describe("Search method to use"),
896
- limit: z.number().max(1000).default(100).describe("Maximum number of results to retrieve and save (max 1000)"),
897
- includeContent: z.boolean().default(true).describe("Whether to include full chunk content in saved results. Set to false if you only need metadata and plan to load specific chunks later."),
898
- }),
899
- execute: async ({ query, knowledge_base_ids, searchMethod, limit, includeContent }) => {
900
- if (!knowledge_base_ids?.length) {
901
- knowledge_base_ids = contexts.map((ctx) => ctx.id);
902
- }
903
-
904
- const results: VectorSearchChunkResult[][] = await Promise.all(
905
- knowledge_base_ids.map(async (knowledge_base_id) => {
906
- const ctx = contexts.find(
907
- (ctx) =>
908
- ctx.id === knowledge_base_id ||
909
- ctx.id.toLowerCase().includes(knowledge_base_id.toLowerCase()),
910
- );
911
-
912
- if (!ctx) {
913
- throw new Error("Knowledge base ID not found: " + knowledge_base_id);
914
- }
915
-
916
- const searchResults = await ctx.search({
917
- query: query,
918
- method:
919
- searchMethod === "hybrid"
920
- ? "hybridSearch"
921
- : searchMethod === "keyword"
922
- ? "tsvector"
923
- : "cosineDistance",
924
- limit: Math.min(limit, 1000),
925
- page: 1,
926
- itemFilters: [],
927
- chunkFilters: [],
928
- sort: { field: "updatedAt", direction: "desc" },
929
- user,
930
- role,
931
- trigger: "tool",
932
- });
933
-
934
- return searchResults.chunks;
935
- })
936
- );
937
-
938
- const chunks = results.flat();
939
-
940
- // Format results in a greppable format with clear separators
941
- const formattedContent = chunks.map((chunk, idx) =>
942
- `### RESULT ${idx + 1} ###\n` +
943
- `ITEM_NAME: ${chunk.item_name}\n` +
944
- `ITEM_ID: ${chunk.item_id}\n` +
945
- `CHUNK_ID: ${chunk.chunk_id}\n` +
946
- `CHUNK_INDEX: ${chunk.chunk_index}\n` +
947
- `CONTEXT: ${chunk.context?.id}\n` +
948
- `SCORE: ${chunk.chunk_hybrid_score || chunk.chunk_fts_rank || chunk.chunk_cosine_distance || 0}\n` +
949
- `---CONTENT START---\n` +
950
- `${includeContent && chunk.chunk_content ? chunk.chunk_content : '[Content not included - use includeContent: true to load, or use get_content tool for specific chunks]'}\n` +
951
- `---CONTENT END---\n\n`
952
- ).join('');
953
-
954
- // Update virtual filesystem with search results
955
- await updateFiles({
956
- 'search_results.txt': formattedContent,
957
- 'search_metadata.json': JSON.stringify({
958
- query,
959
- timestamp: new Date().toISOString(),
960
- results_count: chunks.length,
961
- contexts: knowledge_base_ids,
962
- method: searchMethod,
963
- }, null, 2)
964
- });
965
-
966
- return JSON.stringify({
967
- success: true,
968
- results_count: chunks.length,
969
- message: `Saved ${chunks.length} results to virtual filesystem at /search_results.txt. You can now use bash tools to grep/filter the results without loading them into context.`,
970
- available_commands: [
971
- 'bash: cat search_results.txt | head -50',
972
- 'bash: grep -i "your pattern" search_results.txt',
973
- 'bash: grep "ITEM_NAME: specific_name" search_results.txt -A 10',
974
- 'bash: grep "CHUNK_ID:" search_results.txt | head -10',
975
- ],
976
- next_steps: "Use bash tools to grep/filter the results. Once you identify relevant chunks, you can load their full content using the get_content tools.",
977
- }, null, 2);
978
- },
979
- });
980
-
981
- // NEW TOOL 2: count_items_or_chunks - COUNT queries without loading data
982
- const countTool = tool({
983
- description: `
984
- Count items or chunks matching specific criteria WITHOUT loading them into context.
985
- Use this when the user asks "how many...", "count...", or "number of...".
986
-
987
- You can count:
988
- - Total items in one or more contexts
989
- - Total chunks in one or more contexts
990
- - Items where name contains specific text
991
- - Chunks containing specific content (uses search to find matches, then counts)
992
-
993
- This is much more efficient than retrieving all results just to count them.
994
- `,
995
- inputSchema: z.object({
996
- knowledge_base_ids: z.array(z.enum(contexts.map((ctx) => ctx.id) as [string, ...string[]]))
997
- .describe(`
998
- The available knowledge bases to count from:
999
- ${contexts
1000
- .map(
1001
- (ctx) => `
1002
- <knowledge_base>
1003
- <id>${ctx.id}</id>
1004
- <name>${ctx.name}</name>
1005
- <description>${ctx.description}</description>
1006
- </knowledge_base>
1007
- `,
1008
- )
1009
- .join("\n")}
1010
- `),
1011
- count_what: z.enum(['items', 'chunks']).describe("Whether to count items or chunks"),
1012
- name_contains: z.string().optional().describe("Only count items where name contains this text (case-insensitive)"),
1013
- content_query: z.string().optional().describe("Only count chunks that match this search query (uses hybrid search to find relevant chunks, then counts them)"),
1014
- }),
1015
- execute: async ({ knowledge_base_ids, count_what, name_contains, content_query }) => {
1016
- if (!knowledge_base_ids?.length) {
1017
- knowledge_base_ids = contexts.map((ctx) => ctx.id);
1018
- }
1019
-
1020
- const { db } = await postgresClient();
1021
-
1022
- const counts = await Promise.all(
1023
- knowledge_base_ids.map(async (knowledge_base_id) => {
1024
- const ctx = contexts.find((c) => c.id === knowledge_base_id);
1025
- if (!ctx) {
1026
- throw new Error("Knowledge base ID not found: " + knowledge_base_id);
1027
- }
1028
-
1029
- let count = 0;
1030
-
1031
- if (count_what === 'items') {
1032
- const tableName = getTableName(ctx.id);
1033
- let query = db(tableName).count('id as count');
1034
-
1035
- if (name_contains) {
1036
- query = query.whereRaw('LOWER(name) LIKE ?', [`%${name_contains.toLowerCase()}%`]);
1037
- }
1038
-
1039
- // Apply access control
1040
- const tableDefinition = convertContextToTableDefinition(ctx);
1041
- query = applyAccessControl(tableDefinition, query, user, tableName);
1042
-
1043
- const result = await query.first();
1044
- count = Number(result?.count || 0);
1045
- } else {
1046
- // count_what === 'chunks'
1047
- const chunksTableName = getChunksTableName(ctx.id);
1048
-
1049
- if (content_query) {
1050
- // Use search to find matching chunks, then count
1051
- const searchResults = await ctx.search({
1052
- query: content_query,
1053
- method: 'hybridSearch',
1054
- limit: 10000, // Large limit to get all matches
1055
- page: 1,
1056
- itemFilters: [],
1057
- chunkFilters: [],
1058
- user,
1059
- role,
1060
- trigger: "tool",
1061
- });
1062
- count = searchResults.chunks.length;
1063
- } else {
1064
- // Count all chunks
1065
- let query = db(chunksTableName).count('id as count');
1066
- const result = await query.first();
1067
- count = Number(result?.count || 0);
1068
- }
1069
- }
1070
-
1071
- return {
1072
- context: knowledge_base_id,
1073
- context_name: ctx.name,
1074
- count: count,
1075
- };
1076
- })
1077
- );
1078
-
1079
- const totalCount = counts.reduce((sum, c) => sum + c.count, 0);
1080
-
1081
- return JSON.stringify({
1082
- success: true,
1083
- total_count: totalCount,
1084
- breakdown_by_context: counts,
1085
- query_details: {
1086
- counted: count_what,
1087
- name_filter: name_contains || 'none',
1088
- content_filter: content_query || 'none',
1089
- contexts_searched: knowledge_base_ids.length,
1090
- },
1091
- }, null, 2);
1092
- },
1093
- });
1094
-
1095
- // Copy existing search tools from index.ts
1096
- const searchItemsByNameTool = {
1097
- search_items_by_name: tool({
1098
- description: `Search for relevant items by name across the available knowledge bases.`,
1099
- inputSchema: z.object({
1100
- knowledge_base_ids: z.array(z.enum(contexts.map((ctx) => ctx.id) as [string, ...string[]]))
1101
- .describe(`
1102
- The available knowledge bases are:
1103
- ${contexts
1104
- .map(
1105
- (ctx) => `
1106
- <knowledge_base>
1107
- <id>${ctx.id}</id>
1108
- <name>${ctx.name}</name>
1109
- <description>${ctx.description}</description>
1110
- </knowledge_base>
1111
- `,
1112
- )
1113
- .join("\n")}
1114
- `),
1115
- item_name: z.string().describe("The name of the item to search for."),
1116
- limit: z
1117
- .number()
1118
- .default(100)
1119
- .describe(
1120
- "Maximum number of items to return (max 400), if searching through multiple knowledge bases, the limit is applied for each knowledge base individually.",
1121
- ),
1122
- }),
1123
- execute: async ({ item_name, limit, knowledge_base_ids }) => {
1124
- if (!knowledge_base_ids?.length) {
1125
- knowledge_base_ids = contexts.map((ctx) => ctx.id);
1126
- }
1127
-
1128
- let itemFilters: SearchFilters = [];
1129
- if (item_name) {
1130
- itemFilters.push({ name: { contains: item_name } });
1131
- }
1132
-
1133
- const { db } = await postgresClient();
1134
-
1135
- const results = await Promise.all(
1136
- knowledge_base_ids.map(async (knowledge_base_id) => {
1137
- const ctx = contexts.find(
1138
- (ctx) =>
1139
- ctx.id === knowledge_base_id ||
1140
- ctx.id.toLowerCase().includes(knowledge_base_id.toLowerCase()),
1141
- );
1142
- if (!ctx) {
1143
- throw new Error(
1144
- "Knowledge base ID that was provided to search items by name not found: " + knowledge_base_id,
1145
- );
1146
- }
1147
- let itemsQuery = db(getTableName(ctx.id) + " as items").select([
1148
- "items.id as item_id",
1149
- "items.name as item_name",
1150
- "items.external_id as item_external_id",
1151
- db.raw('items."updatedAt" as item_updated_at'),
1152
- db.raw('items."createdAt" as item_created_at'),
1153
- ...ctx.fields.map((field) => `items.${field.name} as ${field.name}`),
1154
- ]);
1155
-
1156
- if (!limit) {
1157
- limit = 100;
1158
- }
1159
- limit = Math.min(limit, 400);
1160
- itemsQuery = itemsQuery.limit(limit);
1161
-
1162
- const tableDefinition = convertContextToTableDefinition(ctx);
1163
- itemsQuery = applyFilters(itemsQuery, itemFilters || [], tableDefinition, "items");
1164
- itemsQuery = applyAccessControl(tableDefinition, itemsQuery, user, "items");
1165
-
1166
- const items = await itemsQuery;
1167
-
1168
- return items?.map((item) => ({
1169
- ...item,
1170
- context: ctx.id,
1171
- }));
1172
- }),
1173
- );
1174
-
1175
- const items = results.flat();
1176
-
1177
- const formattedResults: (ToolResult | null)[] = await Promise.all(
1178
- items.map(async (item) => {
1179
- if (!item.item_id || !item.context) {
1180
- throw new Error("Item id and context are required to get chunks.");
1181
- }
1182
-
1183
- const chunksTable = getChunksTableName(item.context);
1184
- const chunks: any[] = await db
1185
- .from(chunksTable)
1186
- .select(["id", "source", "metadata"])
1187
- .where("source", item.item_id)
1188
- .limit(1);
1189
-
1190
- if (!chunks || !chunks[0]) {
1191
- return null;
1192
- }
1193
-
1194
- return {
1195
- item_name: item.item_name,
1196
- item_id: item.item_id,
1197
- context: item.context || "",
1198
- chunk_id: chunks[0].id,
1199
- chunk_index: 1,
1200
- chunk_content: undefined,
1201
- metadata: chunks[0].metadata,
1202
- };
1203
- }),
1204
- );
1205
-
1206
- return JSON.stringify(
1207
- formattedResults.filter((result) => result !== null),
1208
- null,
1209
- 2,
1210
- );
1211
- },
1212
- }),
1213
- };
1214
-
1215
- const searchTools = {
1216
- search_content: tool({
1217
- description: `
1218
- Search for relevant information within the actual content of the items across available knowledge bases.
1219
-
1220
- This tool provides a number of strategies:
1221
- - Keyword search: search for exact terms, technical names, IDs, or specific phrases
1222
- - Semantic search: search for conceptual queries where synonyms and paraphrasing matter
1223
- - Hybrid search: best for most queries - combines semantic understanding with exact term matching
1224
-
1225
- You can use the includeContent parameter to control whether to return
1226
- the full chunk content or just metadata.
1227
-
1228
- Use with includeContent: true (default) when you need to:
1229
- - Find specific information or answers within documents
1230
- - Get actual text content that answers a query
1231
- - Extract details, explanations, or instructions from content
1232
-
1233
- Use with includeContent: false when you need to:
1234
- - List which documents/items contain certain topics
1235
- - Count or overview items that match a content query
1236
- - Find item names/metadata without loading full content
1237
- - You can always fetch content later if needed
1238
- `,
1239
- inputSchema: z.object({
1240
- query: z
1241
- .string()
1242
- .describe(
1243
- "The search query to find relevant chunks, this must always be related to the content you are looking for, not something like 'Page 2'.",
1244
- ),
1245
- knowledge_base_ids: z.array(z.enum(contexts.map((ctx) => ctx.id) as [string, ...string[]]))
1246
- .describe(`
1247
- The available knowledge bases are:
1248
- ${contexts
1249
- .map(
1250
- (ctx) => `
1251
- <knowledge_base>
1252
- <id>${ctx.id}</id>
1253
- <name>${ctx.name}</name>
1254
- <description>${ctx.description}</description>
1255
- </knowledge_base>
1256
- `,
1257
- )
1258
- .join("\n")}
1259
- `),
1260
- keywords: z
1261
- .array(z.string())
1262
- .optional()
1263
- .describe(
1264
- "Keywords to search for. Usually extracted from the query, allowing for more precise search results.",
1265
- ),
1266
- searchMethod: z
1267
- .enum(["keyword", "semantic", "hybrid"])
1268
- .default("hybrid")
1269
- .describe(
1270
- "Search method: 'hybrid' (best for most queries - combines semantic understanding with exact term matching), 'keyword' (best for exact terms, technical names, IDs, or specific phrases), 'semantic' (best for conceptual queries where synonyms and paraphrasing matter)",
1271
- ),
1272
- includeContent: z
1273
- .boolean()
1274
- .default(true)
1275
- .describe(
1276
- "Whether to include the full chunk content in results. " +
1277
- "Set to FALSE when you only need to know WHICH documents/items are relevant (lists, overviews, counts). " +
1278
- "Set to TRUE when you need the ACTUAL content to answer the question (information, details, explanations). " +
1279
- "You can always fetch content later, so prefer FALSE for efficiency when listing documents.",
1280
- ),
1281
-
1282
- item_ids: z
1283
- .array(z.string())
1284
- .optional()
1285
- .describe(
1286
- "Use if you wish to retrieve content from specific items (documents) based on the item ID.",
1287
- ),
1288
- item_names: z
1289
- .array(z.string())
1290
- .optional()
1291
- .describe(
1292
- "Use if you wish to retrieve content from specific items (documents) based on the item name. Can be a partial match.",
1293
- ),
1294
- item_external_ids: z
1295
- .array(z.string())
1296
- .optional()
1297
- .describe(
1298
- "Use if you wish to retrieve content from specific items (documents) based on the item external ID. Can be a partial match.",
1299
- ),
1300
- limit: z.number().default(10).describe("Maximum number of chunks to return (max 10)"),
1301
- }),
1302
- execute: async ({
1303
- query,
1304
- searchMethod,
1305
- limit,
1306
- includeContent,
1307
- item_ids,
1308
- item_names,
1309
- item_external_ids,
1310
- keywords,
1311
- knowledge_base_ids,
1312
- }) => {
1313
- if (!knowledge_base_ids?.length) {
1314
- knowledge_base_ids = contexts.map((ctx) => ctx.id);
1315
- }
1316
-
1317
- const results: VectorSearchChunkResult[][] = await Promise.all(
1318
- knowledge_base_ids.map(async (knowledge_base_id) => {
1319
- const ctx = contexts.find(
1320
- (ctx) =>
1321
- ctx.id === knowledge_base_id ||
1322
- ctx.id.toLowerCase().includes(knowledge_base_id.toLowerCase()),
1323
- );
1324
-
1325
- if (!ctx) {
1326
- throw new Error("Knowledge base ID that was provided to search content not found: " + knowledge_base_id);
1327
- }
1328
-
1329
- let itemFilters: SearchFilters = [];
1330
- if (item_ids) {
1331
- itemFilters.push({ id: { in: item_ids } });
1332
- }
1333
- if (item_names) {
1334
- itemFilters.push({ name: { or: item_names.map((name) => ({ contains: name })) } });
1335
- }
1336
- if (item_external_ids) {
1337
- itemFilters.push({ external_id: { in: item_external_ids } });
1338
- }
1339
-
1340
- if (!query && keywords) {
1341
- query = keywords.join(" ");
1342
- }
1343
-
1344
- const results = await ctx.search({
1345
- query: query,
1346
- keywords: keywords,
1347
- method:
1348
- searchMethod === "hybrid"
1349
- ? "hybridSearch"
1350
- : searchMethod === "keyword"
1351
- ? "tsvector"
1352
- : "cosineDistance",
1353
- limit: includeContent ? Math.min(limit, 10) : Math.min(limit * 20, 400),
1354
- page: 1,
1355
- itemFilters: itemFilters || [],
1356
- chunkFilters: [],
1357
- sort: { field: "updatedAt", direction: "desc" },
1358
- user,
1359
- role,
1360
- trigger: "tool",
1361
- });
1362
-
1363
- return results.chunks.map((chunk) => chunk);
1364
- }),
1365
- );
1366
-
1367
- const resultsFlat: VectorSearchChunkResult[] = results.flat();
1368
-
1369
- // Format results with citation info
1370
- const formattedResults: ToolResult[] = resultsFlat.map((chunk) => ({
1371
- item_name: chunk.item_name,
1372
- item_id: chunk.item_id,
1373
- context: chunk.context?.id || "",
1374
- chunk_id: chunk.chunk_id,
1375
- chunk_index: chunk.chunk_index,
1376
- chunk_content: includeContent ? chunk.chunk_content : undefined,
1377
- metadata: {
1378
- ...chunk.chunk_metadata,
1379
- cosine_distance: chunk.chunk_cosine_distance,
1380
- fts_rank: chunk.chunk_fts_rank,
1381
- hybrid_score: chunk.chunk_hybrid_score,
1382
- },
1383
- }));
1384
- return JSON.stringify(formattedResults, null, 2);
1385
- },
1386
- }),
1387
- };
1388
-
1389
- console.log("[EXULU] Creating v2 agent with tools:", {
1390
- search: Object.keys(searchTools),
1391
- searchByName: Object.keys(searchItemsByNameTool),
1392
- new: ['save_search_results', 'count_items_or_chunks'],
1393
- bash: Object.keys(bashTools).slice(0, 5) + '...',
1394
- });
1395
-
1396
- // Note: trajectoryLogger will be passed when calling agent.generate()
1397
-
1398
- const agent = createCustomAgenticRetrievalToolLoopAgent({
1399
- language,
1400
- model,
1401
- customInstructions: custom,
1402
- tools: {
1403
- ...searchTools,
1404
- ...searchItemsByNameTool,
1405
- save_search_results: saveSearchResultsTool,
1406
- count_items_or_chunks: countTool,
1407
- ...bashTools, // Add all bash tools (grep, awk, sed, head, tail, cat, etc.)
1408
- ...(projectRetrievalTool ? { [projectRetrievalTool.id]: projectRetrievalTool.tool } : {}),
1409
- },
1410
- });
1411
-
1412
- return {
1413
- ...agent,
1414
- updateVirtualFiles: updateFiles,
1415
- };
1416
- };
1417
-
1418
- /**
1419
- * Generator function for executing agentic retrieval - same as index.ts but uses v2 agent
1420
- */
1421
- async function* executeAgenticRetrievalV2({
1422
- contexts,
1423
- reranker,
1424
- query,
1425
- user,
1426
- role,
1427
- model,
1428
- instructions,
1429
- projectRetrievalTool,
1430
- }: {
1431
- contexts: ExuluContext[];
1432
- reranker?: ExuluReranker;
1433
- query: string;
1434
- projectRetrievalTool?: ExuluTool;
1435
- user?: User;
1436
- role?: string;
1437
- model: LanguageModel;
1438
- instructions?: string;
1439
- }) {
1440
- const { language } = preprocessQuery(query, {
1441
- detectLanguage: true,
1442
- });
1443
-
1444
- console.log("[EXULU] V2 - Language detected:", language);
1445
-
1446
- // Create trajectory logger
1447
- const trajectoryLogger = new TrajectoryLogger(
1448
- query,
1449
- language,
1450
- contexts.map(c => c.id),
1451
-
1452
- {
1453
- enabledContexts: contexts.map(c => c.id),
1454
- reranker: reranker?.id,
1455
- instructions: instructions,
1456
- }
1457
- );
1458
-
1459
- const agent = await createAgenticRetrievalAgent({
1460
- contexts,
1461
- user,
1462
- role,
1463
- model,
1464
- instructions,
1465
- projectRetrievalTool,
1466
- language,
1467
- });
1468
-
1469
- console.log("[EXULU] V2 - Starting agentic retrieval");
1470
-
1471
- try {
1472
- let finishResolver: (value: any) => void;
1473
- let finishRejector: (error: Error) => void;
1474
-
1475
- const finishPromise = new Promise<any>((resolve, reject) => {
1476
- finishResolver = resolve;
1477
- finishRejector = reject;
1478
- });
1479
-
1480
- const timeoutId = setTimeout(() => {
1481
- finishRejector(new Error("Agentic retrieval timed out after 240 seconds"));
1482
- }, 240000);
1483
-
1484
- const result = agent.generate({
1485
- reranker,
1486
- query,
1487
- trajectoryLogger: trajectoryLogger,
1488
- onFinish: (output) => {
1489
- clearTimeout(timeoutId);
1490
- finishResolver(output);
1491
- },
1492
- });
1493
-
1494
- for await (const output of result) {
1495
- yield output;
1496
- }
1497
-
1498
- const finalOutput = await finishPromise;
1499
-
1500
- console.log("[EXULU] V2 - Agentic retrieval output", finalOutput);
1501
-
1502
- return finalOutput;
1503
- } catch (error) {
1504
- console.error("[EXULU] V2 - Agentic retrieval error:", error);
1505
- yield JSON.stringify({
1506
- status: "error",
1507
- message: error instanceof Error ? error.message : "Unknown error occurred",
1508
- });
1509
- }
1510
- }
1511
-
1512
- /**
1513
- * Creates V2 ExuluTool instance for agentic retrieval
1514
- */
1515
- export const createAgenticRetrievalToolV2 = ({
1516
- contexts,
1517
- rerankers,
1518
- user,
1519
- role,
1520
- model,
1521
- projectRetrievalTool,
1522
- }: {
1523
- contexts: ExuluContext[];
1524
- rerankers: ExuluReranker[];
1525
- user?: User;
1526
- role?: string;
1527
- model: any;
1528
- projectRetrievalTool?: ExuluTool;
1529
- }): ExuluTool | undefined => {
1530
- const license = checkLicense();
1531
- if (!license["agentic-retrieval"]) {
1532
- console.warn(`[EXULU] You are not licensed to use agentic retrieval.`);
1533
- return undefined;
1534
- }
1535
- const contextNames = contexts.map((ctx) => ctx.id).join(", ");
1536
-
1537
- return new ExuluTool({
1538
- id: "agentic_context_search",
1539
- name: "Agentic Context Search",
1540
- description: `Enhanced intelligent context search with virtual bash environment, COUNT queries, and token-efficient large result handling. Searches across: ${contextNames}`,
1541
- category: "contexts",
1542
- type: "context",
1543
- config: [
1544
- {
1545
- name: "instructions",
1546
- description: `Custom instructions for searching the knowledge bases.`,
1547
- type: "string",
1548
- default: "",
1549
- },
1550
- {
1551
- name: "reranker",
1552
- description: "The reranker to use for the retrieval process.",
1553
- type: "string",
1554
- default: "none",
1555
- },
1556
- ...contexts.map((ctx) => ({
1557
- name: ctx.id,
1558
- description: `Enable search in the ${ctx.name} context. ${ctx.description}`,
1559
- type: "boolean" as "boolean" | "string" | "number" | "variable",
1560
- default: true,
1561
- })),
1562
- ],
1563
- inputSchema: z.object({
1564
- query: z.string().describe("The question or query to answer using the knowledge bases"),
1565
- userInstructions: z
1566
- .string()
1567
- .optional()
1568
- .describe("Instructions provided by the user to customize the retrieval process."),
1569
- }),
1570
- execute: async function* ({
1571
- query,
1572
- userInstructions,
1573
- toolVariablesConfig,
1574
- }: {
1575
- query: string;
1576
- userInstructions?: string;
1577
- instructions?: string;
1578
- [key: string]: any;
1579
- }) {
1580
- let configInstructions = "";
1581
- let configuredReranker: ExuluReranker | undefined;
1582
- if (toolVariablesConfig) {
1583
- configInstructions = toolVariablesConfig.instructions;
1584
-
1585
- contexts = contexts.filter(
1586
- (ctx) =>
1587
- toolVariablesConfig[ctx.id] === true ||
1588
- toolVariablesConfig[ctx.id] === "true" ||
1589
- toolVariablesConfig[ctx.id] === 1,
1590
- );
1591
-
1592
- if (toolVariablesConfig.reranker) {
1593
- configuredReranker = rerankers.find(
1594
- (reranker) => reranker.id === toolVariablesConfig.reranker,
1595
- );
1596
- if (!configuredReranker) {
1597
- throw new Error(
1598
- "Reranker not found: " +
1599
- toolVariablesConfig.reranker +
1600
- ", check with a developer if the reranker was removed from the system.",
1601
- );
1602
- }
1603
- }
1604
- }
1605
-
1606
- console.log("[EXULU] V2 - Executing agentic retrieval tool with data", {
1607
- configs: Object.keys(toolVariablesConfig),
1608
- query,
1609
- instructions: configInstructions,
1610
- reranker: configuredReranker?.id || undefined,
1611
- contexts: contexts.map((ctx) => ctx.id),
1612
- });
1613
-
1614
- for await (const chunk of executeAgenticRetrievalV2({
1615
- contexts,
1616
- reranker: configuredReranker,
1617
- query,
1618
- user,
1619
- role,
1620
- model,
1621
- instructions: `${configInstructions ? `CUSTOM INSTRUCTIONS PROVIDED BY THE ADMIN: ${configInstructions}` : ""} ${userInstructions ? `INSTRUCTIONS PROVIDED BY THE USER: ${userInstructions}` : ""}`,
1622
- projectRetrievalTool,
1623
- })) {
1624
- yield { result: JSON.stringify(chunk, null, 2) };
1625
- }
1626
- },
1627
- });
1628
- };