@exulu/backend 1.53.1 → 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.
@@ -0,0 +1,288 @@
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, ClassificationResult } from "./types";
7
+ import type { StrategyConfig } from "./strategies";
8
+ import { createDynamicTools } from "./dynamic-tools";
9
+ import { registerSessionTools } from "./session-tools-registry";
10
+ import type { TrajectoryStepData } from "./trajectory";
11
+
12
+ const FINISH_TOOL_NAME = "finish_retrieval";
13
+
14
+ const finishRetrievalTool = tool({
15
+ description:
16
+ "Call this tool when you have retrieved sufficient information and no further searches are needed. " +
17
+ "You MUST call this tool to signal that retrieval is complete — do not write a text conclusion.",
18
+ inputSchema: z.object({
19
+ reasoning: z.string().describe("One sentence explaining why retrieval is complete"),
20
+ }),
21
+ execute: async ({ reasoning }) => JSON.stringify({ finished: true, reasoning }),
22
+ });
23
+
24
+ function extractChunksFromToolResults(toolResults: any[]): ChunkResult[] {
25
+ const chunks: ChunkResult[] = [];
26
+ for (const result of toolResults ?? []) {
27
+ // AI SDK v6 uses `output` (not `result`) for tool result values
28
+ const rawOutput = result.output ?? result.result;
29
+ let parsed: any;
30
+ try {
31
+ parsed = typeof rawOutput === "string" ? JSON.parse(rawOutput) : rawOutput;
32
+ } catch {
33
+ continue;
34
+ }
35
+
36
+ if (Array.isArray(parsed)) {
37
+ for (const item of parsed) {
38
+ if (item?.item_id && item?.context) {
39
+ chunks.push({
40
+ item_name: item.item_name,
41
+ item_id: item.item_id,
42
+ context: item.context?.id ?? item.context,
43
+ chunk_id: item.chunk_id,
44
+ chunk_index: item.chunk_index,
45
+ chunk_content: item.chunk_content,
46
+ metadata: item.metadata,
47
+ });
48
+ }
49
+ }
50
+ }
51
+ }
52
+ return chunks;
53
+ }
54
+
55
+ /**
56
+ * Core agent loop: one generateText call per step.
57
+ *
58
+ * Unlike v2 (which split each step into a reasoning call + a separate tool
59
+ * execution call), here a single call with toolChoice: "auto" lets the model
60
+ * reason and call tools in one pass. The model sees tool results from the
61
+ * previous step via the conversation history (messages array).
62
+ *
63
+ * The loop stops when:
64
+ * - The model makes no tool calls (it's satisfied), OR
65
+ * - The strategy's stepBudget is exhausted
66
+ */
67
+ export async function* runAgentLoop(params: {
68
+ query: string;
69
+ strategy: StrategyConfig;
70
+ tools: Record<string, AITool>;
71
+ model: LanguageModel;
72
+ reranker?: ExuluReranker;
73
+ contextGuidance?: string;
74
+ customInstructions?: string;
75
+ classification: ClassificationResult;
76
+ sessionId?: string;
77
+ onStepComplete?: (step: AgenticRetrievalOutput["steps"][0]) => void;
78
+ onTrajectoryStep?: (data: TrajectoryStepData) => void;
79
+ }): AsyncGenerator<AgenticRetrievalOutput> {
80
+ const { query, strategy, tools, model, reranker, contextGuidance, customInstructions, sessionId, onStepComplete, onTrajectoryStep } = params;
81
+
82
+ const output: AgenticRetrievalOutput = {
83
+ steps: [],
84
+ reasoning: [],
85
+ chunks: [],
86
+ usage: [],
87
+ totalTokens: 0,
88
+ };
89
+
90
+ const messages: ModelMessage[] = [{ role: "user", content: query }];
91
+ let dynamicTools: Record<string, AITool> = {};
92
+ let forceDepthExploration = false;
93
+ let forceContextCoverage = false;
94
+
95
+ // Track which suggested contexts have been searched to enforce coverage
96
+ const suggestedContextIds = params.classification.suggestedContextIds ?? [];
97
+ const searchedContextIds = new Set<string>();
98
+
99
+ const baseSystemPrompt = [
100
+ strategy.instructions,
101
+ contextGuidance ? `\nCONTEXT GUIDANCE:\n${contextGuidance}` : "",
102
+ customInstructions ? `\nCUSTOM INSTRUCTIONS (override context guidance above where they conflict):\n${customInstructions}` : "",
103
+ ]
104
+ .filter(Boolean)
105
+ .join("\n");
106
+
107
+ const SEARCH_TOOL_NAMES = new Set([
108
+ "search_content",
109
+ "save_search_results",
110
+ "count_items_or_chunks",
111
+ "search_items_by_name",
112
+ ]);
113
+
114
+ for (let step = 0; step < strategy.stepBudget; step++) {
115
+ console.log(`[EXULU] v3 agent loop — step ${step + 1}/${strategy.stepBudget}`);
116
+
117
+ // Build dynamic system prompt: add unsearched-context note after the first step
118
+ const unsearchedNow = suggestedContextIds.filter((id) => !searchedContextIds.has(id));
119
+ const contextCoverageNote =
120
+ unsearchedNow.length > 0 && step > 0
121
+ ? `\n\n⚠️ MANDATORY: The following suggested contexts have NOT been searched yet: [${unsearchedNow.join(", ")}]. You MUST include ALL of them in your next search call. Note: support/ticket contexts use document names like "Ticket #XXXX" — do NOT use item_names when searching them.`
122
+ : "";
123
+ const stepSystemPrompt = baseSystemPrompt + contextCoverageNote;
124
+
125
+ let result: Awaited<ReturnType<typeof generateText>>;
126
+ try {
127
+ const stepTools = forceDepthExploration || forceContextCoverage
128
+ ? { ...tools, ...dynamicTools } // finish_retrieval withheld — model must search/explore more
129
+ : { ...tools, ...dynamicTools, [FINISH_TOOL_NAME]: finishRetrievalTool };
130
+
131
+ result = await withRetry(() =>
132
+ generateText({
133
+ model,
134
+ temperature: 0,
135
+ system: stepSystemPrompt,
136
+ messages,
137
+ tools: stepTools,
138
+ toolChoice: "required",
139
+ stopWhen: stepCountIs(1),
140
+ }),
141
+ );
142
+ } catch (err) {
143
+ console.error("[EXULU] v3 generateText failed:", err);
144
+ throw err;
145
+ }
146
+
147
+ // Carry conversation forward: assistant message + tool results go into history
148
+ // so the model sees them on the next iteration.
149
+ messages.push(...(result.response.messages as ModelMessage[]));
150
+
151
+ // Extract chunks from tool results
152
+ let stepChunks: any[] = extractChunksFromToolResults(result.toolResults as any[]);
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
+
164
+ // Check if any search_content call excluded content (triggers page-load dynamic tools)
165
+ // AI SDK v6 uses `input` (not `args`) for tool call arguments
166
+ const hadExcludedContent = (result.toolCalls as any[])?.some(
167
+ (tc) =>
168
+ (tc.toolName === "search_content" && tc.input?.includeContent === false) ||
169
+ tc.toolName === "search_items_by_name",
170
+ );
171
+
172
+ // Rerank if reranker is available
173
+ if (reranker && stepChunks.length > 0) {
174
+ console.log(`[EXULU] v3 reranking ${stepChunks.length} chunks with ${reranker.name}`);
175
+ stepChunks = await reranker.run(query, stepChunks as any);
176
+ }
177
+
178
+ // Create dynamic tools (browse adjacent pages, load specific pages)
179
+ const newDynamic = await createDynamicTools(stepChunks as ChunkResult[], hadExcludedContent);
180
+ Object.assign(dynamicTools, newDynamic);
181
+ if (sessionId && Object.keys(newDynamic).length > 0) {
182
+ registerSessionTools(sessionId, newDynamic);
183
+ }
184
+
185
+ // If relevant content was found but fewer than 5 chunks, withhold finish_retrieval
186
+ // on the next step to force depth exploration via dynamic tools.
187
+ // Only applies when dynamic tools exist and there's budget remaining for both
188
+ // a depth step and a finish step.
189
+ forceDepthExploration =
190
+ stepChunks.length > 0 &&
191
+ stepChunks.length < 5 &&
192
+ Object.keys(newDynamic).length > 0 &&
193
+ step < strategy.stepBudget - 2;
194
+
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).
198
+ for (const tc of (result.toolCalls as any[]) ?? []) {
199
+ if (SEARCH_TOOL_NAMES.has(tc.toolName)) {
200
+ if (tc.input?.knowledge_base_id) {
201
+ searchedContextIds.add(tc.input.knowledge_base_id);
202
+ }
203
+ for (const id of (tc.input?.knowledge_base_ids ?? [])) {
204
+ searchedContextIds.add(id);
205
+ }
206
+ }
207
+ }
208
+
209
+ // Withhold finish_retrieval on the next step if suggested contexts remain unsearched
210
+ const unsearchedAfterStep = suggestedContextIds.filter((id) => !searchedContextIds.has(id));
211
+ forceContextCoverage = unsearchedAfterStep.length > 0 && step < strategy.stepBudget - 1;
212
+ if (forceContextCoverage) {
213
+ console.log(
214
+ `[EXULU] v3 forceContextCoverage — unsearched suggested: [${unsearchedAfterStep.join(", ")}]`,
215
+ );
216
+ }
217
+
218
+ // Record step
219
+ const stepRecord = {
220
+ stepNumber: step + 1,
221
+ text: result.text ?? "",
222
+ toolCalls: (result.toolCalls as any[])?.map((tc) => ({
223
+ name: tc.toolName,
224
+ id: tc.toolCallId,
225
+ input: tc.input,
226
+ })) ?? [],
227
+ chunks: stepChunks,
228
+ dynamicToolsCreated: Object.keys(newDynamic),
229
+ tokens: result.usage?.totalTokens ?? 0,
230
+ };
231
+
232
+ output.steps.push(stepRecord);
233
+ output.reasoning.push({
234
+ text: result.text ?? "",
235
+ tools: (result.toolCalls as any[])?.map((tc) => ({
236
+ name: tc.toolName,
237
+ id: tc.toolCallId,
238
+ input: tc.input,
239
+ output: stepChunks,
240
+ })) ?? [],
241
+ });
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)));
245
+ output.usage.push(result.usage);
246
+
247
+ onStepComplete?.(stepRecord);
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
+
271
+ yield { ...output };
272
+
273
+ // Stop if the model called finish_retrieval AND no forced continuation is needed
274
+ const calledFinish = (result.toolCalls as any[])?.some(
275
+ (tc) => tc.toolName === FINISH_TOOL_NAME,
276
+ );
277
+ if (calledFinish && !forceContextCoverage) {
278
+ console.log(`[EXULU] v3 model called finish_retrieval after step ${step + 1}`);
279
+ break;
280
+ } else if (calledFinish && forceContextCoverage) {
281
+ console.log(
282
+ `[EXULU] v3 model called finish_retrieval but overriding — unsearched suggested contexts remain`,
283
+ );
284
+ }
285
+ }
286
+
287
+ output.totalTokens = output.usage.reduce((sum, u) => sum + (u?.totalTokens ?? 0), 0);
288
+ }
@@ -0,0 +1,78 @@
1
+ import { generateText, Output } from "ai";
2
+ import type { LanguageModel } from "ai";
3
+ import { z } from "zod";
4
+ import type { ExuluContext } from "@SRC/exulu/context";
5
+ import type { ClassificationResult, ContextSample } from "./types";
6
+ import { withRetry } from "@SRC/utils/with-retry";
7
+
8
+ /**
9
+ * Classifies a query into one of four types and identifies which contexts are
10
+ * most relevant. This is a single fast LLM call that runs before the main
11
+ * agent loop, enabling strategy-based routing.
12
+ */
13
+ export async function classifyQuery(
14
+ query: string,
15
+ contexts: ExuluContext[],
16
+ samples: ContextSample[],
17
+ model: LanguageModel,
18
+ ): Promise<ClassificationResult> {
19
+ const contextDescriptions = contexts
20
+ .map((ctx) => {
21
+ const sample = samples.find((s) => s.contextId === ctx.id);
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}`;
28
+ })
29
+ .join("\n\n");
30
+
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. " +
41
+ "list: user wants to enumerate matching items/documents (show me all, list documents about). " +
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. " +
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).",
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
+ }),
54
+ }),
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
+ });
73
+
74
+ return result.output as ClassificationResult;
75
+ }, 3)
76
+
77
+ return result;
78
+ }
@@ -0,0 +1,70 @@
1
+ import { ExuluContext, getTableName } from "@SRC/exulu/context";
2
+ import { postgresClient } from "@SRC/postgres/client";
3
+ import { applyAccessControl } from "@SRC/graphql/utilities/access-control";
4
+ import { convertContextToTableDefinition } from "@SRC/graphql/utilities/convert-context-to-table-definition";
5
+ import type { User } from "@EXULU_TYPES/models/user";
6
+ import type { ContextSample } from "./types";
7
+
8
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
9
+
10
+ /**
11
+ * Pulls 1–2 example item records per context at agent initialization and caches
12
+ * them in memory. These samples are injected into the classifier prompt so the
13
+ * model understands what data is actually stored (not just field names).
14
+ */
15
+ export class ContextSampler {
16
+ private cache = new Map<string, ContextSample>();
17
+
18
+ async getSamples(
19
+ contexts: ExuluContext[],
20
+ user?: User,
21
+ role?: string,
22
+ ): Promise<ContextSample[]> {
23
+ return Promise.all(contexts.map((ctx) => this.getSample(ctx, user, role)));
24
+ }
25
+
26
+ private async getSample(
27
+ ctx: ExuluContext,
28
+ user?: User,
29
+ role?: string,
30
+ ): Promise<ContextSample> {
31
+ const cached = this.cache.get(ctx.id);
32
+ if (cached && Date.now() - cached.sampledAt < CACHE_TTL_MS) {
33
+ return cached;
34
+ }
35
+
36
+ const { db } = await postgresClient();
37
+ const tableName = getTableName(ctx.id);
38
+ const tableDefinition = convertContextToTableDefinition(ctx);
39
+
40
+ const customFieldNames = ctx.fields.map((f) => f.name);
41
+ const selectFields = ["id", "name", "external_id", ...customFieldNames];
42
+
43
+ let exampleItems: Record<string, any>[] = [];
44
+ try {
45
+ let query = db(tableName).select(selectFields).whereNull("archived").limit(2);
46
+ query = applyAccessControl(tableDefinition, query, user, tableName);
47
+ exampleItems = await query;
48
+ } catch {
49
+ // If table doesn't exist yet or column mismatch, return empty samples
50
+ }
51
+
52
+ const sample: ContextSample = {
53
+ contextId: ctx.id,
54
+ contextName: ctx.name,
55
+ fields: ["name", "external_id", ...customFieldNames],
56
+ exampleItems,
57
+ sampledAt: Date.now(),
58
+ };
59
+
60
+ this.cache.set(ctx.id, sample);
61
+
62
+ // Refresh in background after TTL without blocking the caller
63
+ return sample;
64
+ }
65
+
66
+ /** Evict a context from cache so it's re-sampled on next use */
67
+ invalidate(contextId: string): void {
68
+ this.cache.delete(contextId);
69
+ }
70
+ }
@@ -0,0 +1,115 @@
1
+ import { z } from "zod";
2
+ import { tool } from "ai";
3
+ import type { Tool as AITool } from "ai";
4
+ import { postgresClient } from "@SRC/postgres/client";
5
+ import { getChunksTableName } from "@SRC/exulu/context";
6
+ import { sanitizeToolName } from "@SRC/utils/sanitize-tool-name.ts";
7
+ import type { ChunkResult } from "./types";
8
+
9
+ /**
10
+ * Creates per-chunk navigation tools from the results of a search step.
11
+ *
12
+ * Two types of dynamic tools are created:
13
+ * - get_more_content_from_{item}: Browse adjacent chunks of a multi-chunk item
14
+ * - get_{item}_page_{n}_content: Load the full text of a specific page/chunk
15
+ * (created when includeContent was false in the original search)
16
+ */
17
+ export async function createDynamicTools(
18
+ chunks: ChunkResult[],
19
+ hadExcludedContent: boolean,
20
+ ): Promise<Record<string, AITool>> {
21
+ const { db } = await postgresClient();
22
+ const tools: Record<string, AITool> = {};
23
+ const seenItems = new Set<string>();
24
+
25
+ for (const chunk of chunks) {
26
+ if (!chunk.item_id || !chunk.context) continue;
27
+
28
+ // ── get_more_content_from_{item} ──────────────────────────
29
+ const browseToolName = sanitizeToolName(`get_more_content_from_${chunk.item_name}`);
30
+ if (!seenItems.has(chunk.item_id) && !tools[browseToolName]) {
31
+ seenItems.add(chunk.item_id);
32
+ const chunksTable = getChunksTableName(chunk.context);
33
+
34
+ try {
35
+ const countResult = await db(chunksTable)
36
+ .count("id as count")
37
+ .where("source", chunk.item_id)
38
+ .first();
39
+ const total = Number(countResult?.count ?? 0);
40
+
41
+ if (total > 1) {
42
+ const capturedChunk = chunk;
43
+ tools[browseToolName] = tool({
44
+ description: `"${chunk.item_name}" has ${total} pages/chunks. Use this to read a range of pages from it.`,
45
+ inputSchema: z.object({
46
+ from_index: z.number().min(1).default(1).describe("Starting chunk index (1-based)"),
47
+ to_index: z
48
+ .number()
49
+ .max(total)
50
+ .describe(`Ending chunk index (max ${total})`),
51
+ }),
52
+ execute: async ({ from_index, to_index }) => {
53
+ const { db: db2 } = await postgresClient();
54
+ const rows = await db2(chunksTable)
55
+ .select("*")
56
+ .where("source", capturedChunk.item_id)
57
+ .whereBetween("chunk_index", [from_index, to_index])
58
+ .orderBy("chunk_index", "asc");
59
+
60
+ return JSON.stringify(
61
+ rows.map((r) => ({
62
+ chunk_content: r.content,
63
+ chunk_index: r.chunk_index,
64
+ chunk_id: r.id,
65
+ item_id: capturedChunk.item_id,
66
+ item_name: capturedChunk.item_name,
67
+ context: capturedChunk.context,
68
+ })),
69
+ );
70
+ },
71
+ });
72
+ }
73
+ } catch {
74
+ // Skip if table not accessible
75
+ }
76
+ }
77
+
78
+ // ── get_{item}_page_{n}_content ───────────────────────────
79
+ if (hadExcludedContent && chunk.chunk_id) {
80
+ const pageToolName = sanitizeToolName(
81
+ `get_${chunk.item_name}_page_${chunk.chunk_index}_content`,
82
+ );
83
+ if (!tools[pageToolName]) {
84
+ const capturedChunk = chunk;
85
+ tools[pageToolName] = tool({
86
+ description: `Load the full text of page ${chunk.chunk_index} from "${chunk.item_name}"`,
87
+ inputSchema: z.object({
88
+ reasoning: z.string().describe("Why you need this specific page's content"),
89
+ }),
90
+ execute: async () => {
91
+ const { db: db2 } = await postgresClient();
92
+ const chunksTable = getChunksTableName(capturedChunk.context!);
93
+ const rows = await db2(chunksTable)
94
+ .select("*")
95
+ .where("id", capturedChunk.chunk_id!)
96
+ .limit(1);
97
+
98
+ if (!rows[0]) return JSON.stringify({ error: "Chunk not found" });
99
+
100
+ return JSON.stringify({
101
+ chunk_content: rows[0].content,
102
+ chunk_index: rows[0].chunk_index,
103
+ chunk_id: rows[0].id,
104
+ item_id: capturedChunk.item_id,
105
+ item_name: capturedChunk.item_name,
106
+ context: capturedChunk.context ?? "",
107
+ });
108
+ },
109
+ });
110
+ }
111
+ }
112
+ }
113
+
114
+ return tools;
115
+ }