@exulu/backend 1.55.0 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,11 +20,25 @@ export async function classifyQuery(
20
20
  .map((ctx) => {
21
21
  const sample = samples.find((s) => s.contextId === ctx.id);
22
22
  const fieldList = sample?.fields.join(", ") ?? "name, external_id";
23
- const exampleStr =
24
- sample?.exampleItems.length
25
- ? `\n Example records: ${JSON.stringify(sample.exampleItems.slice(0, 2))}`
26
- : "";
27
- return ` - ${ctx.id}: ${ctx.name}\n Description: ${ctx.description}\n Fields: ${fieldList}${exampleStr}`;
23
+ return `
24
+ <context>
25
+ <id>
26
+ ${ctx.id}
27
+ </id>
28
+ <name>
29
+ ${ctx.name}
30
+ </name>
31
+ <description>
32
+ ${ctx.description}
33
+ </description>
34
+ <fields>
35
+ ${fieldList}
36
+ </fields>
37
+ <example_items>
38
+ ${sample?.exampleItems.map((item) => JSON.stringify(item)).join("\n")}
39
+ </example_items>
40
+ </context>
41
+ `;
28
42
  })
29
43
  .join("\n\n");
30
44
 
@@ -3,10 +3,19 @@ import { postgresClient } from "@SRC/postgres/client";
3
3
  import { applyAccessControl } from "@SRC/graphql/utilities/access-control";
4
4
  import { convertContextToTableDefinition } from "@SRC/graphql/utilities/convert-context-to-table-definition";
5
5
  import type { User } from "@EXULU_TYPES/models/user";
6
- import type { ContextSample } from "./types";
7
6
 
8
7
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
9
8
 
9
+ export interface ContextSample {
10
+ contextId: string;
11
+ contextName: string;
12
+ /** All field names available on items (standard + custom) */
13
+ fields: string[];
14
+ /** Up to 2 example item records */
15
+ exampleItems: Array<Record<string, any>>;
16
+ sampledAt: number;
17
+ }
18
+
10
19
  /**
11
20
  * Pulls 1–2 example item records per context at agent initialization and caches
12
21
  * them in memory. These samples are injected into the classifier prompt so the
@@ -6,13 +6,14 @@ import type { ExuluReranker } from "@SRC/exulu/reranker";
6
6
  import { ExuluTool } from "@SRC/exulu/tool";
7
7
  import type { User } from "@EXULU_TYPES/models/user";
8
8
  import { checkLicense } from "@EE/entitlements";
9
- import { ContextSampler } from "./context-sampler";
10
9
  import { classifyQuery } from "./classifier";
11
10
  import { createRetrievalTools, parseGlobalItemIds } from "./tools";
12
11
  import { STRATEGIES } from "./strategies";
13
12
  import { runAgentLoop } from "./agent-loop";
14
13
  import { TrajectoryLogger } from "./trajectory";
15
14
  import type { AgenticRetrievalOutput, QueryType } from "./types";
15
+ import type { ExuluItem } from "@SRC/index";
16
+ import { ContextSampler } from "./context-sampler";
16
17
 
17
18
  // Module-level sampler — shared across all tool instances so the cache is warm
18
19
  // across requests within the same process.
@@ -23,6 +24,7 @@ async function* executeV3({
23
24
  contexts,
24
25
  reranker,
25
26
  model,
27
+ toolVariablesConfig,
26
28
  user,
27
29
  role,
28
30
  customInstructions,
@@ -33,6 +35,7 @@ async function* executeV3({
33
35
  query: string;
34
36
  contexts: ExuluContext[];
35
37
  reranker?: ExuluReranker;
38
+ toolVariablesConfig?: Record<string, any>;
36
39
  model: LanguageModel;
37
40
  user?: User;
38
41
  role?: string;
@@ -72,22 +75,45 @@ async function* executeV3({
72
75
 
73
76
  // ── 4. Select strategy ────────────────────────────────────────────────────
74
77
  const strategy = STRATEGIES[classification.queryType];
75
-
78
+ const contextSpecificInstructions = activeContexts.map(ctx => {
79
+ const instructions = toolVariablesConfig?.[`${ctx.id}_|_instructions`] ?? "";
80
+ if (instructions) {
81
+ return `
82
+ <${ctx.id}>
83
+ ${instructions}
84
+ </${ctx.id}>
85
+ `;
86
+ } else {
87
+ return null;
88
+ }
89
+ }).filter(Boolean).join("\n");
76
90
  // Build context guidance: the classifier is a priority hint, not a hard filter.
77
91
  // All contexts remain available so the agent can fall back if suggested ones miss.
78
92
  const suggestedIds = classification.suggestedContextIds;
79
93
  const fallbackIds = activeContexts
80
94
  .filter((c) => !suggestedIds.includes(c.id))
81
95
  .map((c) => c.id);
82
- const contextBase =
96
+ let contextBase =
83
97
  suggestedIds.length > 0
84
- ? `Suggested priority contexts: [${suggestedIds.join(", ")}]. Also available: [${fallbackIds.join(", ")}]. Custom instructions may require searching additional or all contexts — follow them.`
98
+ ? `
99
+ Suggested priority contexts: [${suggestedIds.join(", ")}].
100
+
101
+ Also available: [${fallbackIds.join(", ")}].
102
+
103
+ Custom instructions may require searching additional or all contexts — follow them.`
85
104
  : `All contexts available: [${activeContexts.map((c) => c.id).join(", ")}].`;
86
105
 
87
106
  const preselectedNote = preselectedByContext?.size
88
107
  ? `\nSCOPE CONSTRAINT: Retrieval is scoped to preselected items/contexts. Per context: ${[...preselectedByContext.entries()].map(([ctx, ids]) => ids === null ? `${ctx} (full context)` : `${ctx} (${ids.length} item${ids.length === 1 ? "" : "s"})`).join(", ")}. All tools enforce this scope automatically. For full-context entries you may search freely; for item-restricted entries do NOT use search_items_by_name for discovery — go directly to search_content or save_search_results.`
89
108
  : "";
90
109
 
110
+ if (contextSpecificInstructions?.length) {
111
+ contextBase += `
112
+ Context specific instructions:
113
+ ${contextSpecificInstructions}
114
+ `;
115
+ }
116
+
91
117
  const contextGuidance = contextBase + preselectedNote;
92
118
 
93
119
  // ── 5. Initialize tools ───────────────────────────────────────────────────
@@ -95,6 +121,7 @@ async function* executeV3({
95
121
 
96
122
  const retrievalTools = createRetrievalTools({
97
123
  contexts: activeContexts,
124
+ toolVariablesConfig,
98
125
  user,
99
126
  role,
100
127
  updateVirtualFiles: (files) => bashToolkit.sandbox.writeFiles(files),
@@ -166,7 +193,8 @@ export function createAgenticRetrievalToolV3({
166
193
  user,
167
194
  role,
168
195
  model,
169
- preselectedItemIds,
196
+ preselected,
197
+ memoryItems
170
198
  }: {
171
199
  contexts: ExuluContext[];
172
200
  rerankers: ExuluReranker[];
@@ -174,7 +202,8 @@ export function createAgenticRetrievalToolV3({
174
202
  role?: string;
175
203
  model?: LanguageModel;
176
204
  instructions?: string;
177
- preselectedItemIds?: string[];
205
+ preselected?: string[];
206
+ memoryItems?: ExuluItem[];
178
207
  }): ExuluTool | undefined {
179
208
  const license = checkLicense();
180
209
  if (!license["agentic-retrieval"]) {
@@ -229,20 +258,51 @@ export function createAgenticRetrievalToolV3({
229
258
  default: false,
230
259
  },
231
260
  {
232
- name: "log_trajectories",
261
+ name: "logging",
233
262
  description: "Save a detailed markdown + JSON log of every retrieval execution to disk. Useful for debugging and evaluation.",
234
263
  type: "boolean",
235
264
  default: false,
236
265
  },
237
266
  ...contexts.map((ctx) => ({
238
- name: ctx.id,
267
+ name: ctx.id + "_|_enabled",
239
268
  description: `Enable search in "${ctx.name}". ${ctx.description}`,
240
269
  type: "boolean" as const,
241
270
  default: true,
271
+ }
272
+ )),
273
+ ...contexts.map((ctx) => ({
274
+ name: `${ctx.id}_|_instructions`,
275
+ description: `Instructions for the retrieval agent about how to search in the ${ctx.name} context`,
276
+ type: "string" as const,
277
+ default: "",
242
278
  })),
279
+ ...contexts.map((ctx) => ({
280
+ name: `${ctx.id}_|_priority`,
281
+ description: `Defines in which order the context should be searched in, the higher the number the higher the priority, if contexts have the same priority they are searched in parallel`,
282
+ type: "number" as const,
283
+ default: 0,
284
+ })),
285
+ ...contexts.map((ctx) => ({
286
+ name: `${ctx.id}_|_max_results`,
287
+ description: `Defines the maximum number of results to return for the ${ctx.name} context`,
288
+ type: "number" as const,
289
+ default: 0,
290
+ })),
291
+ ...contexts.map((ctx) => ({
292
+ name: `${ctx.id}_|_max_steps`,
293
+ description: `Defines the maximum number of steps the agent is allowed to take when searching the ${ctx.name} context`,
294
+ type: "number" as const,
295
+ default: 0,
296
+ })),
297
+ ...contexts.map((ctx) => ({
298
+ name: `${ctx.id}_|_expand_chunks`,
299
+ description: `Defines if the agent automatically retrieves nearby chunks around the matched chunks, usefull if relevant content might be split up`,
300
+ type: "number" as const,
301
+ default: 0,
302
+ }))
243
303
  ],
244
304
  inputSchema: z.object({
245
- query: z.string().describe("The question or query to answer"),
305
+ userQuery: z.string().describe("The original unaltered question from the user"),
246
306
  userInstructions: z
247
307
  .string()
248
308
  .optional()
@@ -256,24 +316,24 @@ export function createAgenticRetrievalToolV3({
256
316
  )
257
317
  }),
258
318
  execute: async function* ({
259
- query,
319
+ userQuery,
260
320
  userInstructions,
261
321
  confirmedContextIds,
262
322
  toolVariablesConfig,
263
323
  sessionID,
264
324
  }: {
265
- query: string;
325
+ userQuery: string;
266
326
  userInstructions?: string;
267
327
  confirmedContextIds?: string[];
268
328
  toolVariablesConfig?: Record<string, any>;
269
329
  sessionID?: string;
270
330
  }) {
271
-
331
+
272
332
  /* ROADMAP:
273
333
  const app = exuluApp.get();
274
334
  let reasoningModel: LanguageModel | undefined = model;
275
335
  let searchModel: LanguageModel | undefined = model;
276
-
336
+
277
337
 
278
338
  if (toolVariablesConfig?.reasoning_model) {
279
339
  reasoningModel = app.provider(toolVariablesConfig.reasoning_model)?.model?.create({});
@@ -281,7 +341,7 @@ export function createAgenticRetrievalToolV3({
281
341
  throw new Error("Reasoning model not found");
282
342
  }
283
343
  }
284
-
344
+
285
345
  if (toolVariablesConfig?.search_model) {
286
346
  searchModel = app.provider(toolVariablesConfig.search_model);
287
347
  if (!searchModel) {
@@ -304,38 +364,38 @@ export function createAgenticRetrievalToolV3({
304
364
  if (toolVariablesConfig) {
305
365
  configInstructions = toolVariablesConfig["instructions"] ?? "";
306
366
  logTrajectory =
307
- toolVariablesConfig["log_trajectories"] === true ||
308
- toolVariablesConfig["log_trajectories"] === "true";
367
+ toolVariablesConfig["logging"] === true ||
368
+ toolVariablesConfig["logging"] === "true";
309
369
 
310
370
  managedContextEnabled = toolVariablesConfig["managed_context"] === true || toolVariablesConfig["managed_context"] === "true";
311
371
 
312
372
  activeContexts = contexts.filter(
313
373
  (ctx) =>
314
- toolVariablesConfig[ctx.id] === true ||
315
- toolVariablesConfig[ctx.id] === "true" ||
316
- toolVariablesConfig[ctx.id] === 1,
374
+ toolVariablesConfig[ctx.id + "_|_enabled"] === true ||
375
+ toolVariablesConfig[ctx.id + "_|_enabled"] === "true" ||
376
+ toolVariablesConfig[ctx.id + "_|_enabled"] === 1,
317
377
  );
318
378
  if (activeContexts.length === 0) activeContexts = contexts;
319
379
 
320
380
  requiresPreselectedContexts = toolVariablesConfig["require_preselected_contexts"] === true || toolVariablesConfig["require_preselected_contexts"] === "true";
321
381
 
322
382
  const rerankerId = toolVariablesConfig["reranker"];
323
-
383
+
324
384
  if (rerankerId && rerankerId !== "none") {
325
385
  configuredReranker = rerankers.find((r) => r.id === rerankerId);
326
386
  }
327
387
  }
328
388
 
329
389
  console.log("[EXULU] Managed context enabled:", managedContextEnabled);
330
- console.log("[EXULU] Preselected item IDs:", preselectedItemIds);
390
+ console.log("[EXULU] Preselected item IDs:", preselected);
331
391
 
332
- if (managedContextEnabled && !preselectedItemIds?.length) {
392
+ if (managedContextEnabled && !preselected?.length) {
333
393
  console.log("[EXULU] Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool.");
334
394
  yield { result: "Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool." };
335
395
  return;
336
396
  }
337
397
 
338
- if (requiresPreselectedContexts && !confirmedContextIds?.length && !preselectedItemIds?.length) {
398
+ if (requiresPreselectedContexts && !confirmedContextIds?.length && !preselected?.length) {
339
399
  console.log("[EXULU] The user must choose between the available contexts before executing the tool. The available contexts are: " + activeContexts.map((c) => c.id).join(", ") + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text.");
340
400
  yield { result: "The user must choose between the available contexts before executing the tool, the available contexts are: " + activeContexts.map((c) => c.id).join(", ") + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text." };
341
401
  return;
@@ -348,24 +408,46 @@ export function createAgenticRetrievalToolV3({
348
408
  }
349
409
 
350
410
  const combinedInstructions = [
351
- configInstructions ? `Configuration instructions: ${configInstructions}` : "",
352
- adminInstructions ? `Admin instructions: ${adminInstructions}` : "",
353
- userInstructions ? `User instructions: ${userInstructions}` : "",
411
+ configInstructions ? `
412
+ Configuration instructions:
413
+ <configuration_instructions>
414
+ ${configInstructions}
415
+ </configuration_instructions>
416
+ ` : "",
417
+ adminInstructions ? `
418
+ Admin instructions:
419
+ <admin_instructions>
420
+ ${adminInstructions}
421
+ </admin_instructions>
422
+ ` : "",
423
+ userInstructions ? `
424
+ User instructions:
425
+ <user_instructions>
426
+ ${userInstructions}
427
+ </user_instructions>
428
+ ` : "",
429
+ memoryItems ? `
430
+ Relevant memories (these are items that the agent has retrieved from the memory context and are relevant to the query):
431
+ <relevant_memories>
432
+ ${memoryItems?.map(item => JSON.stringify(item)).join("\n")}
433
+ </relevant_memories>
434
+ ` : "",
354
435
  ]
355
436
  .filter(Boolean)
356
437
  .join("\n");
357
438
 
358
439
  for await (const output of executeV3({
359
- query,
440
+ query: userQuery,
360
441
  contexts: activeContexts,
361
442
  reranker: configuredReranker,
443
+ toolVariablesConfig,
362
444
  model,
363
445
  user,
364
446
  role,
365
447
  customInstructions: combinedInstructions || undefined,
366
448
  logTrajectory,
367
449
  sessionId: sessionID,
368
- preselectedItemIds,
450
+ preselectedItemIds: preselected,
369
451
  })) {
370
452
  yield { result: JSON.stringify(output) };
371
453
  }
@@ -77,6 +77,7 @@ export function parseGlobalItemIds(globalIds: string[]): Map<string, string[] |
77
77
 
78
78
  export type RetrievalToolParams = {
79
79
  contexts: ExuluContext[];
80
+ toolVariablesConfig?: Record<string, any>;
80
81
  user?: User;
81
82
  role?: string;
82
83
  updateVirtualFiles: (files: Array<{ path: string; content: string }>) => Promise<void>;
@@ -94,7 +95,7 @@ export type RetrievalToolParams = {
94
95
  * and filtered per strategy.
95
96
  */
96
97
  export function createRetrievalTools(params: RetrievalToolParams) {
97
- const { contexts, user, role, updateVirtualFiles, preselectedItemsByContext } = params;
98
+ const { contexts, toolVariablesConfig, user, role, updateVirtualFiles, preselectedItemsByContext } = params;
98
99
  const ctxEnum = buildContextEnum(contexts);
99
100
 
100
101
  // ──────────────────────────────────────────────────────────
@@ -278,7 +279,7 @@ Use includeContent: true when you need the ACTUAL text to answer a question.
278
279
 
279
280
  For listing queries: always start with includeContent: false, then use dynamic tools to fetch specific pages.`,
280
281
  inputSchema: z.object({
281
- query: z.string().describe("Search query about the content you're looking for"),
282
+ userQuery: z.string().describe("The original unaltered question from the user"),
282
283
  knowledge_base_id: z
283
284
  .enum(contexts.map((c) => c.id) as [string, ...string[]])
284
285
  .describe(
@@ -318,7 +319,7 @@ For listing queries: always start with includeContent: false, then use dynamic t
318
319
  .describe("Max chunks with content (max 20). Without content, up to 200 are returned."),
319
320
  }),
320
321
  execute: async ({
321
- query,
322
+ userQuery,
322
323
  knowledge_base_id,
323
324
  keywords,
324
325
  searchMethod,
@@ -329,7 +330,8 @@ For listing queries: always start with includeContent: false, then use dynamic t
329
330
  limit,
330
331
  }) => {
331
332
  const [ctx] = resolveContexts([knowledge_base_id], contexts) as [ExuluContext];
332
- const effectiveLimit = includeContent ? Math.min(limit ?? 20, 20) : Math.min((limit ?? 20) * 20, 400);
333
+ const maxResults = toolVariablesConfig?.[`${ctx.id}_|_max_results`] || 20;
334
+ const effectiveLimit = includeContent ? Math.min(limit ?? maxResults, maxResults) : Math.min((limit ?? maxResults) * maxResults, 400);
333
335
 
334
336
  const itemFilters: SearchFilters = [];
335
337
 
@@ -361,7 +363,7 @@ For listing queries: always start with includeContent: false, then use dynamic t
361
363
  itemFilters.push({ name: { or: item_names.map((n) => ({ contains: n })) } });
362
364
  if (item_external_ids) itemFilters.push({ external_id: { in: item_external_ids } });
363
365
 
364
- const effectiveQuery = query || keywords?.join(" ") || "";
366
+ const effectiveQuery = userQuery || keywords?.join(" ") || "";
365
367
 
366
368
  let method = mapSearchMethod(searchMethod ?? "hybrid");
367
369
 
@@ -372,6 +374,8 @@ For listing queries: always start with includeContent: false, then use dynamic t
372
374
  }
373
375
  }
374
376
 
377
+ const expandChunks = toolVariablesConfig?.[`${ctx.id}_|_expand_chunks`] || 0;
378
+
375
379
  try {
376
380
  const { chunks } = await ctx.search({
377
381
  query: effectiveQuery,
@@ -385,6 +389,10 @@ For listing queries: always start with includeContent: false, then use dynamic t
385
389
  user,
386
390
  role,
387
391
  trigger: "tool",
392
+ expand: expandChunks > 0 ? {
393
+ before: expandChunks,
394
+ after: expandChunks,
395
+ } : undefined,
388
396
  });
389
397
 
390
398
  return JSON.stringify(
@@ -0,0 +1,208 @@
1
+ import { generateText, stepCountIs, tool } from "ai";
2
+ import type { LanguageModel, Tool as AITool, ModelMessage } from "ai";
3
+ import { z } from "zod";
4
+ import { withRetry } from "@SRC/utils/with-retry";
5
+ import type { ExuluReranker } from "@SRC/exulu/reranker";
6
+ import type { AgenticRetrievalOutput, ChunkResult } from "./types";
7
+ import { DEFAULT_MAX_STEPS, type AgenticRetrievalLog, type ContextRetrievalConfig } from ".";
8
+
9
+ const FINISH_TOOL_NAME = "finish_retrieval";
10
+
11
+ const finishRetrievalTool = tool({
12
+ description:
13
+ "Call this tool when you have retrieved sufficient information and no further searches are needed. " +
14
+ "You MUST call this tool to signal that retrieval is complete — do not write a text conclusion.",
15
+ inputSchema: z.object({
16
+ reasoning: z.string().describe("One sentence explaining why retrieval is complete"),
17
+ }),
18
+ execute: async ({ reasoning }) => JSON.stringify({ finished: true, reasoning }),
19
+ });
20
+
21
+ function extractChunksFromToolResults(toolResults: any[]): ChunkResult[] {
22
+ const chunks: ChunkResult[] = [];
23
+ for (const result of toolResults ?? []) {
24
+ // AI SDK v6 uses `output` (not `result`) for tool result values
25
+ const rawOutput = result.output ?? result.result;
26
+ let parsed: any;
27
+ try {
28
+ parsed = typeof rawOutput === "string" ? JSON.parse(rawOutput) : rawOutput;
29
+ } catch {
30
+ continue;
31
+ }
32
+
33
+ if (Array.isArray(parsed)) {
34
+ for (const item of parsed) {
35
+ if (item?.item_id && item?.context) {
36
+ chunks.push({
37
+ item_name: item.item_name,
38
+ item_id: item.item_id,
39
+ context: item.context?.id ?? item.context,
40
+ chunk_id: item.chunk_id,
41
+ chunk_index: item.chunk_index,
42
+ chunk_content: item.chunk_content,
43
+ metadata: item.metadata,
44
+ });
45
+ }
46
+ }
47
+ }
48
+ }
49
+ return chunks;
50
+ }
51
+
52
+ /**
53
+ * Core agent loop: one generateText call per step.
54
+ *
55
+ * Unlike v2 (which split each step into a reasoning call + a separate tool
56
+ * execution call), here a single call with toolChoice: "auto" lets the model
57
+ * reason and call tools in one pass. The model sees tool results from the
58
+ * previous step via the conversation history (messages array).
59
+ *
60
+ * The loop stops when:
61
+ * - The model makes no tool calls (it's satisfied), OR
62
+ * - The strategy's stepBudget is exhausted
63
+ */
64
+ export async function* runAgentLoop(params: {
65
+ config: ContextRetrievalConfig;
66
+ userQuery: string;
67
+ log: AgenticRetrievalLog;
68
+ todos: {
69
+ status: "planned" | "completed";
70
+ description: string;
71
+ current: boolean;
72
+ }[];
73
+ tools: Record<string, AITool>;
74
+ model: LanguageModel;
75
+ reranker?: ExuluReranker;
76
+ sessionID?: string;
77
+ onStepComplete?: (step: AgenticRetrievalOutput["steps"][0]) => void;
78
+ }): AsyncGenerator<AgenticRetrievalOutput> {
79
+ const { userQuery, tools, model, reranker, sessionID, onStepComplete, config, log, todos } = params;
80
+
81
+ const output: AgenticRetrievalOutput = {
82
+ steps: [],
83
+ reasoning: [],
84
+ chunks: [],
85
+ usage: [],
86
+ totalTokens: 0,
87
+ };
88
+
89
+ const messages: ModelMessage[] = [{ role: "user", content: userQuery }];
90
+
91
+ const stepBudget = config.maxSteps || DEFAULT_MAX_STEPS
92
+
93
+ const SYSTEM_PROMPT = `
94
+ You are a helpful assistant that can search the knowledge base and retrieve information.
95
+
96
+ You are searching for information that is relevant to the following question:
97
+ <user_query>
98
+ ${userQuery}
99
+ </user_query>
100
+
101
+ You have the following instructions for this knowledge base:
102
+ <instructions>
103
+ ${config.instructions}
104
+ </instructions>
105
+
106
+ A first search strategy was drafted as a todo list:
107
+ <todo_list>
108
+ ${todos.map((todo, index) => `${index + 1}. ${todo.status} - ${todo.description}`).join("\n")}
109
+ </todo_list>
110
+
111
+ `;
112
+
113
+ for (let step = 0; step < stepBudget; step++) {
114
+
115
+ log.entries.push({
116
+ label: "Agent loop step",
117
+ timestamp: new Date().toISOString(),
118
+ message: `[EXULU] v3 agent loop — step ${step + 1}/${stepBudget}`,
119
+ });
120
+
121
+ let result: Awaited<ReturnType<typeof generateText>>;
122
+
123
+ const stepTools = { ...tools, [FINISH_TOOL_NAME]: finishRetrievalTool };
124
+
125
+ try {
126
+ result = await withRetry(() =>
127
+ generateText({
128
+ model,
129
+ temperature: 0,
130
+ system: SYSTEM_PROMPT,
131
+ messages,
132
+ tools: stepTools,
133
+ toolChoice: "required",
134
+ stopWhen: stepCountIs(1),
135
+ }),
136
+ );
137
+ } catch (err) {
138
+ console.error("[EXULU] v3 generateText failed:", err);
139
+ throw err;
140
+ }
141
+
142
+ // Carry conversation forward: assistant message + tool results go into history
143
+ // so the model sees them on the next iteration.
144
+ messages.push(...(result.response.messages as ModelMessage[]));
145
+
146
+ // Extract chunks from tool results
147
+ let stepChunks: any[] = extractChunksFromToolResults(result.toolResults as any[]);
148
+
149
+ // Deduplicate by chunk_id within this step (parallel tool calls can return the same chunk
150
+ // if the agent searches the same context twice, or the same chunk is indexed in two contexts).
151
+ const seenChunkIds = new Set<string>();
152
+ stepChunks = stepChunks.filter((c) => {
153
+ if (!c.chunk_id) return true;
154
+ if (seenChunkIds.has(c.chunk_id)) return false;
155
+ seenChunkIds.add(c.chunk_id);
156
+ return true;
157
+ });
158
+
159
+ // Record step
160
+ const stepRecord = {
161
+ stepNumber: step + 1,
162
+ text: result.text ?? "",
163
+ toolCalls: (result.toolCalls as any[])?.map((tc) => ({
164
+ name: tc.toolName,
165
+ id: tc.toolCallId,
166
+ input: tc.input,
167
+ })) ?? [],
168
+ chunks: stepChunks,
169
+ tokens: result.usage?.totalTokens ?? 0,
170
+ };
171
+
172
+ log.entries.push({
173
+ label: "Step completed",
174
+ timestamp: new Date().toISOString(),
175
+ message: JSON.stringify(stepRecord),
176
+ });
177
+
178
+ output.steps.push(stepRecord);
179
+ output.reasoning.push({
180
+ text: result.text ?? "",
181
+ tools: (result.toolCalls as any[])?.map((tc) => ({
182
+ name: tc.toolName,
183
+ id: tc.toolCallId,
184
+ input: tc.input,
185
+ output: stepChunks,
186
+ })) ?? [],
187
+ });
188
+ // Deduplicate against chunks already accumulated from prior steps
189
+ const existingChunkIds = new Set(output.chunks.map((c) => c.chunk_id).filter(Boolean));
190
+ output.chunks.push(...stepChunks.filter((c) => !c.chunk_id || !existingChunkIds.has(c.chunk_id)));
191
+ output.usage.push(result.usage);
192
+
193
+ onStepComplete?.(stepRecord);
194
+
195
+ yield { ...output };
196
+
197
+ // Stop if the model called finish_retrieval AND no forced continuation is needed
198
+ const calledFinish = (result.toolCalls as any[])?.some(
199
+ (tc) => tc.toolName === FINISH_TOOL_NAME,
200
+ );
201
+ if (calledFinish) {
202
+ console.log(`[EXULU] v3 model called finish_retrieval after step ${step + 1}`);
203
+ break;
204
+ }
205
+ }
206
+
207
+ output.totalTokens = output.usage.reduce((sum, u) => sum + (u?.totalTokens ?? 0), 0);
208
+ }