@librechat/agents 3.3.10 → 3.3.12

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 (211) hide show
  1. package/dist/cjs/graphs/Graph.cjs +248 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
  4. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/llm/init.cjs +1 -1
  8. package/dist/cjs/llm/invoke.cjs +160 -43
  9. package/dist/cjs/llm/invoke.cjs.map +1 -1
  10. package/dist/cjs/llm/openai/index.cjs +190 -13
  11. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  12. package/dist/cjs/llm/streamLimits.cjs +723 -0
  13. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  14. package/dist/cjs/main.cjs +12 -3
  15. package/dist/cjs/messages/core.cjs +592 -27
  16. package/dist/cjs/messages/core.cjs.map +1 -1
  17. package/dist/cjs/run.cjs +7 -3
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/session/AgentSession.cjs +4 -1
  20. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  21. package/dist/cjs/stream.cjs +105 -14
  22. package/dist/cjs/stream.cjs.map +1 -1
  23. package/dist/cjs/summarization/node.cjs +157 -37
  24. package/dist/cjs/summarization/node.cjs.map +1 -1
  25. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  26. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  27. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  28. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  30. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/ToolNode.cjs +77 -4
  32. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  33. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  34. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  35. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  36. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  37. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  38. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  39. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  40. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  41. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  42. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  43. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  44. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  45. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  46. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  47. package/dist/cjs/tools/search/search.cjs +30 -10
  48. package/dist/cjs/tools/search/search.cjs.map +1 -1
  49. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  50. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  51. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  52. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  53. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  54. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  55. package/dist/cjs/tools/search/tool.cjs +17 -3
  56. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  57. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  58. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  59. package/dist/cjs/utils/index.cjs +2 -1
  60. package/dist/cjs/utils/misc.cjs +12 -0
  61. package/dist/cjs/utils/misc.cjs.map +1 -1
  62. package/dist/cjs/utils/proxy.cjs +63 -0
  63. package/dist/cjs/utils/proxy.cjs.map +1 -0
  64. package/dist/esm/graphs/Graph.mjs +247 -26
  65. package/dist/esm/graphs/Graph.mjs.map +1 -1
  66. package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
  67. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  68. package/dist/esm/llm/bedrock/index.mjs +13 -2
  69. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  70. package/dist/esm/llm/init.mjs +1 -1
  71. package/dist/esm/llm/invoke.mjs +160 -43
  72. package/dist/esm/llm/invoke.mjs.map +1 -1
  73. package/dist/esm/llm/openai/index.mjs +192 -15
  74. package/dist/esm/llm/openai/index.mjs.map +1 -1
  75. package/dist/esm/llm/streamLimits.mjs +704 -0
  76. package/dist/esm/llm/streamLimits.mjs.map +1 -0
  77. package/dist/esm/main.mjs +8 -6
  78. package/dist/esm/messages/core.mjs +592 -28
  79. package/dist/esm/messages/core.mjs.map +1 -1
  80. package/dist/esm/run.mjs +7 -3
  81. package/dist/esm/run.mjs.map +1 -1
  82. package/dist/esm/session/AgentSession.mjs +4 -1
  83. package/dist/esm/session/AgentSession.mjs.map +1 -1
  84. package/dist/esm/stream.mjs +105 -14
  85. package/dist/esm/stream.mjs.map +1 -1
  86. package/dist/esm/summarization/node.mjs +157 -37
  87. package/dist/esm/summarization/node.mjs.map +1 -1
  88. package/dist/esm/tools/BashExecutor.mjs +3 -2
  89. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  90. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  91. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  92. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  93. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  94. package/dist/esm/tools/ToolNode.mjs +77 -4
  95. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  96. package/dist/esm/tools/ToolSearch.mjs +3 -2
  97. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  98. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  99. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  100. package/dist/esm/tools/search/crw-search.mjs +3 -1
  101. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  102. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  103. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  104. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  105. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  106. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  107. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  108. package/dist/esm/tools/search/rerankers.mjs +26 -8
  109. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  110. package/dist/esm/tools/search/search.mjs +30 -10
  111. package/dist/esm/tools/search/search.mjs.map +1 -1
  112. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  113. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  114. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  115. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  116. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  117. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  118. package/dist/esm/tools/search/tool.mjs +17 -3
  119. package/dist/esm/tools/search/tool.mjs.map +1 -1
  120. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  121. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  122. package/dist/esm/utils/index.mjs +2 -1
  123. package/dist/esm/utils/misc.mjs +12 -1
  124. package/dist/esm/utils/misc.mjs.map +1 -1
  125. package/dist/esm/utils/proxy.mjs +62 -0
  126. package/dist/esm/utils/proxy.mjs.map +1 -0
  127. package/dist/types/graphs/Graph.d.ts +53 -1
  128. package/dist/types/index.d.ts +2 -0
  129. package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
  130. package/dist/types/llm/invoke.d.ts +26 -5
  131. package/dist/types/llm/openai/index.d.ts +3 -0
  132. package/dist/types/llm/streamLimits.d.ts +314 -0
  133. package/dist/types/messages/core.d.ts +11 -6
  134. package/dist/types/run.d.ts +1 -0
  135. package/dist/types/summarization/node.d.ts +27 -2
  136. package/dist/types/tools/BashExecutor.d.ts +2 -2
  137. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  138. package/dist/types/tools/ToolNode.d.ts +11 -1
  139. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  140. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  141. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  142. package/dist/types/tools/search/rerankers.d.ts +9 -5
  143. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  144. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  145. package/dist/types/tools/search/types.d.ts +28 -33
  146. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  147. package/dist/types/types/graph.d.ts +7 -1
  148. package/dist/types/types/run.d.ts +46 -1
  149. package/dist/types/types/tools.d.ts +21 -0
  150. package/dist/types/utils/index.d.ts +1 -0
  151. package/dist/types/utils/misc.d.ts +7 -0
  152. package/dist/types/utils/proxy.d.ts +31 -0
  153. package/package.json +2 -1
  154. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  155. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  156. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  157. package/src/graphs/Graph.ts +302 -27
  158. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  159. package/src/index.ts +11 -0
  160. package/src/langfuseToolOutputTracing.ts +410 -14
  161. package/src/llm/bedrock/index.ts +22 -2
  162. package/src/llm/custom-chat-models.smoke.test.ts +747 -0
  163. package/src/llm/invoke.streamLimits.test.ts +142 -0
  164. package/src/llm/invoke.test.ts +187 -1
  165. package/src/llm/invoke.ts +231 -43
  166. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  167. package/src/llm/openai/index.ts +348 -31
  168. package/src/llm/openai/llm.spec.ts +107 -6
  169. package/src/llm/streamLimits.test.ts +450 -0
  170. package/src/llm/streamLimits.ts +1158 -0
  171. package/src/messages/core.ts +1290 -42
  172. package/src/messages/formatAgentMessages.test.ts +2623 -0
  173. package/src/run.ts +4 -0
  174. package/src/session/AgentSession.ts +5 -0
  175. package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
  176. package/src/specs/preemptSeal.test.ts +374 -5
  177. package/src/stream.ts +141 -6
  178. package/src/summarization/__tests__/node.test.ts +269 -0
  179. package/src/summarization/chunkHandler.test.ts +196 -0
  180. package/src/summarization/node.ts +203 -6
  181. package/src/tools/BashExecutor.ts +4 -3
  182. package/src/tools/CodeExecutor.ts +5 -4
  183. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  184. package/src/tools/ToolNode.ts +109 -6
  185. package/src/tools/ToolSearch.ts +4 -3
  186. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  187. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  188. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  189. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  190. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  191. package/src/tools/search/crw-scraper.ts +6 -0
  192. package/src/tools/search/crw-search.ts +6 -1
  193. package/src/tools/search/firecrawl.ts +6 -0
  194. package/src/tools/search/http-agent.test.ts +133 -0
  195. package/src/tools/search/keenable-scraper.ts +6 -0
  196. package/src/tools/search/keenable-search.ts +6 -1
  197. package/src/tools/search/rerankers.ts +36 -10
  198. package/src/tools/search/search.ts +29 -11
  199. package/src/tools/search/serper-scraper.ts +6 -0
  200. package/src/tools/search/tavily-scraper.ts +6 -0
  201. package/src/tools/search/tavily-search.ts +2 -0
  202. package/src/tools/search/tool.ts +16 -0
  203. package/src/tools/search/types.ts +31 -33
  204. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  205. package/src/types/graph.ts +7 -0
  206. package/src/types/run.ts +49 -1
  207. package/src/types/tools.ts +21 -0
  208. package/src/utils/index.ts +1 -0
  209. package/src/utils/misc.ts +19 -0
  210. package/src/utils/proxy.test.ts +176 -0
  211. package/src/utils/proxy.ts +93 -0
