@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.
- package/dist/cjs/hooks/createToolPolicyHook.cjs +5 -5
- package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +59 -14
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +5 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +5 -5
- package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +59 -14
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +5 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/hooks/createToolPolicyHook.d.ts +12 -15
- package/dist/types/tools/search/types.d.ts +2 -0
- package/package.json +1 -1
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +13 -4
- package/src/hooks/createToolPolicyHook.ts +16 -19
- package/src/tools/ToolSearch.ts +81 -23
- package/src/tools/__tests__/ToolSearch.test.ts +151 -0
- package/src/tools/__tests__/hitl.test.ts +114 -1
- package/src/tools/search/source-processing.test.ts +57 -0
- package/src/tools/search/tool.ts +13 -1
- package/src/tools/search/types.ts +2 -0
|
@@ -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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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` —
|
|
24
|
-
*
|
|
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
|
|
50
|
-
*
|
|
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
|
-
*
|
|
115
|
+
* Explicit rules take precedence over fallback modes:
|
|
119
116
|
*
|
|
120
117
|
* 1. `deny` rule match → `'deny'` (always wins, even in `bypass`).
|
|
121
|
-
* 2. `
|
|
118
|
+
* 2. `ask` rule match → `'ask'`.
|
|
122
119
|
* 3. `allow` rule match → `'allow'`.
|
|
123
|
-
* 4. `
|
|
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 (
|
|
172
|
-
return '
|
|
168
|
+
if (askMatch(toolName)) {
|
|
169
|
+
return 'ask';
|
|
173
170
|
}
|
|
174
171
|
if (allowMatch(toolName)) {
|
|
175
172
|
return 'allow';
|
|
176
173
|
}
|
|
177
|
-
if (
|
|
178
|
-
return '
|
|
174
|
+
if (mode === 'bypass') {
|
|
175
|
+
return 'allow';
|
|
179
176
|
}
|
|
180
177
|
if (mode === 'dontAsk') {
|
|
181
178
|
return 'deny';
|
package/src/tools/ToolSearch.ts
CHANGED
|
@@ -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
|
|
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
|
-
.
|
|
371
|
-
.
|
|
372
|
-
.
|
|
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:
|
|
534
|
+
const results: Array<{
|
|
535
|
+
result: t.ToolSearchResult;
|
|
536
|
+
identifierPriority: number;
|
|
537
|
+
}> = [];
|
|
503
538
|
for (let i = 0; i < tools.length; i++) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
529
|
-
|
|
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', () => {
|
package/src/tools/search/tool.ts
CHANGED
|
@@ -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(
|
|
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;
|