@illuma-ai/agents 1.4.0-alpha.1 → 1.4.0-alpha.3

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.
Files changed (53) hide show
  1. package/dist/cjs/main.cjs +44 -22
  2. package/dist/cjs/main.cjs.map +1 -1
  3. package/dist/cjs/tools/artifacts/schema.cjs +63 -0
  4. package/dist/cjs/tools/artifacts/schema.cjs.map +1 -0
  5. package/dist/cjs/tools/artifacts/tool.cjs +213 -0
  6. package/dist/cjs/tools/artifacts/tool.cjs.map +1 -0
  7. package/dist/cjs/tools/fileSearch/formatter.cjs +93 -0
  8. package/dist/cjs/tools/fileSearch/formatter.cjs.map +1 -0
  9. package/dist/cjs/tools/fileSearch/ragClient.cjs +102 -0
  10. package/dist/cjs/tools/fileSearch/ragClient.cjs.map +1 -0
  11. package/dist/cjs/tools/fileSearch/schema.cjs +18 -0
  12. package/dist/cjs/tools/fileSearch/schema.cjs.map +1 -0
  13. package/dist/cjs/tools/fileSearch/tool.cjs +155 -0
  14. package/dist/cjs/tools/fileSearch/tool.cjs.map +1 -0
  15. package/dist/esm/main.mjs +6 -0
  16. package/dist/esm/main.mjs.map +1 -1
  17. package/dist/esm/tools/artifacts/schema.mjs +56 -0
  18. package/dist/esm/tools/artifacts/schema.mjs.map +1 -0
  19. package/dist/esm/tools/artifacts/tool.mjs +207 -0
  20. package/dist/esm/tools/artifacts/tool.mjs.map +1 -0
  21. package/dist/esm/tools/fileSearch/formatter.mjs +90 -0
  22. package/dist/esm/tools/fileSearch/formatter.mjs.map +1 -0
  23. package/dist/esm/tools/fileSearch/ragClient.mjs +98 -0
  24. package/dist/esm/tools/fileSearch/ragClient.mjs.map +1 -0
  25. package/dist/esm/tools/fileSearch/schema.mjs +15 -0
  26. package/dist/esm/tools/fileSearch/schema.mjs.map +1 -0
  27. package/dist/esm/tools/fileSearch/tool.mjs +152 -0
  28. package/dist/esm/tools/fileSearch/tool.mjs.map +1 -0
  29. package/dist/types/index.d.ts +2 -0
  30. package/dist/types/tools/artifacts/index.d.ts +3 -0
  31. package/dist/types/tools/artifacts/schema.d.ts +63 -0
  32. package/dist/types/tools/artifacts/tool.d.ts +16 -0
  33. package/dist/types/tools/artifacts/types.d.ts +127 -0
  34. package/dist/types/tools/fileSearch/formatter.d.ts +25 -0
  35. package/dist/types/tools/fileSearch/index.d.ts +5 -0
  36. package/dist/types/tools/fileSearch/ragClient.d.ts +32 -0
  37. package/dist/types/tools/fileSearch/schema.d.ts +13 -0
  38. package/dist/types/tools/fileSearch/tool.d.ts +18 -0
  39. package/dist/types/tools/fileSearch/types.d.ts +139 -0
  40. package/package.json +1 -1
  41. package/src/index.ts +2 -0
  42. package/src/tools/artifacts/__tests__/tool.test.ts +243 -0
  43. package/src/tools/artifacts/index.ts +33 -0
  44. package/src/tools/artifacts/schema.ts +76 -0
  45. package/src/tools/artifacts/tool.ts +277 -0
  46. package/src/tools/artifacts/types.ts +149 -0
  47. package/src/tools/fileSearch/__tests__/tool.test.ts +261 -0
  48. package/src/tools/fileSearch/formatter.ts +129 -0
  49. package/src/tools/fileSearch/index.ts +23 -0
  50. package/src/tools/fileSearch/ragClient.ts +137 -0
  51. package/src/tools/fileSearch/schema.ts +19 -0
  52. package/src/tools/fileSearch/tool.ts +207 -0
  53. package/src/tools/fileSearch/types.ts +149 -0
