@librechat/agents 3.2.66 → 3.2.67

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.
@@ -98,12 +98,12 @@ describe('createToolPolicyHook — bypass mode', () => {
98
98
  expect((await callHook(hook, 'read_file')).decision).toBe('allow');
99
99
  });
100
100
 
101
- it('overrides explicit ask patterns (bypass means stop asking)', async () => {
101
+ it('still asks tools that match an explicit ask pattern', async () => {
102
102
  const hook = createToolPolicyHook({
103
103
  mode: 'bypass',
104
104
  ask: ['execute_*'],
105
105
  });
106
- expect((await callHook(hook, 'execute_code')).decision).toBe('allow');
106
+ expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
107
107
  });
108
108
  });
109
109
 
@@ -154,6 +154,15 @@ describe('createToolPolicyHook — pattern matching', () => {
154
154
  });
155
155
 
156
156
  describe('createToolPolicyHook — precedence', () => {
157
+ it('deny wins over ask', async () => {
158
+ const hook = createToolPolicyHook({
159
+ mode: 'default',
160
+ deny: ['execute_delete'],
161
+ ask: ['execute_*'],
162
+ });
163
+ expect((await callHook(hook, 'execute_delete')).decision).toBe('deny');
164
+ });
165
+
157
166
  it('deny wins over allow', async () => {
158
167
  const hook = createToolPolicyHook({
159
168
  mode: 'default',
@@ -173,13 +182,13 @@ describe('createToolPolicyHook — precedence', () => {
173
182
  expect((await callHook(hook, 'anything_else')).decision).toBe('allow');
174
183
  });
175
184
 
176
- it('allow wins over ask in default mode', async () => {
185
+ it('ask wins over allow in default mode', async () => {
177
186
  const hook = createToolPolicyHook({
178
187
  mode: 'default',
179
188
  allow: ['execute_safe'],
180
189
  ask: ['execute_*'],
181
190
  });
182
- expect((await callHook(hook, 'execute_safe')).decision).toBe('allow');
191
+ expect((await callHook(hook, 'execute_safe')).decision).toBe('ask');
183
192
  expect((await callHook(hook, 'execute_dangerous')).decision).toBe('ask');
184
193
  });
185
194
  });
@@ -3,11 +3,11 @@
3
3
  * permission policies (allow / deny / ask lists + a global mode) without
4
4
  * hand-rolling matching, precedence, and decision logic per-host.
5
5
  *
6
- * Maps directly to the Claude Code Agent SDK permission vocabulary
7
- * (`allowed_tools` / `disallowed_tools` / `permissionMode`) so users of
8
- * either SDK can think in the same terms. See the README's HITL section
9
- * for the cross-walk and `docs/hooks-design-report.md` for the broader
10
- * hook system context.
6
+ * Uses the Claude Code Agent SDK permission vocabulary (`allowed_tools` /
7
+ * `disallowed_tools` / `permissionMode`) while treating modes as fallbacks
8
+ * for calls that match no explicit rule. See the README's HITL section for
9
+ * the cross-walk and `docs/hooks-design-report.md` for the broader hook
10
+ * system context.
11
11
  */
12
12
 
13
13
  import type { HookCallback, PreToolUseHookOutput, ToolDecision } from './types';
@@ -20,10 +20,8 @@ import type { HookCallback, PreToolUseHookOutput, ToolDecision } from './types';
20
20
  * - `dontAsk` — unmatched tools are denied; the human is never
21
21
  * prompted. Useful for headless / API agents where a
22
22
  * silent denial is preferable to a hung interrupt.
23
- * - `bypass` — every tool is approved, except those matching `deny`
24
- * patterns. The kill switch you flip when you trust
25
- * the agent and want to stop being asked. Equivalent to
26
- * Claude Code's `bypassPermissions`.
23
+ * - `bypass` — unmatched tools are approved. Explicit `deny` and `ask`
24
+ * rules still apply.
27
25
  */
28
26
  export type ToolPolicyMode = 'default' | 'dontAsk' | 'bypass';
29
27
 
@@ -46,9 +44,8 @@ export interface ToolPolicyConfig {
46
44
  */
47
45
  deny?: readonly string[];
48
46
  /**
49
- * Tool name patterns that always trigger human approval, regardless
50
- * of `mode: 'default'` vs `'dontAsk'`. In `mode: 'bypass'` these are
51
- * still bypassed (because that's what bypass means).
47
+ * Tool name patterns that always trigger human approval. Wins over
48
+ * `allow` and every mode, but not `deny`.
52
49
  */
53
50
  ask?: readonly string[];
54
51
  /**
@@ -115,12 +112,12 @@ function formatReason(
115
112
  * registry.register('PreToolUse', { hooks: [policyHook] });
116
113
  * ```
117
114
  *
118
- * Evaluation order matches Claude Code's permission flow:
115
+ * Explicit rules take precedence over fallback modes:
119
116
  *
120
117
  * 1. `deny` rule match → `'deny'` (always wins, even in `bypass`).
121
- * 2. `mode === 'bypass'` → `'allow'`.
118
+ * 2. `ask` rule match → `'ask'`.
122
119
  * 3. `allow` rule match → `'allow'`.
123
- * 4. `ask` rule match → `'ask'`.
120
+ * 4. `mode === 'bypass'` → `'allow'`.
124
121
  * 5. `mode === 'dontAsk'` → `'deny'`.
125
122
  * 6. fallthrough → `'ask'`.
126
123
  *
@@ -168,14 +165,14 @@ function decide(
168
165
  if (denyMatch(toolName)) {
169
166
  return 'deny';
170
167
  }
171
- if (mode === 'bypass') {
172
- return 'allow';
168
+ if (askMatch(toolName)) {
169
+ return 'ask';
173
170
  }
174
171
  if (allowMatch(toolName)) {
175
172
  return 'allow';
176
173
  }
177
- if (askMatch(toolName)) {
178
- return 'ask';
174
+ if (mode === 'bypass') {
175
+ return 'allow';
179
176
  }
180
177
  if (mode === 'dontAsk') {
181
178
  return 'deny';
@@ -359,18 +359,48 @@ function simplifyParametersForSearch(
359
359
  return { type: parameters.type };
360
360
  }
361
361
 
362
+ /**
363
+ * Splits one alphanumeric identifier segment on case boundaries without
364
+ * emitting artificial one-character acronym fragments.
365
+ */
366
+ function splitCaseSegment(segment: string): string[] {
367
+ const splitTokens = segment
368
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
369
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
370
+ .toLowerCase()
371
+ .split(/\s+/)
372
+ .filter(Boolean);
373
+
374
+ if (splitTokens.length < 2) return splitTokens;
375
+
376
+ const mergedTokens: string[] = [];
377
+ let prefix = '';
378
+ for (const token of splitTokens) {
379
+ if (token.length === 1) {
380
+ prefix += token;
381
+ continue;
382
+ }
383
+ mergedTokens.push(`${prefix}${token}`);
384
+ prefix = '';
385
+ }
386
+
387
+ if (prefix && mergedTokens.length > 0) {
388
+ mergedTokens[mergedTokens.length - 1] += prefix;
389
+ }
390
+ return mergedTokens.length > 0 ? mergedTokens : [prefix];
391
+ }
392
+
362
393
  /**
363
394
  * Tokenizes a string into lowercase words for BM25.
364
- * Splits on underscores and non-alphanumeric characters for consistent matching.
395
+ * Splits camelCase, underscores, and non-alphanumeric characters for consistent matching.
365
396
  * @param text - The text to tokenize
366
397
  * @returns Array of lowercase tokens
367
398
  */
368
399
  function tokenize(text: string): string[] {
369
400
  return text
370
- .toLowerCase()
371
- .replace(/[^a-z0-9]/g, ' ')
372
- .split(/\s+/)
373
- .filter((token) => token.length > 0);
401
+ .split(/[^a-zA-Z0-9]+/)
402
+ .filter(Boolean)
403
+ .flatMap(splitCaseSegment);
374
404
  }
375
405
 
376
406
  /**
@@ -396,7 +426,7 @@ function createToolDocument(tool: t.ToolMetadata, fields: string[]): string {
396
426
  parts.push(paramNames);
397
427
  }
398
428
 
399
- return parts.join(' ');
429
+ return tokenize(parts.join(' ')).join(' ');
400
430
  }
401
431
 
402
432
  /**
@@ -498,35 +528,63 @@ function performLocalSearch(
498
528
 
499
529
  const maxScore = Math.max(...scores.filter((s) => s > 0), 1);
500
530
  const queryLower = query.toLowerCase().trim();
531
+ const queryIdentifier = queryTokens.join('');
532
+ const matchesIdentifiers = fields.includes('name');
501
533
 
502
- const results: t.ToolSearchResult[] = [];
534
+ const results: Array<{
535
+ result: t.ToolSearchResult;
536
+ identifierPriority: number;
537
+ }> = [];
503
538
  for (let i = 0; i < tools.length; i++) {
504
- if (scores[i] > 0) {
505
- const { field, snippet } = findMatchedField(
506
- tools[i],
507
- queryTokens,
508
- fields
509
- );
510
- let normalizedScore = Math.min(scores[i] / maxScore, 1.0);
511
-
512
- const baseName = getBaseToolName(tools[i].name).toLowerCase();
513
- if (baseName === queryLower) {
539
+ const score = scores[i];
540
+ const hasSearchScore = Number.isFinite(score) && score > 0;
541
+ let identifierPriority = 0;
542
+ let normalizedScore = hasSearchScore ? Math.min(score / maxScore, 1.0) : 0;
543
+
544
+ if (matchesIdentifiers) {
545
+ const rawBaseName = getBaseToolName(tools[i].name).toLowerCase();
546
+ const rawFullName = tools[i].name.toLowerCase();
547
+ const baseIdentifier = tokenize(rawBaseName).join('');
548
+ const fullIdentifier = tokenize(rawFullName).join('');
549
+
550
+ if (rawFullName === queryLower) {
551
+ identifierPriority = 4;
552
+ normalizedScore = 1.0;
553
+ } else if (rawBaseName === queryLower) {
554
+ identifierPriority = 3;
555
+ normalizedScore = 1.0;
556
+ } else if (
557
+ baseIdentifier === queryIdentifier ||
558
+ fullIdentifier === queryIdentifier
559
+ ) {
560
+ identifierPriority = 2;
514
561
  normalizedScore = 1.0;
515
- } else if (baseName.startsWith(queryLower)) {
562
+ } else if (baseIdentifier.startsWith(queryIdentifier)) {
563
+ identifierPriority = 1;
516
564
  normalizedScore = Math.max(normalizedScore, 0.95);
517
565
  }
566
+ }
567
+
568
+ if (!hasSearchScore && identifierPriority === 0) continue;
518
569
 
519
- results.push({
570
+ const { field, snippet } = findMatchedField(tools[i], queryTokens, fields);
571
+ results.push({
572
+ result: {
520
573
  tool_name: tools[i].name,
521
574
  match_score: normalizedScore,
522
575
  matched_field: field,
523
576
  snippet,
524
- });
525
- }
577
+ },
578
+ identifierPriority,
579
+ });
526
580
  }
527
581
 
528
- results.sort((a, b) => b.match_score - a.match_score);
529
- const topResults = results.slice(0, maxResults);
582
+ results.sort(
583
+ (a, b) =>
584
+ b.identifierPriority - a.identifierPriority ||
585
+ b.result.match_score - a.result.match_score
586
+ );
587
+ const topResults = results.slice(0, maxResults).map(({ result }) => result);
530
588
 
531
589
  return {
532
590
  tool_references: topResults,
@@ -360,6 +360,52 @@ describe('ToolSearch', () => {
360
360
  expect(result.tool_references[0].match_score).toBeGreaterThan(0);
361
361
  });
362
362
 
363
+ it('does not match tool names when searching only parameters', () => {
364
+ const tools: ToolMetadata[] = [
365
+ {
366
+ name: 'query',
367
+ description: 'A tool whose name matches but parameters do not',
368
+ parameters: {
369
+ type: 'object',
370
+ properties: { value: { type: 'string' } },
371
+ },
372
+ },
373
+ {
374
+ name: 'run_database_query',
375
+ description: 'Run a database query',
376
+ parameters: {
377
+ type: 'object',
378
+ properties: { query: { type: 'string' } },
379
+ },
380
+ },
381
+ ];
382
+
383
+ const result = performLocalSearch(tools, 'query', ['parameters'], 1);
384
+
385
+ expect(result.tool_references).toHaveLength(1);
386
+ expect(result.tool_references[0].tool_name).toBe('run_database_query');
387
+ expect(result.tool_references[0].matched_field).toBe('parameters');
388
+ });
389
+
390
+ it('returns no matches when every selected field is empty', () => {
391
+ const tools: ToolMetadata[] = [
392
+ {
393
+ name: 'get_weather',
394
+ description: 'Get the weather',
395
+ parameters: undefined,
396
+ },
397
+ {
398
+ name: 'send_email',
399
+ description: 'Send an email',
400
+ parameters: undefined,
401
+ },
402
+ ];
403
+
404
+ const result = performLocalSearch(tools, 'query', ['parameters'], 10);
405
+
406
+ expect(result.tool_references).toEqual([]);
407
+ });
408
+
363
409
  it('prioritizes name matches over description matches', () => {
364
410
  const result = performLocalSearch(
365
411
  mockTools,
@@ -830,6 +876,111 @@ describe('ToolSearch', () => {
830
876
  },
831
877
  ];
832
878
 
879
+ it('finds camelCase tools by exact name', () => {
880
+ const tools: ToolMetadata[] = [
881
+ {
882
+ name: 'addActivity',
883
+ description: 'Create an activity',
884
+ parameters: undefined,
885
+ },
886
+ {
887
+ name: 'addPerson',
888
+ description: 'Create a person',
889
+ parameters: undefined,
890
+ },
891
+ ];
892
+
893
+ const result = performLocalSearch(tools, 'addPerson', ['name'], 10);
894
+
895
+ expect(result.tool_references[0].tool_name).toBe('addPerson');
896
+ expect(result.tool_references[0].match_score).toBe(1.0);
897
+ });
898
+
899
+ it('finds camelCase tools by a lowercased exact name', () => {
900
+ const tools: ToolMetadata[] = [
901
+ {
902
+ name: 'addActivity',
903
+ description: 'Create an activity',
904
+ parameters: undefined,
905
+ },
906
+ {
907
+ name: 'addPerson',
908
+ description: 'Create a person',
909
+ parameters: undefined,
910
+ },
911
+ ];
912
+
913
+ const result = performLocalSearch(tools, 'addperson', ['name'], 10);
914
+
915
+ expect(result.tool_references).toHaveLength(1);
916
+ expect(result.tool_references[0].tool_name).toBe('addPerson');
917
+ expect(result.tool_references[0].match_score).toBe(1.0);
918
+ });
919
+
920
+ it('prioritizes an exact full camelCase MCP tool ID', () => {
921
+ const tools: ToolMetadata[] = [
922
+ {
923
+ name: 'add_person_mcp_pipedrive',
924
+ description: 'Create a person with a separator variant',
925
+ parameters: undefined,
926
+ },
927
+ {
928
+ name: 'addActivity_mcp_pipedrive',
929
+ description: 'Create an activity',
930
+ parameters: undefined,
931
+ },
932
+ {
933
+ name: 'addPerson_mcp_pipedrive',
934
+ description: 'Create a person',
935
+ parameters: undefined,
936
+ },
937
+ {
938
+ name: 'updatePerson_mcp_pipedrive',
939
+ description: 'Update a person',
940
+ parameters: undefined,
941
+ },
942
+ ];
943
+
944
+ const result = performLocalSearch(
945
+ tools,
946
+ 'addPerson_mcp_pipedrive',
947
+ ['name'],
948
+ 1
949
+ );
950
+
951
+ expect(result.tool_references[0].tool_name).toBe(
952
+ 'addPerson_mcp_pipedrive'
953
+ );
954
+ expect(result.tool_references[0].match_score).toBe(1.0);
955
+ });
956
+
957
+ it.each([
958
+ ['OAuthToken', 'close_order'],
959
+ ['iOSApp', 'inspect_inventory'],
960
+ ])(
961
+ 'does not return tools matching only an acronym fragment from %s',
962
+ (matchingTool, unrelatedTool) => {
963
+ const tools: ToolMetadata[] = [
964
+ {
965
+ name: matchingTool,
966
+ description: 'Expected acronym tool',
967
+ parameters: undefined,
968
+ },
969
+ {
970
+ name: unrelatedTool,
971
+ description: 'Unrelated tool',
972
+ parameters: undefined,
973
+ },
974
+ ];
975
+
976
+ const result = performLocalSearch(tools, matchingTool, ['name'], 10);
977
+
978
+ expect(
979
+ result.tool_references.map(({ tool_name }) => tool_name)
980
+ ).toEqual([matchingTool]);
981
+ }
982
+ );
983
+
833
984
  it('searches across all tools including MCP tools', () => {
834
985
  const result = performLocalSearch(
835
986
  mcpTools,
@@ -34,7 +34,7 @@ import type {
34
34
  import type * as t from '@/types';
35
35
  import { Providers as providers, GraphEvents } from '@/common';
36
36
  import * as events from '@/utils/events';
37
- import { HookRegistry } from '@/hooks';
37
+ import { HookRegistry, createToolPolicyHook } from '@/hooks';
38
38
  import { ToolNode } from '../ToolNode';
39
39
 
40
40
  async function flushAsyncWork(): Promise<void> {
@@ -255,6 +255,119 @@ describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoo
255
255
  ]);
256
256
  });
257
257
 
258
+ it('waits for approval before executing an explicit ask rule in bypass mode', async () => {
259
+ let toolExecuted = false;
260
+ jest
261
+ .spyOn(events, 'safeDispatchCustomEvent')
262
+ .mockImplementation(async (event, data) => {
263
+ if (event !== 'on_tool_execute') {
264
+ return;
265
+ }
266
+ toolExecuted = true;
267
+ const request = data as {
268
+ resolve: (results: t.ToolExecuteResult[]) => void;
269
+ };
270
+ request.resolve([
271
+ { toolCallId: 'call_1', content: 'deleted', status: 'success' },
272
+ ]);
273
+ });
274
+ const registry = new HookRegistry();
275
+ registry.register('PreToolUse', {
276
+ hooks: [
277
+ createToolPolicyHook({
278
+ mode: 'bypass',
279
+ ask: ['dangerous_*'],
280
+ }),
281
+ ],
282
+ });
283
+ const node = new ToolNode({
284
+ tools: [createSchemaStub('dangerous_tool')],
285
+ eventDrivenMode: true,
286
+ agentId: 'agent-x',
287
+ toolCallStepIds: new Map([['call_1', 'step_call_1']]),
288
+ hookRegistry: registry,
289
+ humanInTheLoop: { enabled: true },
290
+ });
291
+ const graph = buildHITLGraph(node, [
292
+ {
293
+ id: 'call_1',
294
+ name: 'dangerous_tool',
295
+ args: { command: 'delete data' },
296
+ },
297
+ ]);
298
+ const config = {
299
+ configurable: { thread_id: 'thread-bypass-explicit-ask' },
300
+ };
301
+
302
+ const interrupted = await graph.invoke({ messages: [] }, config);
303
+
304
+ expect(isInterrupted<t.HumanInterruptPayload>(interrupted)).toBe(true);
305
+ expect(toolExecuted).toBe(false);
306
+
307
+ const resumed = (await resumeGraph(
308
+ graph,
309
+ interrupted,
310
+ [{ type: 'approve' }],
311
+ config
312
+ )) as { messages: BaseMessage[] };
313
+
314
+ expect(toolExecuted).toBe(true);
315
+ expect(
316
+ resumed.messages.some(
317
+ (message) =>
318
+ message._getType() === 'tool' &&
319
+ (message as ToolMessage).tool_call_id === 'call_1' &&
320
+ message.content === 'deleted'
321
+ )
322
+ ).toBe(true);
323
+ });
324
+
325
+ it('executes an unmatched tool without interruption in bypass mode', async () => {
326
+ let toolExecuted = false;
327
+ jest
328
+ .spyOn(events, 'safeDispatchCustomEvent')
329
+ .mockImplementation(async (event, data) => {
330
+ if (event !== 'on_tool_execute') {
331
+ return;
332
+ }
333
+ toolExecuted = true;
334
+ const request = data as {
335
+ resolve: (results: t.ToolExecuteResult[]) => void;
336
+ };
337
+ request.resolve([
338
+ { toolCallId: 'call_1', content: 'read result', status: 'success' },
339
+ ]);
340
+ });
341
+ const registry = new HookRegistry();
342
+ registry.register('PreToolUse', {
343
+ hooks: [
344
+ createToolPolicyHook({
345
+ mode: 'bypass',
346
+ ask: ['dangerous_*'],
347
+ }),
348
+ ],
349
+ });
350
+ const node = new ToolNode({
351
+ tools: [createSchemaStub('read_tool')],
352
+ eventDrivenMode: true,
353
+ agentId: 'agent-x',
354
+ toolCallStepIds: new Map([['call_1', 'step_call_1']]),
355
+ hookRegistry: registry,
356
+ humanInTheLoop: { enabled: true },
357
+ });
358
+ const graph = buildHITLGraph(node, [
359
+ { id: 'call_1', name: 'read_tool', args: { command: 'read data' } },
360
+ ]);
361
+
362
+ const result = await graph.invoke(
363
+ { messages: [] },
364
+ { configurable: { thread_id: 'thread-bypass-unmatched' } }
365
+ );
366
+
367
+ expect(isInterrupted(result)).toBe(false);
368
+ expect(toolExecuted).toBe(true);
369
+ });
370
+
258
371
  it('resume with approve runs the tool through the host event path', async () => {
259
372
  mockEventDispatch([
260
373
  { toolCallId: 'call_1', content: 'host-result', status: 'success' },
@@ -141,6 +141,63 @@ describe('expandHighlights content stripping', () => {
141
141
  expect(result.organic?.[0].content).toBeUndefined();
142
142
  expect(result.organic?.[0].references).toBeUndefined();
143
143
  });
144
+
145
+ test('honors a custom mainExpandBy when expanding highlights', () => {
146
+ const highlightText = 'KEYFACT';
147
+ // Boundary-free filler so expansion is governed purely by mainExpandBy,
148
+ // not by where natural separators happen to fall.
149
+ const content = `${'x'.repeat(1000)}${highlightText}${'y'.repeat(1000)}`;
150
+ const makeData = (): t.SearchResultData => ({
151
+ organic: [
152
+ {
153
+ ...makeOrganic('https://a.com'),
154
+ content,
155
+ highlights: [{ text: highlightText, score: 0.9 }],
156
+ },
157
+ ],
158
+ });
159
+
160
+ // separatorExpandBy 0 isolates the mainExpandBy effect.
161
+ const narrow = expandHighlights(makeData(), 50, 0).organic?.[0]
162
+ .highlights?.[0];
163
+ const wide = expandHighlights(makeData(), 500, 0).organic?.[0]
164
+ .highlights?.[0];
165
+
166
+ expect(narrow?.text).toContain(highlightText);
167
+ expect(wide?.text).toContain(highlightText);
168
+ // 50 chars of context each side vs. 500 → wide must be markedly longer.
169
+ expect(wide!.text.length).toBeGreaterThan(narrow!.text.length);
170
+ expect(narrow!.text.length).toBe(highlightText.length + 100);
171
+ expect(wide!.text.length).toBe(highlightText.length + 1000);
172
+ });
173
+
174
+ test('honors a custom separatorExpandBy when seeking boundaries', () => {
175
+ const highlightText = 'KEYFACT';
176
+ // The main window lands inside a boundary-free 'y' run; the only natural
177
+ // boundary ('. ') sits 250 chars past the main window's end. A small
178
+ // separator range can't reach it; a large one can.
179
+ const content = `${'x'.repeat(100)}${highlightText}${'y'.repeat(300)}. ${'z'.repeat(300)}`;
180
+ const makeData = (): t.SearchResultData => ({
181
+ organic: [
182
+ {
183
+ ...makeOrganic('https://a.com'),
184
+ content,
185
+ highlights: [{ text: highlightText, score: 0.9 }],
186
+ },
187
+ ],
188
+ });
189
+
190
+ // Same mainExpandBy; only the separator search range differs.
191
+ const narrow = expandHighlights(makeData(), 50, 100).organic?.[0]
192
+ .highlights?.[0];
193
+ const wide = expandHighlights(makeData(), 50, 400).organic?.[0]
194
+ .highlights?.[0];
195
+
196
+ expect(narrow?.text).toContain(highlightText);
197
+ expect(wide?.text).toContain(highlightText);
198
+ // Only the wider separator range reaches the trailing sentence boundary.
199
+ expect(wide!.text.length).toBeGreaterThan(narrow!.text.length);
200
+ });
144
201
  });
145
202
 
146
203
  describe('createSourceProcessor content capping', () => {
@@ -203,6 +203,8 @@ function createSearchProcessor({
203
203
  supportsNews,
204
204
  sourceProcessor,
205
205
  onGetHighlights,
206
+ mainExpandBy,
207
+ separatorExpandBy,
206
208
  logger,
207
209
  }: {
208
210
  safeSearch: t.SearchToolConfig['safeSearch'];
@@ -212,6 +214,8 @@ function createSearchProcessor({
212
214
  searchAPI: ReturnType<typeof createSearchAPI>;
213
215
  sourceProcessor: ReturnType<typeof createSourceProcessor>;
214
216
  onGetHighlights: t.SearchToolConfig['onGetHighlights'];
217
+ mainExpandBy: t.SearchToolConfig['mainExpandBy'];
218
+ separatorExpandBy: t.SearchToolConfig['separatorExpandBy'];
215
219
  logger: t.Logger;
216
220
  }) {
217
221
  return async function ({
@@ -260,7 +264,11 @@ function createSearchProcessor({
260
264
  numElements: maxSources,
261
265
  });
262
266
 
263
- return expandHighlights(processedSources);
267
+ return expandHighlights(
268
+ processedSources,
269
+ mainExpandBy,
270
+ separatorExpandBy
271
+ );
264
272
  } catch (error) {
265
273
  logger.error('Error in search:', error);
266
274
  return {
@@ -376,6 +384,8 @@ export const createSearchTool = (
376
384
  maxContentLength,
377
385
  chunkSize,
378
386
  chunkOverlap,
387
+ mainExpandBy,
388
+ separatorExpandBy,
379
389
  maxOutputChars,
380
390
  strategies = ['no_extraction'],
381
391
  filterContent = true,
@@ -525,6 +535,8 @@ export const createSearchTool = (
525
535
  supportsNews: searchProvider !== 'keenable',
526
536
  sourceProcessor,
527
537
  onGetHighlights,
538
+ mainExpandBy,
539
+ separatorExpandBy,
528
540
  logger,
529
541
  });
530
542
 
@@ -227,6 +227,8 @@ export interface ProcessSourcesConfig {
227
227
  * configurable via the `SEARCH_CHUNK_OVERLAP` env var. Clamped below
228
228
  * `chunkSize`. */
229
229
  chunkOverlap?: number;
230
+ mainExpandBy?: number;
231
+ separatorExpandBy?: number;
230
232
  strategies?: string[];
231
233
  filterContent?: boolean;
232
234
  reranker?: BaseReranker;