@@ -24,9 +24,9 @@ describe('buildBashExecutionToolDescription', () => {
24
24
  expect(BashExecutionToolDescription).toContain('heredoc/printf');
25
25
  expect(BashExecutionToolDescription).toContain('not bare Python');
26
26
  expect(BashExecutionToolDescription).toContain(
27
- 'failed executions do not register new files'
27
+ 'Failed executions register nothing'
28
28
  );
29
- expect(BashExecutionToolDescription).toContain('not later-call storage');
29
+ expect(BashExecutionToolDescription).toContain('`/tmp` never survives the call');
30
30
  });
31
31
 
32
32
  it('appends the tool-output references guide when enabled', () => {
@@ -51,10 +51,8 @@ describe('ProgrammaticToolCalling', () => {
51
51
  expect(description).toContain('raw=$(tool');
52
52
  expect(description).toContain('direct tool > file may be empty');
53
53
  expect(description).toContain('/mnt/data/sf.json');
54
- expect(description).toContain(
55
- 'failed executions do not register new files'
56
- );
57
- expect(description).toContain('not later-call storage');
54
+ expect(description).toContain('Failed executions register nothing');
55
+ expect(description).toContain('`/tmp` never survives the call');
58
56
  });
59
57
  });
60
58
 
@@ -20,6 +20,7 @@ import {
20
20
  } from '../subagent';
