@exulu/backend 1.53.0 → 1.54.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 +4892 -3864
- package/dist/index.d.cts +66 -4
- package/dist/index.d.ts +66 -4
- package/dist/index.js +4954 -3933
- package/ee/agentic-retrieval/ANALYSIS.md +658 -0
- package/ee/agentic-retrieval/logs/README.md +198 -0
- package/ee/agentic-retrieval/v2.ts +1628 -0
- package/ee/agentic-retrieval/v3/agent-loop.ts +242 -0
- package/ee/agentic-retrieval/v3/classifier.ts +73 -0
- package/ee/agentic-retrieval/v3/context-sampler.ts +70 -0
- package/ee/agentic-retrieval/v3/dynamic-tools.ts +115 -0
- package/ee/agentic-retrieval/v3/index.ts +281 -0
- package/ee/agentic-retrieval/v3/strategies.ts +167 -0
- package/ee/agentic-retrieval/v3/tools.ts +435 -0
- package/ee/agentic-retrieval/v3/trajectory.ts +96 -0
- package/ee/agentic-retrieval/v3/types.ts +59 -0
- package/ee/agentic-retrieval/v4/agent-loop.ts +121 -0
- package/ee/agentic-retrieval/v4/embed-preprocessor.ts +76 -0
- package/ee/agentic-retrieval/v4/index.ts +181 -0
- package/ee/agentic-retrieval/v4/system-prompt.ts +248 -0
- package/ee/agentic-retrieval/v4/tools.ts +241 -0
- package/ee/agentic-retrieval/v4/types.ts +29 -0
- package/ee/chunking/markdown.ts +4 -2
- package/ee/workers.ts +1 -1
- package/package.json +6 -3
|
@@ -0,0 +1,1628 @@
|
|
|
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
|
+
};
|