@@ -0,0 +1,277 @@
1
+ /**
2
+ * artifact_tool + content_reader library factories.
3
+ *
4
+ * The library owns the LangChain wiring (schema, description, response
5
+ * shape) and the action dispatch; the runtime supplies a handler bundle
6
+ * matching the `ArtifactWriteHandlers` / `ContentReadHandlers` interface.
7
+ *
8
+ * This keeps 800+ LOC of host-specific handler logic (S3 adapters, file
9
+ * model CRUD, syntax checkers, line utils) out of the library while
10
+ * still centralizing the tool surface every runtime shares.
11
+ */
12
+
13
+ import { tool, DynamicStructuredTool } from '@langchain/core/tools';
14
+ import {
15
+ artifactToolSchema,
16
+ contentReaderSchema,
17
+ ARTIFACT_TOOL_NAME,
18
+ CONTENT_READER_NAME,
19
+ } from './schema';
20
+ import type {
21
+ ArtifactToolConfig,
22
+ ContentReaderToolConfig,
23
+ ArtifactToolScope,
24
+ ArtifactToolResult,
25
+ WriteArgs,
26
+ EditArgs,
27
+ VerifyArgs,
28
+ DeleteArgs,
29
+ ReadArgs,
30
+ SearchArgs,
31
+ ListArgs,
32
+ InfoArgs,
33
+ ContentIdResolver,
34
+ } from './types';
35
+
36
+ const DEFAULT_ARTIFACT_DESCRIPTION = `Author content artifacts that render live in the host's preview panel — this tool does NOT produce downloadable files (use execute_code for those).
37
+
38
+ Actions:
39
+ - write: Create a new artifact. Write a COMPLETE file in one call. The \`name\` field MUST include the file extension — it routes the preview (.tsx, .html, .mmd, .svg, .csv, .json, .dot, .md, .drawio).
40
+ - verify: Check an artifact for syntax errors. REQUIRED as the next step after every write/edit on code files — do not render until verify passes.
41
+ - edit: Surgical string replacement — provide old_str (exact match) and new_str. Works on all file types.
42
+ - delete: Remove an artifact and its backing file.
43
+
44
+ Artifacts are persisted by the host. No manual save needed.`;
45
+
46
+ const DEFAULT_CONTENT_READER_DESCRIPTION = `Read and navigate stored content — artifacts authored by artifact_tool, large tool results auto-cached by the host, uploaded file attachments, and code blocks.
47
+
48
+ Read-only surface. Use write/edit tools (artifact_tool, execute_code) to mutate.
49
+
50
+ Actions:
51
+ - read: Return line ranges from a specific content_id.
52
+ - search: Regex search across a specific content_id with paginated matches.
53
+ - list: Enumerate every content entry currently stored.
54
+ - info: Metadata (size, kind, creation time) for a specific content_id.`;
55
+
56
+ /**
57
+ * Optional content_id self-healing — if the runtime supplies a resolver,
58
+ * we pre-resolve the ID on every action that takes one so nicknames
59
+ * (e.g., "Dashboard") map to canonical IDs.
60
+ */
61
+ async function resolveContentIdIfPresent(
62
+ resolver: ContentIdResolver | undefined,
63
+ id: string | undefined,
64
+ scope: ArtifactToolScope,
65
+ logger?: { debug: (msg: string) => void },
66
+ ): Promise<string | undefined> {
67
+ if (!resolver || !id) return id;
68
+ const out = await resolver.resolve(id, scope);
69
+ if (!out) return id;
70
+ if (out.resolvedId !== id) {
71
+ logger?.debug(
72
+ `[artifact] resolved "${id}" → "${out.resolvedId}"${out.resolvedName ? ` ("${out.resolvedName}")` : ''}`,
73
+ );
74
+ }
75
+ return out.resolvedId;
76
+ }
77
+
78
+ // ─── Writer tool (artifact_tool) ─────────────────────────────────────────
79
+
80
+ export function createArtifactTool(
81
+ config: ArtifactToolConfig,
82
+ ): DynamicStructuredTool {
83
+ const { handlers, getScope, resolver, logger, descriptionOverride } = config;
84
+
85
+ return tool(
86
+ async (rawInput, runnableConfig): Promise<ArtifactToolResult> => {
87
+ const scope = getScope(runnableConfig);
88
+ if (!scope) {
89
+ logger?.warn('[artifact_tool] no scope resolved from runnableConfig');
90
+ return ['Error: No conversation context available', {}];
91
+ }
92
+
93
+ const input = rawInput as {
94
+ action: 'write' | 'edit' | 'verify' | 'delete';
95
+ content_id?: string;
96
+ content?: string;
97
+ name?: string;
98
+ old_str?: string;
99
+ new_str?: string;
100
+ replace_all?: boolean;
101
+ };
102
+
103
+ const resolvedContentId = await resolveContentIdIfPresent(
104
+ resolver,
105
+ input.content_id,
106
+ scope,
107
+ logger,
108
+ );
109
+
110
+ const started = Date.now();
111
+ try {
112
+ switch (input.action) {
113
+ case 'write': {
114
+ const args: WriteArgs = {
115
+ action: 'write',
116
+ content_id: resolvedContentId,
117
+ content: input.content ?? '',
118
+ name: input.name,
119
+ };
120
+ if (!args.content) return ['Error: write requires content', {}];
121
+ return await handlers.write(args, scope);
122
+ }
123
+ case 'edit': {
124
+ if (!resolvedContentId) return ['Error: edit requires content_id', {}];
125
+ const args: EditArgs = {
126
+ action: 'edit',
127
+ content_id: resolvedContentId,
128
+ old_str: input.old_str ?? '',
129
+ new_str: input.new_str ?? '',
130
+ replace_all: input.replace_all,
131
+ };
132
+ if (!args.old_str) return ['Error: edit requires old_str', {}];
133
+ return await handlers.edit(args, scope);
134
+ }
135
+ case 'verify': {
136
+ if (!resolvedContentId) return ['Error: verify requires content_id', {}];
137
+ const args: VerifyArgs = {
138
+ action: 'verify',
139
+ content_id: resolvedContentId,
140
+ };
141
+ return await handlers.verify(args, scope);
142
+ }
143
+ case 'delete': {
144
+ if (!resolvedContentId) return ['Error: delete requires content_id', {}];
145
+ const args: DeleteArgs = {
146
+ action: 'delete',
147
+ content_id: resolvedContentId,
148
+ };
149
+ return await handlers.delete(args, scope);
150
+ }
151
+ default:
152
+ return [`Unknown action: ${(input as { action: string }).action}`, {}];
153
+ }
154
+ } catch (err) {
155
+ const e = err instanceof Error ? err : new Error(String(err));
156
+ logger?.error('[artifact_tool] handler threw', {
157
+ action: input.action,
158
+ contentId: resolvedContentId,
159
+ error: e.message,
160
+ elapsed: `${Date.now() - started}ms`,
161
+ });
162
+ return [`Error: ${e.message}`, {}];
163
+ }
164
+ },
165
+ {
166
+ name: ARTIFACT_TOOL_NAME,
167
+ responseFormat: 'content_and_artifact',
168
+ description: descriptionOverride ?? DEFAULT_ARTIFACT_DESCRIPTION,
169
+ schema: artifactToolSchema,
170
+ },
171
+ );
172
+ }
173
+
174
+ // ─── Reader tool (content_reader) ────────────────────────────────────────
175
+
176
+ export function createContentReaderTool(
177
+ config: ContentReaderToolConfig,
178
+ ): DynamicStructuredTool {
179
+ const { handlers, getScope, resolver, logger, descriptionOverride } = config;
180
+
181
+ return tool(
182
+ async (rawInput, runnableConfig): Promise<ArtifactToolResult> => {
183
+ const scope = getScope(runnableConfig);
184
+ if (!scope) {
185
+ logger?.warn('[content_reader] no scope resolved from runnableConfig');
186
+ return ['Error: No conversation context available', {}];
187
+ }
188
+
189
+ const input = rawInput as {
190
+ action: 'read' | 'search' | 'list' | 'info';
191
+ content_id?: string;
192
+ start_line?: number;
193
+ end_line?: number;
194
+ pattern?: string;
195
+ flags?: string;
196
+ context?: number;
197
+ offset?: number;
198
+ limit?: number;
199
+ };
200
+
201
+ const resolvedContentId = await resolveContentIdIfPresent(
202
+ resolver,
203
+ input.content_id,
204
+ scope,
205
+ logger,
206
+ );
207
+
208
+ const started = Date.now();
209
+ try {
210
+ switch (input.action) {
211
+ case 'read': {
212
+ if (!resolvedContentId) return ['Error: read requires content_id', {}];
213
+ const args: ReadArgs = {
214
+ action: 'read',
215
+ content_id: resolvedContentId,
216
+ start_line: input.start_line,
217
+ end_line: input.end_line,
218
+ offset: input.offset,
219
+ limit: input.limit,
220
+ };
221
+ return await handlers.read(args, scope);
222
+ }
223
+ case 'search': {
224
+ if (!resolvedContentId) return ['Error: search requires content_id', {}];
225
+ if (!input.pattern) return ['Error: search requires pattern', {}];
226
+ const args: SearchArgs = {
227
+ action: 'search',
228
+ content_id: resolvedContentId,
229
+ pattern: input.pattern,
230
+ flags: input.flags,
231
+ context: input.context,
232
+ offset: input.offset,
233
+ limit: input.limit,
234
+ };
235
+ return await handlers.search(args, scope);
236
+ }
237
+ case 'list': {
238
+ const args: ListArgs = { action: 'list' };
239
+ return await handlers.list(args, scope);
240
+ }
241
+ case 'info': {
242
+ if (!resolvedContentId) return ['Error: info requires content_id', {}];
243
+ const args: InfoArgs = {
244
+ action: 'info',
245
+ content_id: resolvedContentId,
246
+ };
247
+ return await handlers.info(args, scope);
248
+ }
249
+ default:
250
+ return [`Unknown action: ${(input as { action: string }).action}`, {}];
251
+ }
252
+ } catch (err) {
253
+ const e = err instanceof Error ? err : new Error(String(err));
254
+ logger?.error('[content_reader] handler threw', {
255
+ action: input.action,
256
+ contentId: resolvedContentId,
257
+ error: e.message,
258
+ elapsed: `${Date.now() - started}ms`,
259
+ });
260
+ return [`Error: ${e.message}`, {}];
261
+ }
262
+ },
263
+ {
264
+ name: CONTENT_READER_NAME,
265
+ responseFormat: 'content_and_artifact',
266
+ description: descriptionOverride ?? DEFAULT_CONTENT_READER_DESCRIPTION,
267
+ schema: contentReaderSchema,
268
+ },
269
+ );
270
+ }
271
+
272
+ export {
273
+ ARTIFACT_TOOL_NAME,
274
+ CONTENT_READER_NAME,
275
+ ARTIFACT_WRITE_ACTIONS,
276
+ CONTENT_READ_ACTIONS,
277
+ } from './schema';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Artifact-tool types. The library owns the LangChain wiring, schema, and
3
+ * action dispatch. Runtime supplies an `ArtifactHandlers` bundle — one
4
+ * function per action — so ranger reuses its existing handlers, CLI
5
+ * supplies disk-backed ones, and A2A server supplies buffer-backed ones.
6
+ *
7
+ * Each handler receives:
8
+ * - `args`: the parsed input (typed per action)
9
+ * - `scope`: runtime-resolved scope identity (conversationId + userId
10
+ * for ranger, agent-run-id for CLI, a2a-task-id for A2A)
11
+ *
12
+ * Each handler returns a `[llmText, toolArtifact]` tuple matching
13
+ * LangChain's `content_and_artifact` response format.
14
+ */
15
+
16
+ export interface ArtifactToolScope {
17
+ /**
18
+ * Primary scope — whatever the runtime uses to silo artifacts per
19
+ * session/run/conversation. Ranger uses `conversationId`; CLI uses
20
+ * `agent-run-id` or `agent-id`; A2A uses `a2a-task-id`.
21
+ */
22
+ conversationId: string;
23
+ /** Optional — present when the runtime has an authenticated user. */
24
+ userId?: string;
25
+ /** Free-form extension — runtimes can add their own fields. */
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export type ArtifactToolResult = [llmText: string, artifact?: unknown];
30
+
31
+ // ─── Action arg shapes ────────────────────────────────────────────────────
32
+
33
+ export interface WriteArgs {
34
+ action: 'write';
35
+ content_id?: string;
36
+ content: string;
37
+ name?: string;
38
+ }
39
+
40
+ export interface EditArgs {
41
+ action: 'edit';
42
+ content_id: string;
43
+ old_str: string;
44
+ new_str: string;
45
+ replace_all?: boolean;
46
+ }
47
+
48
+ export interface VerifyArgs {
49
+ action: 'verify';
50
+ content_id: string;
51
+ }
52
+
53
+ export interface DeleteArgs {
54
+ action: 'delete';
55
+ content_id: string;
56
+ }
57
+
58
+ export interface ReadArgs {
59
+ action: 'read';
60
+ content_id: string;
61
+ start_line?: number;
62
+ end_line?: number;
63
+ offset?: number;
64
+ limit?: number;
65
+ }
66
+
67
+ export interface SearchArgs {
68
+ action: 'search';
69
+ content_id: string;
70
+ pattern: string;
71
+ flags?: string;
72
+ context?: number;
73
+ offset?: number;
74
+ limit?: number;
75
+ }
76
+
77
+ export interface ListArgs {
78
+ action: 'list';
79
+ }
80
+
81
+ export interface InfoArgs {
82
+ action: 'info';
83
+ content_id: string;
84
+ }
85
+
86
+ export type ArtifactWriteAction = WriteArgs | EditArgs | VerifyArgs | DeleteArgs;
87
+ export type ArtifactReadAction = ReadArgs | SearchArgs | ListArgs | InfoArgs;
88
+
89
+ // ─── Handler bundles ──────────────────────────────────────────────────────
90
+
91
+ /** Writer-surface handlers — invoked by `artifact_tool`. */
92
+ export interface ArtifactWriteHandlers {
93
+ write(args: WriteArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
94
+ edit(args: EditArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
95
+ verify(args: VerifyArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
96
+ delete(args: DeleteArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
97
+ }
98
+
99
+ /** Reader-surface handlers — invoked by `content_reader`. */
100
+ export interface ContentReadHandlers {
101
+ read(args: ReadArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
102
+ search(args: SearchArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
103
+ list(args: ListArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
104
+ info(args: InfoArgs, scope: ArtifactToolScope): Promise<ArtifactToolResult>;
105
+ }
106
+
107
+ /**
108
+ * Optional content_id self-healing: runtimes that want to let the LLM
109
+ * pass nicknames (e.g., "Dashboard") instead of canonical IDs implement
110
+ * this. Library falls through when unset.
111
+ */
112
+ export interface ContentIdResolver {
113
+ resolve(
114
+ id: string,
115
+ scope: ArtifactToolScope,
116
+ ): Promise<{ resolvedId: string; resolvedName?: string } | null>;
117
+ }
118
+
119
+ export interface ArtifactToolLogger {
120
+ debug: (msg: string, ...args: unknown[]) => void;
121
+ info: (msg: string, ...args: unknown[]) => void;
122
+ warn: (msg: string, ...args: unknown[]) => void;
123
+ error: (msg: string, ...args: unknown[]) => void;
124
+ }
125
+
126
+ export interface ArtifactToolBaseConfig {
127
+ /**
128
+ * Resolves the runtime scope from the LangChain `config` object on each
129
+ * invocation. Ranger pulls `conversationId` + `userId` from request
130
+ * metadata; CLI pulls `agent-run-id` from its runner context.
131
+ */
132
+ getScope: (config: unknown) => ArtifactToolScope | null;
133
+ resolver?: ContentIdResolver;
134
+ logger?: ArtifactToolLogger;
135
+ /**
136
+ * Description override. Host can inject brand-specific guidance
137
+ * (e.g., ranger's HTML branding mandate, TSX style rules). Defaults
138
+ * to a generic description appropriate for any runtime.
139
+ */
140
+ descriptionOverride?: string;
141
+ }
142
+
143
+ export interface ArtifactToolConfig extends ArtifactToolBaseConfig {
144
+ handlers: ArtifactWriteHandlers;
145
+ }
146
+
147
+ export interface ContentReaderToolConfig extends ArtifactToolBaseConfig {
148
+ handlers: ContentReadHandlers;
149
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Unit tests for the file_search library tool.
3
+ *
4
+ * The real RAG backend is mocked through the `RagClient` interface so
5
+ * these tests verify the tool's own logic: target_files filtering,
6
+ * bounded-concurrency querying, per-file error isolation, formatter
7
+ * handoff, and empty/no-file edge cases.
8
+ */
9
+
10
+ import { createFileSearchTool } from '../tool';
11
+ import {
12
+ plainTextFormatter,
13
+ createCitationAnchorFormatter,
14
+ } from '../formatter';
15
+ import type {
16
+ RagClient,
17
+ RagQueryParams,
18
+ RagChunk,
19
+ FileSearchFile,
20
+ } from '../types';
21
+
22
+ // Build a mock RagClient that records every query it receives and returns
23
+ // a deterministic chunk set per file.
24
+ function makeRagClient(
25
+ opts: {
26
+ chunksByFile?: Record<string, RagChunk[]>;
27
+ failFileIds?: Set<string>;
28
+ hang?: boolean;
29
+ } = {}
30
+ ) {
31
+ const calls: RagQueryParams[] = [];
32
+ const client: RagClient = {
33
+ async query(params) {
34
+ calls.push({ ...params });
35
+ if (opts.hang) {
36
+ // Never resolves — used to test timeout handling.
37
+ await new Promise(() => {});
38
+ }
39
+ if (opts.failFileIds?.has(params.file_id)) {
40
+ throw new Error(`simulated failure for ${params.file_id}`);
41
+ }
42
+ return opts.chunksByFile?.[params.file_id] ?? [];
43
+ },
44
+ };
45
+ return { client, calls };
46
+ }
47
+
48
+ const files: FileSearchFile[] = [
49
+ { file_id: 'f-alpha', filename: 'alpha.pdf', isCurrentMessage: true },
50
+ { file_id: 'f-beta', filename: 'beta-report.docx' },
51
+ { file_id: 'f-gamma', filename: 'gamma_notes.txt' },
52
+ ];
53
+
54
+ describe('createFileSearchTool', () => {
55
+ it('returns a no-files message when factory was seeded with zero files', async () => {
56
+ const { client } = makeRagClient();
57
+ const t = createFileSearchTool({ ragClient: client, files: [] });
58
+ const result = await t.invoke({ query: 'anything' });
59
+ // Dual-format tools return [string, artifact?]; LangChain surfaces the
60
+ // string directly in some invocation paths — handle both shapes.
61
+ const text = Array.isArray(result) ? result[0] : result;
62
+ expect(String(text)).toMatch(/no files to search/i);
63
+ });
64
+
65
+ it('queries every file when target_files is omitted', async () => {
66
+ const { client, calls } = makeRagClient({
67
+ chunksByFile: {
68
+ 'f-alpha': [chunk('f-alpha', 'alpha page 1', 0.1)],
69
+ 'f-beta': [chunk('f-beta', 'beta text', 0.2)],
70
+ 'f-gamma': [chunk('f-gamma', 'gamma note', 0.3)],
71
+ },
72
+ });
73
+ const t = createFileSearchTool({ ragClient: client, files });
74
+ await t.invoke({ query: 'test' });
75
+ expect(calls.map((c) => c.file_id).sort()).toEqual([
76
+ 'f-alpha',
77
+ 'f-beta',
78
+ 'f-gamma',
79
+ ]);
80
+ });
81
+
82
+ it('filters files by target_files substring (case-insensitive)', async () => {
83
+ const { client, calls } = makeRagClient({
84
+ chunksByFile: { 'f-beta': [chunk('f-beta', 'beta', 0.2)] },
85
+ });
86
+ const t = createFileSearchTool({ ragClient: client, files });
87
+ await t.invoke({ query: 'q', target_files: ['BETA-REPORT'] });
88
+ expect(calls.map((c) => c.file_id)).toEqual(['f-beta']);
89
+ });
90
+
91
+ it('falls back to all files when target_files matches nothing', async () => {
92
+ const { client, calls } = makeRagClient({
93
+ chunksByFile: {
94
+ 'f-alpha': [chunk('f-alpha', 'a', 0.1)],
95
+ 'f-beta': [chunk('f-beta', 'b', 0.2)],
96
+ 'f-gamma': [chunk('f-gamma', 'c', 0.3)],
97
+ },
98
+ });
99
+ const warn = jest.fn();
100
+ const t = createFileSearchTool({
101
+ ragClient: client,
102
+ files,
103
+ logger: { debug: jest.fn(), info: jest.fn(), warn, error: jest.fn() },
104
+ });
105
+ await t.invoke({ query: 'q', target_files: ['no-such-file'] });
106
+ expect(calls.length).toBe(3);
107
+ expect(warn).toHaveBeenCalled();
108
+ });
109
+
110
+ it('isolates per-file failures so one bad file does not fail the call', async () => {
111
+ const { client, calls } = makeRagClient({
112
+ chunksByFile: {
113
+ 'f-alpha': [chunk('f-alpha', 'alpha good', 0.1)],
114
+ 'f-gamma': [chunk('f-gamma', 'gamma good', 0.2)],
115
+ },
116
+ failFileIds: new Set(['f-beta']),
117
+ });
118
+ const onFileError = jest.fn();
119
+ const t = createFileSearchTool({
120
+ ragClient: client,
121
+ files,
122
+ callbacks: { onFileError },
123
+ logger: silentLogger(),
124
+ });
125
+ const result = await t.invoke({ query: 'q' });
126
+ const text = Array.isArray(result) ? result[0] : result;
127
+ expect(String(text)).toMatch(/alpha good/);
128
+ expect(String(text)).toMatch(/gamma good/);
129
+ expect(calls.length).toBe(3);
130
+ expect(onFileError).toHaveBeenCalledWith(
131
+ expect.objectContaining({ file_id: 'f-beta' }),
132
+ expect.any(Error)
133
+ );
134
+ });
135
+
136
+ it('forwards entity_id, scope, and authHeaders on every query', async () => {
137
+ const { client, calls } = makeRagClient({
138
+ chunksByFile: { 'f-alpha': [chunk('f-alpha', 'x', 0.1)] },
139
+ });
140
+ const t = createFileSearchTool({
141
+ ragClient: client,
142
+ files: [files[0]],
143
+ entity_id: 'tenant-42',
144
+ scope: 'user:alice',
145
+ getAuthHeaders: () => ({ Authorization: 'Bearer TOKEN' }),
146
+ });
147
+ await t.invoke({ query: 'q' });
148
+ expect(calls[0]).toEqual(
149
+ expect.objectContaining({
150
+ file_id: 'f-alpha',
151
+ query: 'q',
152
+ entity_id: 'tenant-42',
153
+ scope: 'user:alice',
154
+ authHeaders: { Authorization: 'Bearer TOKEN' },
155
+ })
156
+ );
157
+ });
158
+
159
+ it('prioritizes current-turn files even when stale files have closer distances', async () => {
160
+ const { client } = makeRagClient({
161
+ chunksByFile: {
162
+ 'f-alpha': [chunk('f-alpha', 'current-turn', 0.5)],
163
+ 'f-beta': [chunk('f-beta', 'older-turn', 0.1)],
164
+ },
165
+ });
166
+ const t = createFileSearchTool({
167
+ ragClient: client,
168
+ files: [files[0], files[1]], // alpha=current, beta=not
169
+ });
170
+ const result = await t.invoke({ query: 'q' });
171
+ const text = Array.isArray(result) ? String(result[0]) : String(result);
172
+ const currentIdx = text.indexOf('current-turn');
173
+ const olderIdx = text.indexOf('older-turn');
174
+ expect(currentIdx).toBeGreaterThanOrEqual(0);
175
+ expect(olderIdx).toBeGreaterThan(currentIdx);
176
+ });
177
+
178
+ it('uses plainTextFormatter by default (no citation anchors)', async () => {
179
+ const { client } = makeRagClient({
180
+ chunksByFile: { 'f-alpha': [chunk('f-alpha', 'hello', 0.1)] },
181
+ });
182
+ const t = createFileSearchTool({
183
+ ragClient: client,
184
+ files: [files[0]],
185
+ });
186
+ const result = await t.invoke({ query: 'q' });
187
+ const text = Array.isArray(result) ? String(result[0]) : String(result);
188
+ expect(text).not.toMatch(/\\ue202turn0file/);
189
+ expect(text).toMatch(/File: alpha\.pdf/);
190
+ });
191
+
192
+ it('uses citation anchors when createCitationAnchorFormatter is supplied', async () => {
193
+ const { client } = makeRagClient({
194
+ chunksByFile: { 'f-alpha': [chunk('f-alpha', 'hello', 0.1)] },
195
+ });
196
+ let offset = 0;
197
+ const formatter = createCitationAnchorFormatter({
198
+ getSourceOffset: () => offset,
199
+ advanceSourceOffset: (by) => {
200
+ offset += by;
201
+ },
202
+ });
203
+ const t = createFileSearchTool({
204
+ ragClient: client,
205
+ files: [files[0]],
206
+ formatter,
207
+ });
208
+ const first = await t.invoke({ query: 'q' });
209
+ const text1 = Array.isArray(first) ? String(first[0]) : String(first);
210
+ expect(text1).toMatch(/Source 0/);
211
+ expect(text1).toMatch(/\\ue202turn0file0/);
212
+
213
+ // Second call in the same turn: offset should have advanced.
214
+ const second = await t.invoke({ query: 'q2' });
215
+ const text2 = Array.isArray(second) ? String(second[0]) : String(second);
216
+ expect(text2).toMatch(/Source 1/);
217
+ expect(text2).toMatch(/\\ue202turn0file1/);
218
+ });
219
+
220
+ it('extracts 1-indexed page numbers from metadata.page (rag_api 0-indexed)', async () => {
221
+ const { client } = makeRagClient({
222
+ chunksByFile: {
223
+ 'f-alpha': [
224
+ {
225
+ file_id: 'f-alpha',
226
+ page_content: 'page two content',
227
+ distance: 0.1,
228
+ metadata: { page: 1 }, // rag_api 0-indexed → display = 2
229
+ },
230
+ ],
231
+ },
232
+ });
233
+ const t = createFileSearchTool({
234
+ ragClient: client,
235
+ files: [files[0]],
236
+ });
237
+ const result = await t.invoke({ query: 'q' });
238
+ const text = Array.isArray(result) ? String(result[0]) : String(result);
239
+ expect(text).toMatch(/Page: 2/);
240
+ });
241
+ });
242
+
243
+ // ── helpers ──────────────────────────────────────────────────────────────
244
+
245
+ function chunk(
246
+ file_id: string,
247
+ text: string,
248
+ distance: number,
249
+ metadata?: Record<string, unknown>
250
+ ): RagChunk {
251
+ return { file_id, page_content: text, distance, metadata };
252
+ }
253
+
254
+ function silentLogger() {
255
+ return {
256
+ debug: jest.fn(),
257
+ info: jest.fn(),
258
+ warn: jest.fn(),
259
+ error: jest.fn(),
260
+ };
261
+ }