21
21
  import { sanitizeForwardedSubagentUpdateData } from '../subagent/SubagentExecutor';
22
22
  import { Constants, Providers, GraphEvents, StepTypes } from '@/common';
23
+ import { StreamLimitExceededError } from '@/llm/streamLimits';
23
24
  import { AgentContext } from '@/agents/AgentContext';
24
25
  import { HookRegistry } from '@/hooks/HookRegistry';
25
26
  import { HandlerRegistry } from '@/events';
@@ -451,6 +452,131 @@ describe('SubagentExecutor', () => {
451
452
  expect(result.messages).toEqual([]);
452
453
  });
453
454
 
455
+ it('rethrows a child stream-limit trip instead of converting it to a tool result', async () => {
456
+ const executor = createExecutor({
457
+ createChildGraph: (): StandardGraph =>
458
+ ({
459
+ createWorkflow: (): { invoke: jest.Mock } => ({
460
+ invoke: jest.fn().mockRejectedValue(
461
+ new StreamLimitExceededError({
462
+ kind: 'tool_call_args',
463
+ limit: 10,
464
+ observed: 11,
465
+ toolName: 'db_query',
466
+ })
467
+ ),
468
+ }),
469
+ clearHeavyState: jest.fn(),
470
+ }) as unknown as StandardGraph,
471
+ });
472
+ await expect(
473
+ executor.execute({ description: 'Do something', subagentType: 'researcher' })
474
+ ).rejects.toBeInstanceOf(StreamLimitExceededError);
475
+ });
476
+
477
+ it('binds the child to the batch-captured controller, not the live scope', async () => {
478
+ const batchBreaker = new AbortController();
479
+ const liveBreaker = new AbortController();
480
+ const executor = createExecutor({
481
+ breakerScope: { controller: () => liveBreaker },
482
+ createChildGraph: (): StandardGraph =>
483
+ ({
484
+ createWorkflow: (): { invoke: jest.Mock } => ({
485
+ invoke: jest.fn().mockRejectedValue(
486
+ new StreamLimitExceededError({
487
+ kind: 'tool_call_args',
488
+ limit: 10,
489
+ observed: 11,
490
+ toolName: 'db_query',
491
+ })
492
+ ),
493
+ }),
494
+ clearHeavyState: jest.fn(),
495
+ }) as unknown as StandardGraph,
496
+ });
497
+
498
+ /** `breaker` is the controller the parent TOOL BATCH captured before
499
+ * PreToolUse hooks; a reset during those hooks replaced the live
500
+ * scope, and the child must not revive on — or trip — the new run's
501
+ * controller. */
502
+ await expect(
503
+ executor.execute({
504
+ description: 'Do something',
505
+ subagentType: 'researcher',
506
+ breaker: batchBreaker,
507
+ })
508
+ ).rejects.toBeInstanceOf(StreamLimitExceededError);
509
+ expect(batchBreaker.signal.aborted).toBe(true);
510
+ expect(liveBreaker.signal.aborted).toBe(false);
511
+ });
512
+
513
+ it('trips the breaker controller captured at execution start, not the current one', async () => {
514
+ const oldRun = new AbortController();
515
+ const newRun = new AbortController();
516
+ let current = oldRun;
517
+ let releaseChild: (() => void) | undefined;
518
+ const executor = createExecutor({
519
+ breakerScope: { controller: () => current },
520
+ createChildGraph: (): StandardGraph =>
521
+ ({
522
+ createWorkflow: (): { invoke: jest.Mock } => ({
523
+ invoke: jest.fn().mockImplementation(
524
+ () =>
525
+ new Promise((_resolve, reject) => {
526
+ releaseChild = (): void =>
527
+ reject(
528
+ new StreamLimitExceededError({
529
+ kind: 'tool_call_args',
530
+ limit: 10,
531
+ observed: 11,
532
+ toolName: 'db_query',
533
+ })
534
+ );
535
+ })
536
+ ),
537
+ }),
538
+ clearHeavyState: jest.fn(),
539
+ }) as unknown as StandardGraph,
540
+ });
541
+
542
+ const pending = executor.execute({
543
+ description: 'Do something',
544
+ subagentType: 'researcher',
545
+ });
546
+ while (releaseChild == null) {
547
+ await new Promise((resolve) => setTimeout(resolve, 0));
548
+ }
549
+ /** The failed run's reset installs a fresh controller while the child
550
+ * is still settling; the straggler's trip must land on the controller
551
+ * captured when its execution started. */
552
+ current = newRun;
553
+ releaseChild();
554
+ await expect(pending).rejects.toBeInstanceOf(StreamLimitExceededError);
555
+ expect(oldRun.signal.aborted).toBe(true);
556
+ expect(newRun.signal.aborted).toBe(false);
557
+ });
558
+
559
+ it('threads run-level streamLimits into every child graph input', async () => {
560
+ const childGraphInputs: StandardGraphInput[] = [];
561
+ const noopFactory = makeNoopGraphFactory();
562
+ const executor = createExecutor({
563
+ streamLimits: { maxToolCallArgBytes: 1234, maxDeltaEventsPerTurn: 9 },
564
+ createChildGraph: (input: StandardGraphInput): StandardGraph => {
565
+ childGraphInputs.push(input);
566
+ return noopFactory();
567
+ },
568
+ });
569
+ await executor.execute({
570
+ description: 'Do something',
571
+ subagentType: 'researcher',
572
+ });
573
+ expect(childGraphInputs).toHaveLength(1);
574
+ expect(childGraphInputs[0].streamLimits).toEqual({
575
+ maxToolCallArgBytes: 1234,
576
+ maxDeltaEventsPerTurn: 9,
577
+ });
578
+ });
579
+
454
580
  it('executes child graph and returns filtered content', async () => {
455
581
  const { factory, clearHeavyState } = makeStubGraphFactory({
456
582
  messages: [
@@ -0,0 +1,389 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { AIMessage, ToolMessage } from '@langchain/core/messages';
4
+ import { describe, it, expect, jest, afterEach } from '@jest/globals';
5
+ import type { StructuredToolInterface } from '@langchain/core/tools';
6
+ import type { RunnableConfig } from '@langchain/core/runnables';
7
+ import type * as t from '@/types';
8
+ import * as events from '@/utils/events';
9
+ import {
10
+ StreamLimitExceededError,
11
+ RUN_BREAKER_SCOPE_CONFIG_KEY,
12
+ } from '@/llm/streamLimits';
13
+ import { GraphEvents } from '@/common';
14
+ import { HookRegistry } from '@/hooks';
15
+ import { ToolNode } from '../ToolNode';
16
+
17
+ function createSignalCaptureTool(name: string): {
18
+ tool: StructuredToolInterface;
19
+ observed: () => AbortSignal | undefined;
20
+ } {
21
+ let signal: AbortSignal | undefined;
22
+ const captureTool = tool(
23
+ async (_input, config) => {
24
+ signal = (config as RunnableConfig | undefined)?.signal;
25
+ return 'captured';
26
+ },
27
+ {
28
+ name,
29
+ description: 'captures the abort signal its runtime receives',
30
+ schema: z.object({}),
31
+ }
32
+ ) as unknown as StructuredToolInterface;
33
+ return { tool: captureTool, observed: () => signal };
34
+ }
35
+
36
+ function createToolCallMessage(id: string, name: string): AIMessage {
37
+ return new AIMessage({ content: '', tool_calls: [{ id, name, args: {} }] });
38
+ }
39
+
40
+ /** A minimal tool whose invoke ignores the abort signal entirely — unlike
41
+ * langchain `tool()` Runnables, which race the signal and reject with its
42
+ * reason the moment a trip fires mid-execution. */
43
+ function createSignalBlindTool(
44
+ name: string,
45
+ fn: () => Promise<string>
46
+ ): StructuredToolInterface {
47
+ return {
48
+ name,
49
+ description: `signal-blind ${name}`,
50
+ invoke: fn,
51
+ } as unknown as StructuredToolInterface;
52
+ }
53
+
54
+ function installToolExecuteResponder(): {
55
+ toolExecuteCalls: t.ToolExecuteBatchRequest[];
56
+ } {
57
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
58
+ jest
59
+ .spyOn(events, 'safeDispatchCustomEvent')
60
+ .mockImplementation(async (event, data): Promise<void> => {
61
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
62
+ return;
63
+ }
64
+ const batch = data as t.ToolExecuteBatchRequest;
65
+ toolExecuteCalls.push(batch);
66
+ batch.resolve(
67
+ batch.toolCalls.map((call) => ({
68
+ toolCallId: call.id,
69
+ status: 'success' as const,
70
+ content: `ok ${call.name}`,
71
+ }))
72
+ );
73
+ });
74
+ return { toolExecuteCalls };
75
+ }
76
+
77
+ describe('ToolNode breaker signal composition', () => {
78
+ afterEach(() => {
79
+ jest.restoreAllMocks();
80
+ });
81
+
82
+ it('exposes the run breaker to direct tool runtimes', async () => {
83
+ const breaker = new AbortController();
84
+ const { tool: capture, observed } = createSignalCaptureTool('capture');
85
+ const node = new ToolNode({
86
+ tools: [capture],
87
+ getBreakerSignal: () => breaker.signal,
88
+ });
89
+
90
+ const result = (await node.invoke({
91
+ messages: [createToolCallMessage('call_1', 'capture')],
92
+ })) as { messages: ToolMessage[] };
93
+
94
+ expect(result.messages[0].content).toBe('captured');
95
+ const signal = observed();
96
+ expect(signal).toBeDefined();
97
+ expect(signal?.aborted).toBe(false);
98
+
99
+ breaker.abort(new Error('stream limit breach'));
100
+ expect(signal?.aborted).toBe(true);
101
+ });
102
+
103
+ it('composes the breaker with the caller signal instead of replacing it', async () => {
104
+ const breaker = new AbortController();
105
+ const caller = new AbortController();
106
+ const { tool: capture, observed } = createSignalCaptureTool('capture');
107
+ const node = new ToolNode({
108
+ tools: [capture],
109
+ getBreakerSignal: () => breaker.signal,
110
+ });
111
+
112
+ await node.invoke(
113
+ { messages: [createToolCallMessage('call_1', 'capture')] },
114
+ { signal: caller.signal }
115
+ );
116
+
117
+ const signal = observed();
118
+ expect(signal).toBeDefined();
119
+ expect(signal?.aborted).toBe(false);
120
+
121
+ caller.abort(new Error('caller cancelled'));
122
+ expect(signal?.aborted).toBe(true);
123
+ });
124
+
125
+ it('leaves the caller signal untouched when no breaker accessor is set', async () => {
126
+ const caller = new AbortController();
127
+ const { tool: capture, observed } = createSignalCaptureTool('capture');
128
+ const node = new ToolNode({ tools: [capture] });
129
+
130
+ await node.invoke(
131
+ { messages: [createToolCallMessage('call_1', 'capture')] },
132
+ { signal: caller.signal }
133
+ );
134
+
135
+ expect(observed()).toBe(caller.signal);
136
+ });
137
+
138
+ it('rejects the batch at entry when the breaker has already tripped', async () => {
139
+ const breaker = new AbortController();
140
+ const trip = new StreamLimitExceededError({
141
+ kind: 'tool_call_args',
142
+ limit: 10,
143
+ observed: 11,
144
+ toolName: 'db_query',
145
+ });
146
+ breaker.abort(trip);
147
+ let toolRan = false;
148
+ const sideEffect = tool(
149
+ async () => {
150
+ toolRan = true;
151
+ return 'ran';
152
+ },
153
+ {
154
+ name: 'side_effect',
155
+ description: 'must never run on a failed run',
156
+ schema: z.object({}),
157
+ }
158
+ ) as unknown as StructuredToolInterface;
159
+ const node = new ToolNode({
160
+ tools: [sideEffect],
161
+ getBreakerSignal: () => breaker.signal,
162
+ });
163
+
164
+ await expect(
165
+ node.invoke({
166
+ messages: [createToolCallMessage('call_1', 'side_effect')],
167
+ })
168
+ ).rejects.toBe(trip);
169
+ expect(toolRan).toBe(false);
170
+ });
171
+
172
+ it('stops regular siblings when an interrupting tool trips the breaker', async () => {
173
+ const breaker = new AbortController();
174
+ const trip = new StreamLimitExceededError({
175
+ kind: 'tool_call_args',
176
+ limit: 10,
177
+ observed: 11,
178
+ toolName: 'db_query',
179
+ });
180
+ let sideEffectRan = false;
181
+ /** Plain tool objects, NOT langchain `tool()`: Runnable invokes race
182
+ * the composed signal and reject with its reason the instant the trip
183
+ * fires, which would mask the stage boundary this test targets. The
184
+ * exposure is exactly tools that ignore cancellation. */
185
+ const tripper = createSignalBlindTool('ask_question', async () => {
186
+ breaker.abort(trip);
187
+ return 'completed normally across the trip';
188
+ });
189
+ const sideEffect = createSignalBlindTool('send_email', async () => {
190
+ sideEffectRan = true;
191
+ return 'sent';
192
+ });
193
+ const node = new ToolNode({
194
+ tools: [tripper, sideEffect],
195
+ interruptingToolNames: new Set(['ask_question']),
196
+ getBreakerSignal: () => breaker.signal,
197
+ });
198
+
199
+ await expect(
200
+ node.invoke({
201
+ messages: [
202
+ new AIMessage({
203
+ content: '',
204
+ tool_calls: [
205
+ { id: 'call_1', name: 'ask_question', args: {} },
206
+ { id: 'call_2', name: 'send_email', args: {} },
207
+ ],
208
+ }),
209
+ ],
210
+ })
211
+ ).rejects.toBe(trip);
212
+ expect(sideEffectRan).toBe(false);
213
+ });
214
+
215
+ it('stops event dispatch when a direct tool trips the breaker mid-batch', async () => {
216
+ const breaker = new AbortController();
217
+ const trip = new StreamLimitExceededError({
218
+ kind: 'tool_call_args',
219
+ limit: 10,
220
+ observed: 11,
221
+ toolName: 'db_query',
222
+ });
223
+ const { toolExecuteCalls } = installToolExecuteResponder();
224
+ const tripper = createSignalBlindTool('direct_tripper', async () => {
225
+ breaker.abort(trip);
226
+ return 'completed normally across the trip';
227
+ });
228
+ const node = new ToolNode({
229
+ tools: [tripper],
230
+ eventDrivenMode: true,
231
+ directToolNames: new Set(['direct_tripper']),
232
+ toolCallStepIds: new Map([
233
+ ['call_1', 'step_1'],
234
+ ['call_2', 'step_2'],
235
+ ]),
236
+ getBreakerSignal: () => breaker.signal,
237
+ });
238
+
239
+ await expect(
240
+ node.invoke({
241
+ messages: [
242
+ new AIMessage({
243
+ content: '',
244
+ tool_calls: [
245
+ { id: 'call_1', name: 'direct_tripper', args: {} },
246
+ { id: 'call_2', name: 'remote_tool', args: {} },
247
+ ],
248
+ }),
249
+ ],
250
+ })
251
+ ).rejects.toBe(trip);
252
+ expect(toolExecuteCalls).toHaveLength(0);
253
+ });
254
+
255
+ it('stops a direct tool when the breaker trips during its PreToolUse hook', async () => {
256
+ const breaker = new AbortController();
257
+ const trip = new StreamLimitExceededError({
258
+ kind: 'tool_call_args',
259
+ limit: 10,
260
+ observed: 11,
261
+ toolName: 'db_query',
262
+ });
263
+ let toolRan = false;
264
+ const sideEffect = createSignalBlindTool('send_email', async () => {
265
+ toolRan = true;
266
+ return 'sent';
267
+ });
268
+ const registry = new HookRegistry();
269
+ registry.register('PreToolUse', {
270
+ hooks: [
271
+ async () => {
272
+ breaker.abort(trip);
273
+ return { decision: 'allow' };
274
+ },
275
+ ],
276
+ });
277
+ const node = new ToolNode({
278
+ tools: [sideEffect],
279
+ hookRegistry: registry,
280
+ getBreakerSignal: () => breaker.signal,
281
+ });
282
+
283
+ await expect(
284
+ node.invoke({
285
+ messages: [createToolCallMessage('call_1', 'send_email')],
286
+ })
287
+ ).rejects.toBe(trip);
288
+ expect(toolRan).toBe(false);
289
+ });
290
+
291
+ it('stops the host batch when the breaker trips during approval hooks', async () => {
292
+ const breaker = new AbortController();
293
+ const trip = new StreamLimitExceededError({
294
+ kind: 'tool_call_args',
295
+ limit: 10,
296
+ observed: 11,
297
+ toolName: 'db_query',
298
+ });
299
+ const { toolExecuteCalls } = installToolExecuteResponder();
300
+ const registry = new HookRegistry();
301
+ registry.register('PreToolUse', {
302
+ hooks: [
303
+ async () => {
304
+ breaker.abort(trip);
305
+ return { decision: 'allow' };
306
+ },
307
+ ],
308
+ });
309
+ const node = new ToolNode({
310
+ tools: [],
311
+ eventDrivenMode: true,
312
+ hookRegistry: registry,
313
+ toolCallStepIds: new Map([['call_1', 'step_1']]),
314
+ getBreakerSignal: () => breaker.signal,
315
+ });
316
+
317
+ await expect(
318
+ node.invoke({
319
+ messages: [createToolCallMessage('call_1', 'remote_tool')],
320
+ })
321
+ ).rejects.toBe(trip);
322
+ expect(toolExecuteCalls).toHaveLength(0);
323
+ });
324
+
325
+ it('stamps the batch-entry run scope into tool configs and strips it from host batches', async () => {
326
+ const scope = Object.freeze({ epoch: 3, controller: new AbortController() });
327
+ let seenScope: unknown;
328
+ const capture = {
329
+ name: 'capture_scope',
330
+ description: 'captures the batch scope from its config',
331
+ invoke: async (
332
+ _params: unknown,
333
+ config?: { configurable?: Record<string, unknown> }
334
+ ): Promise<string> => {
335
+ seenScope = config?.configurable?.[RUN_BREAKER_SCOPE_CONFIG_KEY];
336
+ return 'ok';
337
+ },
338
+ } as unknown as StructuredToolInterface;
339
+ const node = new ToolNode({
340
+ tools: [capture],
341
+ getRunScope: () => scope,
342
+ });
343
+
344
+ await node.invoke({
345
+ messages: [createToolCallMessage('call_1', 'capture_scope')],
346
+ });
347
+ expect(seenScope).toBe(scope);
348
+
349
+ /** Host batch requests spread `configurable` into their own run
350
+ * configs — the scope must not leak there. */
351
+ const { toolExecuteCalls } = installToolExecuteResponder();
352
+ const eventNode = new ToolNode({
353
+ tools: [],
354
+ eventDrivenMode: true,
355
+ toolCallStepIds: new Map([['call_1', 'step_1']]),
356
+ getRunScope: () => scope,
357
+ });
358
+ await eventNode.invoke({
359
+ messages: [createToolCallMessage('call_1', 'remote_tool')],
360
+ });
361
+ expect(toolExecuteCalls).toHaveLength(1);
362
+ expect(
363
+ toolExecuteCalls[0].configurable?.[RUN_BREAKER_SCOPE_CONFIG_KEY]
364
+ ).toBeUndefined();
365
+ });
366
+
367
+ it('sends a breaker-composed signal on ON_TOOL_EXECUTE batch requests', async () => {
368
+ const breaker = new AbortController();
369
+ const { toolExecuteCalls } = installToolExecuteResponder();
370
+ const node = new ToolNode({
371
+ tools: [],
372
+ eventDrivenMode: true,
373
+ toolCallStepIds: new Map([['call_1', 'step_1']]),
374
+ getBreakerSignal: () => breaker.signal,
375
+ });
376
+
377
+ await node.invoke({
378
+ messages: [createToolCallMessage('call_1', 'remote_tool')],
379
+ });
380
+
381
+ expect(toolExecuteCalls).toHaveLength(1);
382
+ const { signal } = toolExecuteCalls[0];
383
+ expect(signal).toBeDefined();
384
+ expect(signal?.aborted).toBe(false);
385
+
386
+ breaker.abort(new Error('stream limit breach'));
387
+ expect(signal?.aborted).toBe(true);
388
+ });
389
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `StreamLimitExceededError` must pass through ToolNode's tool-error
3
+ * conversion like a `GraphInterrupt`: a child run (subagent) that trips a
4
+ * stream circuit breaker is a safety abort, and converting it into an error
5
+ * ToolMessage would let the parent keep generating after the limit fired.
6
+ */
7
+ import { z } from 'zod';
8
+ import { tool } from '@langchain/core/tools';
9
+ import { AIMessage } from '@langchain/core/messages';
10
+ import { describe, it, expect } from '@jest/globals';
11
+ import type { StructuredToolInterface } from '@langchain/core/tools';
12
+ import { StreamLimitExceededError } from '@/llm/streamLimits';
13
+ import { ToolNode } from '@/tools/ToolNode';
14
+
15
+ const stateWith = (name: string): { messages: AIMessage[] } => ({
16
+ messages: [
17
+ new AIMessage({
18
+ content: '',
19
+ tool_calls: [{ id: 'call_1', name, args: { command: 'x' } }],
20
+ }),
21
+ ],
22
+ });
23
+
24
+ describe('ToolNode stream-limit passthrough', () => {
25
+ it('rethrows StreamLimitExceededError instead of converting it to a ToolMessage', async () => {
26
+ const limitBoom = tool(
27
+ async () => {
28
+ throw new StreamLimitExceededError({
29
+ kind: 'tool_call_args',
30
+ limit: 10,
31
+ observed: 11,
32
+ toolName: 'db_query',
33
+ });
34
+ },
35
+ {
36
+ name: 'limitBoom',
37
+ description: 'tool that trips a stream limit',
38
+ schema: z.object({ command: z.string() }),
39
+ }
40
+ ) as unknown as StructuredToolInterface;
41
+
42
+ const node = new ToolNode({ tools: [limitBoom] });
43
+ await expect(
44
+ node.invoke(stateWith('limitBoom'), {
45
+ configurable: { run_id: 'limit-run' },
46
+ })
47
+ ).rejects.toBeInstanceOf(StreamLimitExceededError);
48
+ });
49
+
50
+ it('still converts ordinary tool errors to error ToolMessages', async () => {
51
+ const plainBoom = tool(
52
+ async () => {
53
+ throw new Error('ordinary failure');
54
+ },
55
+ {
56
+ name: 'plainBoom',
57
+ description: 'tool that throws a normal error',
58
+ schema: z.object({ command: z.string() }),
59
+ }
60
+ ) as unknown as StructuredToolInterface;
61
+
62
+ const node = new ToolNode({ tools: [plainBoom] });
63
+ const result = (await node.invoke(stateWith('plainBoom'), {
64
+ configurable: { run_id: 'plain-run' },
65
+ })) as { messages: Array<{ content: unknown; status?: string }> };
66
+ expect(result.messages).toHaveLength(1);
67
+ expect(String(result.messages[0].content)).toContain('ordinary failure');
68
+ });
69
+ });
@@ -27,6 +27,8 @@ export class CrwScraper implements t.BaseScraper {
27
27
  private xpath?: string;
28
28
  private proxy?: string;
29
29
  private stealth?: boolean;
30
+ private httpAgent?: t.HttpAgent;
31
+ private httpsAgent?: t.HttpsAgent;
30
32
 
31
33
  constructor(config: t.CrwScraperConfig = {}) {
32
34
  this.apiKey = config.apiKey ?? process.env.CRW_API_KEY ?? '';
@@ -49,6 +51,8 @@ export class CrwScraper implements t.BaseScraper {
49
51
  this.xpath = config.xpath;
50
52
  this.proxy = config.proxy;
51
53
  this.stealth = config.stealth;
54
+ this.httpAgent = config.httpAgent;
55
+ this.httpsAgent = config.httpsAgent;
52
56
 
53
57
  // Self-host fastCRW may run without auth, so a missing key is only a
54
58
  // warning — unlike Firecrawl/Tavily, scrapeUrl does NOT early-return on it.
@@ -96,6 +100,8 @@ export class CrwScraper implements t.BaseScraper {
96
100
  {
97
101
  headers,
98
102
  timeout: payloadTimeout + CRW_TIMEOUT_BUFFER,
103
+ httpAgent: this.httpAgent,
104
+ httpsAgent: this.httpsAgent,
99
105
  }
100
106
  );
101
107
 
@@ -91,7 +91,12 @@ export const createCrwAPI = (
91
91
  const response = await axios.post<t.CrwSearchResponse>(
92
92
  config.apiUrl,
93
93
  payload,
94
- { headers, timeout: config.timeout }
94
+ {
95
+ headers,
96
+ timeout: config.timeout,
97
+ httpAgent: options?.httpAgent,
98
+ httpsAgent: options?.httpsAgent,
99
+ }
95
100
  );
96
101
 
97
102
  const body = response.data;
@@ -29,6 +29,8 @@ export class FirecrawlScraper implements t.BaseScraper {
29
29
  private location?: { country?: string; languages?: string[] };
30
30
  private onlyMainContent?: boolean;
31
31
  private changeTrackingOptions?: object;
32
+ private httpAgent?: t.HttpAgent;
33
+ private httpsAgent?: t.HttpsAgent;
32
34
 
33
35
  constructor(config: t.FirecrawlScraperConfig = {}) {
34
36
  this.apiKey = config.apiKey ?? process.env.FIRECRAWL_API_KEY ?? '';
@@ -61,6 +63,8 @@ export class FirecrawlScraper implements t.BaseScraper {
61
63
  this.location = config.location;
62
64
  this.onlyMainContent = config.onlyMainContent;
63
65
  this.changeTrackingOptions = config.changeTrackingOptions;
66
+ this.httpAgent = config.httpAgent;
67
+ this.httpsAgent = config.httpsAgent;
64
68
 
65
69
  if (!this.apiKey) {
66
70
  this.logger.warn('FIRECRAWL_API_KEY is not set. Scraping will not work.');
@@ -121,6 +125,8 @@ export class FirecrawlScraper implements t.BaseScraper {
121
125
  Authorization: `Bearer ${this.apiKey}`,
122
126
  },
123
127
  timeout: this.timeout,
128
+ httpAgent: this.httpAgent,
129
+ httpsAgent: this.httpsAgent,
124
130
  });
125
131
 
126
132
  return [url, response.data];