@librechat/agents 3.2.65 → 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.
Files changed (78) hide show
  1. package/dist/cjs/graphs/Graph.cjs +15 -2
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/createToolPolicyHook.cjs +5 -5
  4. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
  5. package/dist/cjs/instrumentation.cjs +15 -3
  6. package/dist/cjs/instrumentation.cjs.map +1 -1
  7. package/dist/cjs/langfuseToolOutputTracing.cjs +1 -2
  8. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  9. package/dist/cjs/langfuseTraceShaping.cjs +51 -24
  10. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  11. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +8 -0
  12. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +8 -0
  14. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  15. package/dist/cjs/tools/BashExecutor.cjs +9 -8
  16. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  17. package/dist/cjs/tools/CodeExecutor.cjs +9 -7
  18. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolSearch.cjs +59 -14
  20. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs +5 -3
  22. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  23. package/dist/esm/graphs/Graph.mjs +15 -2
  24. package/dist/esm/graphs/Graph.mjs.map +1 -1
  25. package/dist/esm/hooks/createToolPolicyHook.mjs +5 -5
  26. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
  27. package/dist/esm/instrumentation.mjs +15 -3
  28. package/dist/esm/instrumentation.mjs.map +1 -1
  29. package/dist/esm/langfuseToolOutputTracing.mjs +1 -2
  30. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  31. package/dist/esm/langfuseTraceShaping.mjs +51 -24
  32. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  33. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +8 -0
  34. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  35. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +8 -0
  36. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  37. package/dist/esm/tools/BashExecutor.mjs +9 -8
  38. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  39. package/dist/esm/tools/CodeExecutor.mjs +9 -7
  40. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  41. package/dist/esm/tools/ToolSearch.mjs +59 -14
  42. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  43. package/dist/esm/tools/search/tool.mjs +5 -3
  44. package/dist/esm/tools/search/tool.mjs.map +1 -1
  45. package/dist/types/graphs/Graph.d.ts +2 -0
  46. package/dist/types/hooks/createToolPolicyHook.d.ts +12 -15
  47. package/dist/types/langfuseTraceShaping.d.ts +12 -6
  48. package/dist/types/tools/BashExecutor.d.ts +7 -6
  49. package/dist/types/tools/CodeExecutor.d.ts +7 -5
  50. package/dist/types/tools/search/types.d.ts +2 -0
  51. package/dist/types/types/graph.d.ts +10 -3
  52. package/package.json +1 -1
  53. package/src/graphs/Graph.ts +21 -3
  54. package/src/hooks/__tests__/createToolPolicyHook.test.ts +13 -4
  55. package/src/hooks/createToolPolicyHook.ts +16 -19
  56. package/src/instrumentation.ts +20 -0
  57. package/src/langfuseToolOutputTracing.ts +2 -4
  58. package/src/langfuseTraceShaping.ts +73 -20
  59. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +110 -0
  60. package/src/llm/anthropic/utils/message_inputs.ts +15 -0
  61. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +122 -0
  62. package/src/llm/bedrock/utils/message_inputs.ts +13 -0
  63. package/src/specs/langfuse-instrumentation.test.ts +64 -0
  64. package/src/specs/langfuse-routing.integration.test.ts +49 -4
  65. package/src/specs/langfuse-tool-output-tracing.test.ts +5 -4
  66. package/src/specs/langfuse-trace-shaping.test.ts +80 -9
  67. package/src/specs/subagent.test.ts +180 -0
  68. package/src/tools/BashExecutor.ts +9 -8
  69. package/src/tools/CodeExecutor.ts +9 -7
  70. package/src/tools/ToolSearch.ts +81 -23
  71. package/src/tools/__tests__/BashExecutor.test.ts +16 -5
  72. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +17 -6
  73. package/src/tools/__tests__/ToolSearch.test.ts +151 -0
  74. package/src/tools/__tests__/hitl.test.ts +114 -1
  75. package/src/tools/search/source-processing.test.ts +57 -0
  76. package/src/tools/search/tool.ts +13 -1
  77. package/src/tools/search/types.ts +2 -0
  78. package/src/types/graph.ts +10 -3
@@ -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;
@@ -534,9 +534,9 @@ export type LangfuseToolOutputTracingConfig = {
534
534
 
535
535
  export type LangfuseToolNodeTracingConfig = {
536
536
  /**
537
- * Overrides ToolNode callback tracing. ToolNode spans are exported by the
538
- * env-backed Langfuse callback, so this only enables tracing when that
539
- * callback is configured.
537
+ * Opts into the internal ToolNode batch observation. Graph tool-dispatch
538
+ * and individual tool observations are exported without this wrapper, so
539
+ * the default is false to avoid a redundant hierarchy level.
540
540
  */
541
541
  enabled?: boolean;
542
542
  };
@@ -546,6 +546,13 @@ export interface LangfuseConfig {
546
546
  publicKey?: string;
547
547
  secretKey?: string;
548
548
  baseUrl?: string;
549
+ /**
550
+ * Environment identifier attached to exported traces (Langfuse
551
+ * `environment`). When unset, falls back to `LANGFUSE_TRACING_ENVIRONMENT`
552
+ * then `NODE_ENV`, so production traces are not collapsed under the
553
+ * `default` environment.
554
+ */
555
+ environment?: string;
549
556
  metadata?: Record<string, string | number | boolean | null | undefined>;
550
557
  /**
551
558
  * Internal OTLP span attributes to attach to Langfuse observations before