@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.
@@ -6,6 +6,8 @@ import type { ExuluReranker } from "@SRC/exulu/reranker";
6
6
  import type { AgenticRetrievalOutput, ChunkResult, ClassificationResult } from "./types";
7
7
  import type { StrategyConfig } from "./strategies";
8
8
  import { createDynamicTools } from "./dynamic-tools";
9
+ import { registerSessionTools } from "./session-tools-registry";
10
+ import type { TrajectoryStepData } from "./trajectory";
9
11
 
10
12
  const FINISH_TOOL_NAME = "finish_retrieval";
11
13
 
@@ -71,9 +73,11 @@ export async function* runAgentLoop(params: {
71
73
  contextGuidance?: string;
72
74
  customInstructions?: string;
73
75
  classification: ClassificationResult;
76
+ sessionId?: string;
74
77
  onStepComplete?: (step: AgenticRetrievalOutput["steps"][0]) => void;
78
+ onTrajectoryStep?: (data: TrajectoryStepData) => void;
75
79
  }): AsyncGenerator<AgenticRetrievalOutput> {
76
- const { query, strategy, tools, model, reranker, contextGuidance, customInstructions, onStepComplete } = params;
80
+ const { query, strategy, tools, model, reranker, contextGuidance, customInstructions, sessionId, onStepComplete, onTrajectoryStep } = params;
77
81
 
78
82
  const output: AgenticRetrievalOutput = {
79
83
  steps: [],
@@ -147,6 +151,16 @@ export async function* runAgentLoop(params: {
147
151
  // Extract chunks from tool results
148
152
  let stepChunks: any[] = extractChunksFromToolResults(result.toolResults as any[]);
149
153
 
154
+ // Deduplicate by chunk_id within this step (parallel tool calls can return the same chunk
155
+ // if the agent searches the same context twice, or the same chunk is indexed in two contexts).
156
+ const seenChunkIds = new Set<string>();
157
+ stepChunks = stepChunks.filter((c) => {
158
+ if (!c.chunk_id) return true;
159
+ if (seenChunkIds.has(c.chunk_id)) return false;
160
+ seenChunkIds.add(c.chunk_id);
161
+ return true;
162
+ });
163
+
150
164
  // Check if any search_content call excluded content (triggers page-load dynamic tools)
151
165
  // AI SDK v6 uses `input` (not `args`) for tool call arguments
152
166
  const hadExcludedContent = (result.toolCalls as any[])?.some(
@@ -164,6 +178,9 @@ export async function* runAgentLoop(params: {
164
178
  // Create dynamic tools (browse adjacent pages, load specific pages)
165
179
  const newDynamic = await createDynamicTools(stepChunks as ChunkResult[], hadExcludedContent);
166
180
  Object.assign(dynamicTools, newDynamic);
181
+ if (sessionId && Object.keys(newDynamic).length > 0) {
182
+ registerSessionTools(sessionId, newDynamic);
183
+ }
167
184
 
168
185
  // If relevant content was found but fewer than 5 chunks, withhold finish_retrieval
169
186
  // on the next step to force depth exploration via dynamic tools.
@@ -175,9 +192,14 @@ export async function* runAgentLoop(params: {
175
192
  Object.keys(newDynamic).length > 0 &&
176
193
  step < strategy.stepBudget - 2;
177
194
 
178
- // Track which suggested contexts have been searched this step
195
+ // Track which suggested contexts have been searched this step.
196
+ // search_content and save_search_results now use knowledge_base_id (singular);
197
+ // count_items_or_chunks and search_items_by_name still use knowledge_base_ids (plural array).
179
198
  for (const tc of (result.toolCalls as any[]) ?? []) {
180
199
  if (SEARCH_TOOL_NAMES.has(tc.toolName)) {
200
+ if (tc.input?.knowledge_base_id) {
201
+ searchedContextIds.add(tc.input.knowledge_base_id);
202
+ }
181
203
  for (const id of (tc.input?.knowledge_base_ids ?? [])) {
182
204
  searchedContextIds.add(id);
183
205
  }
@@ -217,11 +239,35 @@ export async function* runAgentLoop(params: {
217
239
  output: stepChunks,
218
240
  })) ?? [],
219
241
  });
220
- output.chunks.push(...stepChunks);
242
+ // Deduplicate against chunks already accumulated from prior steps
243
+ const existingChunkIds = new Set(output.chunks.map((c) => c.chunk_id).filter(Boolean));
244
+ output.chunks.push(...stepChunks.filter((c) => !c.chunk_id || !existingChunkIds.has(c.chunk_id)));
221
245
  output.usage.push(result.usage);
222
246
 
223
247
  onStepComplete?.(stepRecord);
224
248
 
249
+ if (onTrajectoryStep) {
250
+ const toolResultMap = new Map<string, any>();
251
+ for (const tr of (result.toolResults as any[]) ?? []) {
252
+ toolResultMap.set(tr.toolCallId, tr.output ?? tr.result);
253
+ }
254
+ onTrajectoryStep({
255
+ stepNumber: step + 1,
256
+ systemPrompt: stepSystemPrompt,
257
+ text: result.text ?? "",
258
+ toolCalls:
259
+ (result.toolCalls as any[])?.map((tc) => ({
260
+ name: tc.toolName,
261
+ id: tc.toolCallId,
262
+ input: tc.input,
263
+ output: toolResultMap.get(tc.toolCallId),
264
+ })) ?? [],
265
+ chunks: stepChunks,
266
+ dynamicToolsCreated: Object.keys(newDynamic),
267
+ tokens: result.usage?.totalTokens ?? 0,
268
+ });
269
+ }
270
+
225
271
  yield { ...output };
226
272
 
227
273
  // Stop if the model called finish_retrieval AND no forced continuation is needed
@@ -3,6 +3,7 @@ import type { LanguageModel } from "ai";
3
3
  import { z } from "zod";
4
4
  import type { ExuluContext } from "@SRC/exulu/context";
5
5
  import type { ClassificationResult, ContextSample } from "./types";
6
+ import { withRetry } from "@SRC/utils/with-retry";
6
7
 
7
8
  /**
8
9
  * Classifies a query into one of four types and identifies which contexts are
@@ -27,47 +28,51 @@ export async function classifyQuery(
27
28
  })
28
29
  .join("\n\n");
29
30
 
30
- const result = await generateText({
31
- model,
32
- temperature: 0,
33
- output: Output.object({
34
- schema: z.object({
35
- queryType: z
36
- .enum(["aggregate", "list", "targeted", "exploratory"])
37
- .describe(
38
- "aggregate: ONLY use when the user explicitly asks to COUNT how many documents/items/tickets exist in the knowledge base (e.g. 'how many documents about X?', 'total number of tickets'). NEVER use for: real-world statistics stored in a document, intent statements, how-to questions, error/fault descriptions, configuration questions, or any query that does not explicitly ask for a count of knowledge base entries. When in doubt, choose targeted. " +
31
+ const result: ClassificationResult = await withRetry(async () => {
32
+ const result = await generateText({
33
+ model,
34
+ temperature: 0,
35
+ output: Output.object({
36
+ schema: z.object({
37
+ queryType: z
38
+ .enum(["aggregate", "list", "targeted", "exploratory"])
39
+ .describe(
40
+ "aggregate: ONLY use when the user explicitly asks to COUNT how many documents/items/tickets exist in the knowledge base (e.g. 'how many documents about X?', 'total number of tickets'). NEVER use for: real-world statistics stored in a document, intent statements, how-to questions, error/fault descriptions, configuration questions, or any query that does not explicitly ask for a count of knowledge base entries. When in doubt, choose targeted. " +
39
41
  "list: user wants to enumerate matching items/documents (show me all, list documents about). " +
40
42
  "targeted: use for almost everything — specific fact, answer, configuration, how-to, error/fault, feature/behavior question. Also use for intent statements and short commands describing a desired state (phrases that state what the user wants to do or achieve, even without an explicit question word). Real-world statistics stored in documents also go here. When in doubt, choose targeted over aggregate or exploratory. " +
41
43
  "exploratory: only for broad conceptual questions needing multi-source synthesis (what is the process for Z, explain how X works, general overview of topic Y).",
42
- ),
43
- language: z
44
- .string()
45
- .describe("ISO 639-3 language code of the query (e.g. eng, deu, fra)"),
46
- suggestedContextIds: z
47
- .array(z.string())
48
- .describe(
49
- "IDs of knowledge bases most likely to contain the answer. Return empty array to search all contexts.",
50
- ),
44
+ ),
45
+ language: z
46
+ .string()
47
+ .describe("ISO 639-3 language code of the query (e.g. eng, deu, fra)"),
48
+ suggestedContextIds: z
49
+ .array(z.enum(contexts.map((c) => c.id)))
50
+ .describe(
51
+ "IDs of knowledge bases most likely to contain the answer. Return empty array to search all contexts.",
52
+ ),
53
+ }),
51
54
  }),
52
- }),
53
- toolChoice: "none",
54
- system: `You are a query classifier for a multi-knowledge-base retrieval system.
55
- Classify the query and identify which knowledge bases are most relevant.
55
+ toolChoice: "none",
56
+ system: `You are a query classifier for a multi-knowledge-base retrieval system.
57
+ Classify the query and identify which knowledge bases are most relevant.
58
+
59
+ Available knowledge bases:
60
+ ${contextDescriptions}
61
+
62
+ Guidelines for queryType:
63
+ - Use "aggregate" ONLY when the query contains explicit counting language (e.g., "how many", "count", "total number", "wie viele"). Short statements, commands, or phrases without a question word are NEVER aggregate — classify them as targeted.
64
+ - When in doubt between aggregate and targeted: always choose targeted.
65
+
66
+ Guidelines for suggestedContextIds:
67
+ - Be conservative: only suggest contexts that are genuinely likely to contain the answer.
68
+ Aim for 2–3 focused suggestions rather than listing everything.
69
+ - Use each knowledge base's name and description (shown above) to judge relevance.
70
+ - Return an empty array only if you truly cannot determine which contexts are relevant.`,
71
+ prompt: `Query: ${query}`,
72
+ });
56
73
 
57
- Available knowledge bases:
58
- ${contextDescriptions}
74
+ return result.output as ClassificationResult;
75
+ }, 3)
59
76
 
60
- Guidelines for queryType:
61
- - Use "aggregate" ONLY when the query contains explicit counting language (e.g., "how many", "count", "total number", "wie viele"). Short statements, commands, or phrases without a question word are NEVER aggregate — classify them as targeted.
62
- - When in doubt between aggregate and targeted: always choose targeted.
63
-
64
- Guidelines for suggestedContextIds:
65
- - Be conservative: only suggest contexts that are genuinely likely to contain the answer.
66
- Aim for 2–3 focused suggestions rather than listing everything.
67
- - Use each knowledge base's name and description (shown above) to judge relevance.
68
- - Return an empty array only if you truly cannot determine which contexts are relevant.`,
69
- prompt: `Query: ${query}`,
70
- });
71
-
72
- return result.output as ClassificationResult;
77
+ return result;
73
78
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { createBashTool } from "bash-tool";
3
- import type { LanguageModel } from "ai";
3
+ import type { LanguageModel, Tool } from "ai";
4
4
  import type { ExuluContext } from "@SRC/exulu/context";
5
5
  import type { ExuluReranker } from "@SRC/exulu/reranker";
6
6
  import { ExuluTool } from "@SRC/exulu/tool";
@@ -8,7 +8,7 @@ import type { User } from "@EXULU_TYPES/models/user";
8
8
  import { checkLicense } from "@EE/entitlements";
9
9
  import { ContextSampler } from "./context-sampler";
10
10
  import { classifyQuery } from "./classifier";
11
- import { createRetrievalTools } from "./tools";
11
+ import { createRetrievalTools, parseGlobalItemIds } from "./tools";
12
12
  import { STRATEGIES } from "./strategies";
13
13
  import { runAgentLoop } from "./agent-loop";
14
14
  import { TrajectoryLogger } from "./trajectory";
@@ -26,6 +26,9 @@ async function* executeV3({
26
26
  user,
27
27
  role,
28
28
  customInstructions,
29
+ logTrajectory,
30
+ sessionId,
31
+ preselectedItemIds,
29
32
  }: {
30
33
  query: string;
31
34
  contexts: ExuluContext[];
@@ -34,16 +37,29 @@ async function* executeV3({
34
37
  user?: User;
35
38
  role?: string;
36
39
  customInstructions?: string;
40
+ logTrajectory?: boolean;
41
+ sessionId?: string;
42
+ preselectedItemIds?: string[];
37
43
  }): AsyncGenerator<AgenticRetrievalOutput> {
38
- // ── 1. Sample example records from each context (cached) ──────────────────
44
+ // ── 1. Parse preselected item IDs (global format: "<context_id>/<item_id>")
45
+ const preselectedByContext = preselectedItemIds?.length
46
+ ? parseGlobalItemIds(preselectedItemIds)
47
+ : undefined;
48
+
49
+ // When preselection is active, restrict to only contexts that have selected items
50
+ const activeContexts = preselectedByContext?.size
51
+ ? contexts.filter((c) => preselectedByContext.has(c.id))
52
+ : contexts;
53
+
54
+ // ── 2. Sample example records from each context (cached) ──────────────────
39
55
  console.log("[EXULU] v3 — sampling contexts");
40
- const samples = await sampler.getSamples(contexts, user, role);
56
+ const samples = await sampler.getSamples(activeContexts, user, role);
41
57
 
42
- // ── 2. Classify query (single fast LLM call) ──────────────────────────────
58
+ // ── 3. Classify query (single fast LLM call) ──────────────────────────────
43
59
  console.log("[EXULU] v3 — classifying query");
44
60
  let classification;
45
61
  try {
46
- classification = await classifyQuery(query, contexts, samples, model);
62
+ classification = await classifyQuery(query, activeContexts, samples, model);
47
63
  } catch (err) {
48
64
  console.warn("[EXULU] v3 — classification failed, falling back to exploratory:", err);
49
65
  classification = {
@@ -54,32 +70,39 @@ async function* executeV3({
54
70
  }
55
71
  console.log("[EXULU] v3 — classified as:", classification);
56
72
 
57
- // ── 3. Select strategy ────────────────────────────────────────────────────
73
+ // ── 4. Select strategy ────────────────────────────────────────────────────
58
74
  const strategy = STRATEGIES[classification.queryType];
59
75
 
60
76
  // Build context guidance: the classifier is a priority hint, not a hard filter.
61
77
  // All contexts remain available so the agent can fall back if suggested ones miss.
62
78
  const suggestedIds = classification.suggestedContextIds;
63
- const fallbackIds = contexts
79
+ const fallbackIds = activeContexts
64
80
  .filter((c) => !suggestedIds.includes(c.id))
65
81
  .map((c) => c.id);
66
- const contextGuidance =
82
+ const contextBase =
67
83
  suggestedIds.length > 0
68
84
  ? `Suggested priority contexts: [${suggestedIds.join(", ")}]. Also available: [${fallbackIds.join(", ")}]. Custom instructions may require searching additional or all contexts — follow them.`
69
- : `All contexts available: [${contexts.map((c) => c.id).join(", ")}].`;
85
+ : `All contexts available: [${activeContexts.map((c) => c.id).join(", ")}].`;
86
+
87
+ const preselectedNote = preselectedByContext?.size
88
+ ? `\nSCOPE CONSTRAINT: Retrieval is scoped to preselected items/contexts. Per context: ${[...preselectedByContext.entries()].map(([ctx, ids]) => ids === null ? `${ctx} (full context)` : `${ctx} (${ids.length} item${ids.length === 1 ? "" : "s"})`).join(", ")}. All tools enforce this scope automatically. For full-context entries you may search freely; for item-restricted entries do NOT use search_items_by_name for discovery — go directly to search_content or save_search_results.`
89
+ : "";
70
90
 
71
- // ── 4. Initialize tools ───────────────────────────────────────────────────
91
+ const contextGuidance = contextBase + preselectedNote;
92
+
93
+ // ── 5. Initialize tools ───────────────────────────────────────────────────
72
94
  const bashToolkit = await createBashTool({ files: {} });
73
95
 
74
96
  const retrievalTools = createRetrievalTools({
75
- contexts, // ALL contexts — agent decides which to search based on context guidance
97
+ contexts: activeContexts,
76
98
  user,
77
99
  role,
78
100
  updateVirtualFiles: (files) => bashToolkit.sandbox.writeFiles(files),
101
+ preselectedItemsByContext: preselectedByContext,
79
102
  });
80
103
 
81
104
  // Build the tool set for this strategy
82
- const activeTools: Record<string, any> = {};
105
+ const activeTools: Record<string, Tool> = {};
83
106
  for (const name of strategy.retrieval_tools) {
84
107
  if (name in retrievalTools) {
85
108
  activeTools[name] = retrievalTools[name as keyof typeof retrievalTools];
@@ -89,10 +112,10 @@ async function* executeV3({
89
112
  Object.assign(activeTools, bashToolkit.tools);
90
113
  }
91
114
 
92
- // ── 5. Set up trajectory logging ──────────────────────────────────────────
93
- const trajectory = new TrajectoryLogger(query, classification);
115
+ // ── 6. Set up trajectory logging ──────────────────────────────────────────
116
+ const trajectory = new TrajectoryLogger(query, classification, undefined, preselectedItemIds);
94
117
 
95
- // ── 6. Run agent loop ─────────────────────────────────────────────────────
118
+ // ── 7. Run agent loop ─────────────────────────────────────────────────────
96
119
  let finalOutput: AgenticRetrievalOutput | undefined;
97
120
  let executionError: Error | undefined;
98
121
 
@@ -106,7 +129,9 @@ async function* executeV3({
106
129
  contextGuidance,
107
130
  customInstructions,
108
131
  classification,
132
+ sessionId,
109
133
  onStepComplete: (step) => trajectory.recordStep(step),
134
+ onTrajectoryStep: (data) => trajectory.recordRichStep(data),
110
135
  })) {
111
136
  finalOutput = output;
112
137
  yield output;
@@ -117,7 +142,7 @@ async function* executeV3({
117
142
  throw err;
118
143
  } finally {
119
144
  if (finalOutput) {
120
- const trajectoryFile = await trajectory.finalize(finalOutput, !executionError, executionError);
145
+ const trajectoryFile = await trajectory.finalize(finalOutput, !executionError, executionError, logTrajectory);
121
146
  if (trajectoryFile) {
122
147
  finalOutput.trajectoryFile = trajectoryFile;
123
148
  }
@@ -141,6 +166,7 @@ export function createAgenticRetrievalToolV3({
141
166
  user,
142
167
  role,
143
168
  model,
169
+ preselectedItemIds,
144
170
  }: {
145
171
  contexts: ExuluContext[];
146
172
  rerankers: ExuluReranker[];
@@ -148,6 +174,7 @@ export function createAgenticRetrievalToolV3({
148
174
  role?: string;
149
175
  model?: LanguageModel;
150
176
  instructions?: string;
177
+ preselectedItemIds?: string[];
151
178
  }): ExuluTool | undefined {
152
179
  const license = checkLicense();
153
180
  if (!license["agentic-retrieval"]) {
@@ -177,6 +204,12 @@ export function createAgenticRetrievalToolV3({
177
204
  type: "string",
178
205
  default: "none",
179
206
  },
207
+ {
208
+ name: "managed_context",
209
+ description: "Makes sure the user defines which items from which contexts the agentic retrieval tool will search in",
210
+ type: "boolean",
211
+ default: false,
212
+ },
180
213
  {
181
214
  name: "reasoning_model",
182
215
  description: "By default the agentic retrieval tool uses the model from the agent calling the tool, but you can overwrite this here for the reasoning phase",
@@ -189,6 +222,18 @@ export function createAgenticRetrievalToolV3({
189
222
  type: "string",
190
223
  default: "",
191
224
  },
225
+ {
226
+ name: "require_preselected_contexts",
227
+ description: "Require the user to preselect contexts before executing the tool, meaning the user will be asked to select the contexts they want to search in",
228
+ type: "boolean",
229
+ default: false,
230
+ },
231
+ {
232
+ name: "log_trajectories",
233
+ description: "Save a detailed markdown + JSON log of every retrieval execution to disk. Useful for debugging and evaluation.",
234
+ type: "boolean",
235
+ default: false,
236
+ },
192
237
  ...contexts.map((ctx) => ({
193
238
  name: ctx.id,
194
239
  description: `Enable search in "${ctx.name}". ${ctx.description}`,
@@ -202,15 +247,26 @@ export function createAgenticRetrievalToolV3({
202
247
  .string()
203
248
  .optional()
204
249
  .describe("Additional instructions from the user to guide retrieval"),
250
+ confirmedContextIds: z
251
+ .array(z.string())
252
+ .optional()
253
+ .describe(
254
+ "Knowledge base IDs explicitly confirmed by the user to be used in the retrieval. " +
255
+ "When presen only searches these contexts. "
256
+ )
205
257
  }),
206
258
  execute: async function* ({
207
259
  query,
208
260
  userInstructions,
261
+ confirmedContextIds,
209
262
  toolVariablesConfig,
263
+ sessionID,
210
264
  }: {
211
265
  query: string;
212
266
  userInstructions?: string;
267
+ confirmedContextIds?: string[];
213
268
  toolVariablesConfig?: Record<string, any>;
269
+ sessionID?: string;
214
270
  }) {
215
271
 
216
272
  /* ROADMAP:
@@ -234,14 +290,24 @@ export function createAgenticRetrievalToolV3({
234
290
  } */
235
291
 
236
292
  if (!model) {
237
- throw new Error("Model is required for executing the agentic retrieval tool");
293
+ yield { result: "Model is required for executing the agentic retrieval tool" };
294
+ return;
238
295
  }
296
+
239
297
  let activeContexts = contexts;
240
298
  let configuredReranker: ExuluReranker | undefined;
241
299
  let configInstructions = "";
300
+ let logTrajectory = false;
301
+ let requiresPreselectedContexts = false;
302
+ let managedContextEnabled = false;
242
303
 
243
304
  if (toolVariablesConfig) {
244
305
  configInstructions = toolVariablesConfig["instructions"] ?? "";
306
+ logTrajectory =
307
+ toolVariablesConfig["log_trajectories"] === true ||
308
+ toolVariablesConfig["log_trajectories"] === "true";
309
+
310
+ managedContextEnabled = toolVariablesConfig["managed_context"] === true || toolVariablesConfig["managed_context"] === "true";
245
311
 
246
312
  activeContexts = contexts.filter(
247
313
  (ctx) =>
@@ -251,12 +317,36 @@ export function createAgenticRetrievalToolV3({
251
317
  );
252
318
  if (activeContexts.length === 0) activeContexts = contexts;
253
319
 
320
+ requiresPreselectedContexts = toolVariablesConfig["require_preselected_contexts"] === true || toolVariablesConfig["require_preselected_contexts"] === "true";
321
+
254
322
  const rerankerId = toolVariablesConfig["reranker"];
323
+
255
324
  if (rerankerId && rerankerId !== "none") {
256
325
  configuredReranker = rerankers.find((r) => r.id === rerankerId);
257
326
  }
258
327
  }
259
328
 
329
+ console.log("[EXULU] Managed context enabled:", managedContextEnabled);
330
+ console.log("[EXULU] Preselected item IDs:", preselectedItemIds);
331
+
332
+ if (managedContextEnabled && !preselectedItemIds?.length) {
333
+ console.log("[EXULU] Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool.");
334
+ yield { result: "Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool." };
335
+ return;
336
+ }
337
+
338
+ if (requiresPreselectedContexts && !confirmedContextIds?.length && !preselectedItemIds?.length) {
339
+ console.log("[EXULU] The user must choose between the available contexts before executing the tool. The available contexts are: " + activeContexts.map((c) => c.id).join(", ") + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text.");
340
+ yield { result: "The user must choose between the available contexts before executing the tool, the available contexts are: " + activeContexts.map((c) => c.id).join(", ") + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text." };
341
+ return;
342
+ }
343
+
344
+ if (confirmedContextIds?.length) {
345
+ const confirmed = new Set(confirmedContextIds);
346
+ const filtered = activeContexts.filter((c) => confirmed.has(c.id));
347
+ if (filtered.length > 0) activeContexts = filtered;
348
+ }
349
+
260
350
  const combinedInstructions = [
261
351
  configInstructions ? `Configuration instructions: ${configInstructions}` : "",
262
352
  adminInstructions ? `Admin instructions: ${adminInstructions}` : "",
@@ -273,9 +363,13 @@ export function createAgenticRetrievalToolV3({
273
363
  user,
274
364
  role,
275
365
  customInstructions: combinedInstructions || undefined,
366
+ logTrajectory,
367
+ sessionId: sessionID,
368
+ preselectedItemIds,
276
369
  })) {
277
370
  yield { result: JSON.stringify(output) };
278
371
  }
372
+ return;
279
373
  },
280
374
  });
281
375
  }
@@ -0,0 +1,20 @@
1
+ import type { Tool as AITool } from "ai";
2
+
3
+ // Persists dynamic tools (get_more_content_from_X, get_X_page_N_content) created
4
+ // during an agentic retrieval run, keyed by session ID. This lets the outer chat
5
+ // agent call them directly on follow-up questions without re-running retrieval.
6
+ const registry = new Map<string, Map<string, AITool>>();
7
+
8
+ export function registerSessionTools(sessionId: string, tools: Record<string, AITool>): void {
9
+ const existing = registry.get(sessionId) ?? new Map();
10
+ for (const [name, toolDef] of Object.entries(tools)) {
11
+ existing.set(name, toolDef);
12
+ }
13
+ registry.set(sessionId, existing);
14
+ }
15
+
16
+ export function getSessionTools(sessionId: string): Record<string, AITool> {
17
+ const toolMap = registry.get(sessionId);
18
+ if (!toolMap || toolMap.size === 0) return {};
19
+ return Object.fromEntries(toolMap.entries());
20
+ }
@@ -25,9 +25,10 @@ another component will do that based on what you retrieve.
25
25
  Always respond in the SAME LANGUAGE as the user's query.
26
26
  Always write search queries in the SAME LANGUAGE as the user's query — do NOT translate to English.
27
27
 
28
- SEARCH APPROACH — go wide first, then deep:
29
- 1. First step: search broadly across all sources the system instructions indicate — do NOT
30
- pre-filter to a single context on step 1.
28
+ SEARCH APPROACH — one knowledge base at a time, then go deep:
29
+ 1. search_content and save_search_results accept ONE knowledge base per call. Make a separate
30
+ call for each knowledge base you need to cover never skip one. Search all relevant
31
+ knowledge bases before concluding, even if the first one already returned good results.
31
32
  2. After finding a relevant document, use get_more_content_from_{item} dynamic tools to load
32
33
  additional pages/sections. The specific answer is often NOT in the first retrieved chunk —
33
34
  always explore adjacent content before concluding.
@@ -44,9 +45,8 @@ export const AGGREGATE_INSTRUCTIONS = `
44
45
  ${BASE_INSTRUCTIONS}
45
46
 
46
47
  STRATEGY: This is a COUNTING or AGGREGATION query.
47
- - Use count_items_or_chunks exclusively
48
+ - Use count_items_or_chunks exclusively — it accepts multiple knowledge bases in one call for efficiency
48
49
  - Do NOT use search_content — it loads unnecessary data
49
- - Search ALL contexts in parallel in a single tool call
50
50
  - Return immediately after counting — one step is sufficient
51
51
  - If the count needs a content filter, use content_query parameter
52
52
  `.trim();
@@ -81,9 +81,23 @@ Search language:
81
81
  - Always write search queries in the SAME LANGUAGE as the user's query.
82
82
  - Do NOT translate the query to English — the documents are indexed in their original language.
83
83
 
84
- Step 1 — wide hybrid search (includeContent: true, limit 10):
85
- - Search broadly across all sources per the system instructions — do not limit to 1 context.
86
- - This gives you the best results from every relevant source at once.
84
+ Step 1 — match the opening move to what the query actually needs:
85
+
86
+ Query references a SPECIFIC NAMED DOCUMENT (product manual, titled report, named file):
87
+ → ALWAYS start with search_items_by_name — searches document name/title directly
88
+ → Only proceed to load content if the document is found
89
+
90
+ Query asks WHETHER a topic EXISTS or WHAT documents cover a topic (no specific title given):
91
+ → search_content with includeContent: false
92
+ → Returns matching document names without loading chunk text — efficient and precise
93
+ → Load content with dynamic get_{item}_page_{n}_content tools only if needed in step 2
94
+
95
+ Query asks for CONTENT itself (procedures, parameters, explanations, how-to):
96
+ → search_content with includeContent: true, limit 20, searchMethod: "hybrid"
97
+ → Make one call per knowledge base — search each separately before concluding
98
+
99
+ Query provides an EXACT TERM (error code, product code, ID, parameter name):
100
+ → search_content with searchMethod: "keyword"
87
101
 
88
102
  Step 2+ — depth and follow-up:
89
103
  - For any relevant document found with fewer than 5 chunks, use get_more_content_from_{item}
@@ -93,19 +107,9 @@ Step 2+ — depth and follow-up:
93
107
  - Try alternative phrasings if the first query doesn't surface the right answer.
94
108
 
95
109
  Product-specific filtering:
96
- - When the query mentions a specific product (e.g., "FST-3", "ECO"), you MAY use
97
- item_names: ["<product>"] on a follow-up search to narrow results — but only after an initial
110
+ - When the query mentions a specific named entity (product, model, version), you MAY use
111
+ item_names: ["<entity>"] on a follow-up search to narrow results — but only after an initial
98
112
  wide search. Never start with item_names filtering alone.
99
-
100
- Two-step approach — use includeContent: false first:
101
- - Only when you expect many results (>20) and need to identify the right document first.
102
- - Step 1: search_content with includeContent: false → see which documents/chunks match.
103
- - Step 2: use dynamic get_{item}_page_{n}_content tools to load specific pages.
104
-
105
- Search method selection:
106
- - hybrid (default): best for most queries
107
- - keyword: exact product codes, document IDs, error codes
108
- - semantic: conceptual questions, synonyms, paraphrasing
109
113
  `.trim();
110
114
 
111
115
  export const EXPLORATORY_INSTRUCTIONS = `
@@ -114,13 +118,13 @@ ${BASE_INSTRUCTIONS}
114
118
  STRATEGY: This is an EXPLORATORY query — general question requiring broad search.
115
119
 
116
120
  Recommended approach:
117
- 1. Start with a wide hybrid search across all relevant contexts (includeContent: true, limit: 10)
121
+ 1. Search each relevant knowledge base separately with hybrid search (includeContent: true, limit: 20) — one call per knowledge base
118
122
  2. If results are insufficient: try alternative search terms or different search method
119
123
  3. Use save_search_results + bash grep when you need to scan many results without context bloat
120
124
  4. Use dynamic get_more_content_from_{item} tools to read adjacent pages when a relevant item is found
121
125
 
122
126
  When to declare done:
123
- - You have retrieved chunks that cover the key aspects of the query
127
+ - You have retrieved chunks that cover the key aspects of the query from all relevant knowledge bases
124
128
  - OR you have tried 3+ different search strategies and found nothing relevant
125
129
 
126
130
  Do NOT use count_items_or_chunks for exploratory queries — the user wants content, not statistics.
@@ -140,7 +144,7 @@ export const STRATEGIES: Record<QueryType, StrategyConfig> = {
140
144
  },
141
145
  list: {
142
146
  queryType: "list",
143
- stepBudget: 2,
147
+ stepBudget: 3,
144
148
  retrieval_tools: ["count_items_or_chunks", "search_items_by_name", "search_content"],
145
149
  include_bash: false,
146
150
  instructions: LIST_INSTRUCTIONS,
@@ -154,7 +158,7 @@ export const STRATEGIES: Record<QueryType, StrategyConfig> = {
154
158
  },
155
159
  exploratory: {
156
160
  queryType: "exploratory",
157
- stepBudget: 4,
161
+ stepBudget: 5,
158
162
  retrieval_tools: [
159
163
  "count_items_or_chunks",
160
164
  "search_items_by_name",