@librechat/agents 3.3.11 → 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 (192) hide show
  1. package/dist/cjs/graphs/Graph.cjs +246 -25
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  4. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  5. package/dist/cjs/llm/invoke.cjs +146 -36
  6. package/dist/cjs/llm/invoke.cjs.map +1 -1
  7. package/dist/cjs/llm/openai/index.cjs +2 -2
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/llm/streamLimits.cjs +723 -0
  10. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  11. package/dist/cjs/main.cjs +8 -0
  12. package/dist/cjs/run.cjs +6 -2
  13. package/dist/cjs/run.cjs.map +1 -1
  14. package/dist/cjs/session/AgentSession.cjs +4 -1
  15. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  16. package/dist/cjs/stream.cjs +103 -12
  17. package/dist/cjs/stream.cjs.map +1 -1
  18. package/dist/cjs/summarization/node.cjs +157 -37
  19. package/dist/cjs/summarization/node.cjs.map +1 -1
  20. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  21. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  22. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  23. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  24. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  25. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  26. package/dist/cjs/tools/ToolNode.cjs +76 -3
  27. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  28. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  29. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  30. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  31. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  32. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  33. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  34. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  35. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  36. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  37. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  38. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  39. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  40. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  41. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  42. package/dist/cjs/tools/search/search.cjs +30 -10
  43. package/dist/cjs/tools/search/search.cjs.map +1 -1
  44. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  45. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  46. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  47. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  48. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  49. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  50. package/dist/cjs/tools/search/tool.cjs +16 -2
  51. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  52. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  53. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  54. package/dist/cjs/utils/index.cjs +1 -0
  55. package/dist/cjs/utils/misc.cjs +12 -0
  56. package/dist/cjs/utils/misc.cjs.map +1 -1
  57. package/dist/cjs/utils/proxy.cjs +63 -0
  58. package/dist/cjs/utils/proxy.cjs.map +1 -0
  59. package/dist/esm/graphs/Graph.mjs +245 -24
  60. package/dist/esm/graphs/Graph.mjs.map +1 -1
  61. package/dist/esm/llm/bedrock/index.mjs +13 -2
  62. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  63. package/dist/esm/llm/invoke.mjs +146 -36
  64. package/dist/esm/llm/invoke.mjs.map +1 -1
  65. package/dist/esm/llm/openai/index.mjs +2 -2
  66. package/dist/esm/llm/openai/index.mjs.map +1 -1
  67. package/dist/esm/llm/streamLimits.mjs +704 -0
  68. package/dist/esm/llm/streamLimits.mjs.map +1 -0
  69. package/dist/esm/main.mjs +4 -2
  70. package/dist/esm/run.mjs +6 -2
  71. package/dist/esm/run.mjs.map +1 -1
  72. package/dist/esm/session/AgentSession.mjs +4 -1
  73. package/dist/esm/session/AgentSession.mjs.map +1 -1
  74. package/dist/esm/stream.mjs +103 -12
  75. package/dist/esm/stream.mjs.map +1 -1
  76. package/dist/esm/summarization/node.mjs +157 -37
  77. package/dist/esm/summarization/node.mjs.map +1 -1
  78. package/dist/esm/tools/BashExecutor.mjs +3 -2
  79. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  80. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  81. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  82. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  83. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  84. package/dist/esm/tools/ToolNode.mjs +76 -3
  85. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  86. package/dist/esm/tools/ToolSearch.mjs +3 -2
  87. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  88. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  89. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  90. package/dist/esm/tools/search/crw-search.mjs +3 -1
  91. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  92. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  93. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  94. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  95. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  96. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  97. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  98. package/dist/esm/tools/search/rerankers.mjs +26 -8
  99. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  100. package/dist/esm/tools/search/search.mjs +30 -10
  101. package/dist/esm/tools/search/search.mjs.map +1 -1
  102. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  103. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  104. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  105. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  106. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  107. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  108. package/dist/esm/tools/search/tool.mjs +16 -2
  109. package/dist/esm/tools/search/tool.mjs.map +1 -1
  110. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  111. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  112. package/dist/esm/utils/index.mjs +1 -0
  113. package/dist/esm/utils/misc.mjs +12 -1
  114. package/dist/esm/utils/misc.mjs.map +1 -1
  115. package/dist/esm/utils/proxy.mjs +62 -0
  116. package/dist/esm/utils/proxy.mjs.map +1 -0
  117. package/dist/types/graphs/Graph.d.ts +53 -1
  118. package/dist/types/index.d.ts +2 -0
  119. package/dist/types/llm/invoke.d.ts +25 -4
  120. package/dist/types/llm/openai/index.d.ts +3 -0
  121. package/dist/types/llm/streamLimits.d.ts +314 -0
  122. package/dist/types/run.d.ts +1 -0
  123. package/dist/types/summarization/node.d.ts +27 -2
  124. package/dist/types/tools/BashExecutor.d.ts +2 -2
  125. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  126. package/dist/types/tools/ToolNode.d.ts +11 -1
  127. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  128. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  129. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  130. package/dist/types/tools/search/rerankers.d.ts +9 -5
  131. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  132. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  133. package/dist/types/tools/search/types.d.ts +28 -33
  134. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  135. package/dist/types/types/graph.d.ts +7 -1
  136. package/dist/types/types/run.d.ts +46 -1
  137. package/dist/types/types/tools.d.ts +21 -0
  138. package/dist/types/utils/index.d.ts +1 -0
  139. package/dist/types/utils/misc.d.ts +7 -0
  140. package/dist/types/utils/proxy.d.ts +31 -0
  141. package/package.json +2 -1
  142. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  143. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  144. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  145. package/src/graphs/Graph.ts +302 -27
  146. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  147. package/src/index.ts +11 -0
  148. package/src/llm/bedrock/index.ts +22 -2
  149. package/src/llm/invoke.streamLimits.test.ts +142 -0
  150. package/src/llm/invoke.test.ts +89 -1
  151. package/src/llm/invoke.ts +197 -20
  152. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  153. package/src/llm/openai/index.ts +14 -6
  154. package/src/llm/streamLimits.test.ts +450 -0
  155. package/src/llm/streamLimits.ts +1158 -0
  156. package/src/run.ts +4 -0
  157. package/src/session/AgentSession.ts +5 -0
  158. package/src/stream.ts +141 -6
  159. package/src/summarization/__tests__/node.test.ts +269 -0
  160. package/src/summarization/chunkHandler.test.ts +196 -0
  161. package/src/summarization/node.ts +203 -6
  162. package/src/tools/BashExecutor.ts +4 -3
  163. package/src/tools/CodeExecutor.ts +5 -4
  164. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  165. package/src/tools/ToolNode.ts +109 -6
  166. package/src/tools/ToolSearch.ts +4 -3
  167. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  168. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  169. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  170. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  171. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  172. package/src/tools/search/crw-scraper.ts +6 -0
  173. package/src/tools/search/crw-search.ts +6 -1
  174. package/src/tools/search/firecrawl.ts +6 -0
  175. package/src/tools/search/http-agent.test.ts +133 -0
  176. package/src/tools/search/keenable-scraper.ts +6 -0
  177. package/src/tools/search/keenable-search.ts +6 -1
  178. package/src/tools/search/rerankers.ts +36 -10
  179. package/src/tools/search/search.ts +29 -11
  180. package/src/tools/search/serper-scraper.ts +6 -0
  181. package/src/tools/search/tavily-scraper.ts +6 -0
  182. package/src/tools/search/tavily-search.ts +2 -0
  183. package/src/tools/search/tool.ts +16 -0
  184. package/src/tools/search/types.ts +31 -33
  185. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  186. package/src/types/graph.ts +7 -0
  187. package/src/types/run.ts +49 -1
  188. package/src/types/tools.ts +21 -0
  189. package/src/utils/index.ts +1 -0
  190. package/src/utils/misc.ts +19 -0
  191. package/src/utils/proxy.test.ts +176 -0
  192. package/src/utils/proxy.ts +93 -0
@@ -28,6 +28,10 @@ import type { AgentContext } from '@/agents/AgentContext';
28
28
  import type { StandardGraph } from '@/graphs/Graph';
29
29
  import type { HandlerRegistry } from '@/events';
30
30
  import { Constants, GraphEvents, Callback, StepTypes } from '@/common';
31
+ import {
32
+ StreamLimitExceededError,
33
+ RUN_BREAKER_SCOPE_CONFIG_KEY,
34
+ } from '@/llm/streamLimits';
31
35
  import { executeHooks } from '@/hooks';
32
36
 
33
37
  const DEFAULT_MAX_TURNS = 25;
@@ -157,6 +161,14 @@ export type SubagentExecuteParams = {
157
161
  description: string;
158
162
  subagentType: string;
159
163
  threadId?: string;
164
+ /**
165
+ * Breaker controller captured at the parent TOOL BATCH's entry, before
166
+ * PreToolUse hooks. Preferred over the live scope accessor: a graph reset
167
+ * during a hook would otherwise bind this child to the NEW run's
168
+ * controller — reviving it on a fresh signal and letting its trips cancel
169
+ * unrelated work.
170
+ */
171
+ breaker?: AbortController;
160
172
  /**
161
173
  * Parent-side `tool_call_id` of the `subagent` tool invocation that
162
174
  * triggered this execution. Surfaced on {@link SubagentUpdateEvent} so
@@ -214,11 +226,31 @@ export type ChildGraphFactory = (input: StandardGraphInput) => StandardGraph;
214
226
  export type SubagentExecutorOptions = {
215
227
  configs: Map<string, ResolvedSubagentConfig>;
216
228
  parentSignal?: AbortSignal;
229
+ /** Run-scoped breaker abort shared by every executor of one graph, so a
230
+ * child tripping a stream limit stops subagents running under OTHER
231
+ * parallel agent nodes too. An accessor rather than a captured controller:
232
+ * the graph recreates its controller per run, and each execution must
233
+ * bind to the controller current when it STARTS — signal and trip target
234
+ * together, so a straggler from a failed run can neither revive on nor
235
+ * circuit-break a later run's controller. Absent (tests, minimal hosts),
236
+ * the executor falls back to its own private controller. */
237
+ breakerScope?: {
238
+ controller: () => AbortController;
239
+ };
217
240
  hookRegistry?: HookRegistry;
218
241
  parentRunId: string;
219
242
  parentAgentId?: string;
220
243
  langfuse?: StandardGraphInput['langfuse'];
221
244
  tokenCounter?: TokenCounter;
245
+ /**
246
+ * Run-level stream circuit breakers, forwarded into every child graph so
247
+ * a host raising, lowering, or disabling the limits governs subagents too.
248
+ * Child model calls run through `attemptInvoke`'s local stream handler
249
+ * (children have no registered dispatcher), which enforces the child
250
+ * graph's own resolved limits; without this the child would silently
251
+ * revert to the defaults.
252
+ */
253
+ streamLimits?: StandardGraphInput['streamLimits'];
222
254
  /** Remaining nesting budget. 0 or negative blocks execution. */
223
255
  maxDepth?: number;
224
256
  /**
@@ -254,11 +286,20 @@ export type SubagentExecutorOptions = {
254
286
  export class SubagentExecutor {
255
287
  private readonly configs: Map<string, ResolvedSubagentConfig>;
256
288
  private readonly parentSignal?: AbortSignal;
289
+ /** Aborted when a child trips a stream circuit breaker: parallel sibling
290
+ * subagents run concurrently on the parent's signal, and rejecting the
291
+ * batch alone would leave their provider requests streaming after the
292
+ * safety abort. One-way by design — a tripped breaker ends the run, so no
293
+ * later child of this executor should start either. Fallback for hosts
294
+ * that do not supply the graph's shared `breakerScope`. */
295
+ private readonly childRunAbort = new AbortController();
296
+ private readonly breakerScope?: SubagentExecutorOptions['breakerScope'];
257
297
  private readonly hookRegistry?: HookRegistry;
258
298
  private readonly parentRunId: string;
259
299
  private readonly parentAgentId?: string;
260
300
  private readonly langfuse?: StandardGraphInput['langfuse'];
261
301
  private readonly tokenCounter?: TokenCounter;
302
+ private readonly streamLimits?: StandardGraphInput['streamLimits'];
262
303
  private readonly maxDepth: number;
263
304
  private readonly createChildGraph: ChildGraphFactory;
264
305
  private readonly usageSink?: SubagentUsageSink;
@@ -269,11 +310,13 @@ export class SubagentExecutor {
269
310
  constructor(options: SubagentExecutorOptions) {
270
311
  this.configs = options.configs;
271
312
  this.parentSignal = options.parentSignal;
313
+ this.breakerScope = options.breakerScope;
272
314
  this.hookRegistry = options.hookRegistry;
273
315
  this.parentRunId = options.parentRunId;
274
316
  this.parentAgentId = options.parentAgentId;
275
317
  this.langfuse = options.langfuse;
276
318
  this.tokenCounter = options.tokenCounter;
319
+ this.streamLimits = options.streamLimits;
277
320
  this.maxDepth = options.maxDepth ?? 1;
278
321
  this.createChildGraph = options.createChildGraph;
279
322
  this.usageSink = options.usageSink;
@@ -285,6 +328,23 @@ export class SubagentExecutor {
285
328
  }
286
329
  }
287
330
 
331
+ /** The breaker controller current for this execution — read per spawn
332
+ * because the graph recreates its controller each run. */
333
+ private resolveBreakerController(): AbortController {
334
+ return this.breakerScope?.controller() ?? this.childRunAbort;
335
+ }
336
+
337
+ /** One signal that fires on the parent's abort or the breaker abort,
338
+ * collapsed to a single signal when possible (mirrors
339
+ * `composeAbortSignals` in Graph.ts). */
340
+ private composeChildSignal(breaker: AbortController): AbortSignal {
341
+ const child = breaker.signal;
342
+ if (this.parentSignal == null || this.parentSignal === child) {
343
+ return child;
344
+ }
345
+ return AbortSignal.any([this.parentSignal, child]);
346
+ }
347
+
288
348
  /** Snapshot of the parent's registry at the moment a subagent is dispatched. */
289
349
  private getParentHandlerRegistry(): HandlerRegistry | undefined {
290
350
  return this.resolveParentHandlerRegistry?.();
@@ -292,6 +352,15 @@ export class SubagentExecutor {
292
352
 
293
353
  async execute(params: SubagentExecuteParams): Promise<SubagentExecuteResult> {
294
354
  const { description, subagentType, threadId, parentToolCallId } = params;
355
+ /** Captured ONCE per execution, preferring the controller the parent
356
+ * tool batch captured at ITS entry (before PreToolUse hooks): a failed
357
+ * run's graph reset replaces the live controller, and resolving it here
358
+ * — after the hook awaits — would bind this child to the NEW run's
359
+ * un-aborted controller: reviving old-run work, or worse, tripping the
360
+ * new run's breaker from an old child's stream-limit breach. Signal and
361
+ * trip target both bind to this capture. */
362
+ const childBreaker = params.breaker ?? this.resolveBreakerController();
363
+ const childSignal = this.composeChildSignal(childBreaker);
295
364
  const config = this.configs.get(subagentType);
296
365
 
297
366
  if (!config) {
@@ -369,10 +438,11 @@ export class SubagentExecutor {
369
438
  const hostUsageSink = this.usageSink;
370
439
  const childGraph = this.createChildGraph({
371
440
  runId: childRunId,
372
- signal: this.parentSignal,
441
+ signal: childSignal,
373
442
  agents: [childInputs],
374
443
  langfuse: this.langfuse,
375
444
  tokenCounter: this.tokenCounter,
445
+ streamLimits: this.streamLimits,
376
446
  subagentScope: true,
377
447
  /**
378
448
  * Forwarded so the child graph's own `SubagentExecutor` (created in
@@ -501,7 +571,7 @@ export class SubagentExecutor {
501
571
  { messages: [new HumanMessage(description)] },
502
572
  {
503
573
  recursionLimit: maxTurns * RECURSION_MULTIPLIER,
504
- signal: this.parentSignal,
574
+ signal: childSignal,
505
575
  callbacks,
506
576
  runName: `subagent:${subagentType}`,
507
577
  configurable: {
@@ -511,6 +581,16 @@ export class SubagentExecutor {
511
581
  }
512
582
  );
513
583
  } catch (error) {
584
+ /** Aborted before any observational work below: parallel siblings — in
585
+ * this executor and, via the graph-scoped breaker, under other
586
+ * parallel agent nodes — stream on the composed child signal, and
587
+ * awaiting forwarding.drain() first would let them consume provider
588
+ * quota for that entire interval. Trips the ENTRY-captured controller:
589
+ * after a reset, a straggler must break its own dead run, not the
590
+ * current one. */
591
+ if (error instanceof StreamLimitExceededError) {
592
+ childBreaker.abort(error);
593
+ }
514
594
  const errorMessage = truncateErrorMessage(error);
515
595
  if (forwarding) {
516
596
  await forwarding.drain();
@@ -525,6 +605,16 @@ export class SubagentExecutor {
525
605
  });
526
606
  }
527
607
  childGraph.clearHeavyState();
608
+ /**
609
+ * A tripped stream circuit breaker is a safety abort, not a recoverable
610
+ * subagent failure: converting it into a tool result would let the
611
+ * parent keep generating (or spawn another child) after the limit
612
+ * fired. Rethrown here and passed through ToolNode's error conversion,
613
+ * so the parent run rejects with the child's limit error.
614
+ */
615
+ if (error instanceof StreamLimitExceededError) {
616
+ throw error;
617
+ }
528
618
  return {
529
619
  content: `Subagent error: ${errorMessage}`,
530
620
  messages: [],
@@ -956,7 +1046,10 @@ function sanitizeChildConfigurable(
956
1046
  function isLangGraphRuntimeConfigKey(key: string): boolean {
957
1047
  return (
958
1048
  key.startsWith(LANGGRAPH_RUNTIME_CONFIG_PREFIX) ||
959
- LANGGRAPH_CHECKPOINT_CONFIG_KEYS.has(key)
1049
+ LANGGRAPH_CHECKPOINT_CONFIG_KEYS.has(key) ||
1050
+ /** The parent batch's breaker scope must not leak into the child
1051
+ * workflow's configurable — children own separate controllers. */
1052
+ key === RUN_BREAKER_SCOPE_CONFIG_KEY
960
1053
  );
961
1054
  }
962
1055
 
@@ -32,6 +32,7 @@ import type {
32
32
  } from '@/types/stream';
33
33
  import type {
34
34
  TokenCounter,
35
+ StreamLimits,
35
36
  StreamPreemption,
36
37
  TokenBudgetBreakdown,
37
38
  } from '@/types/run';
@@ -357,6 +358,12 @@ export type StandardGraphInput = {
357
358
  * this field.
358
359
  */
359
360
  preemption?: StreamPreemption;
361
+ /**
362
+ * Stream circuit breakers, forwarded from `RunConfig.streamLimits` and
363
+ * resolved once at graph construction. Enforced by the stream handler on
364
+ * every streamed chunk event. See {@link StreamLimits}.
365
+ */
366
+ streamLimits?: StreamLimits;
360
367
  };
361
368
 
362
369
  export type GraphEdge = {
package/src/types/run.ts CHANGED
@@ -28,7 +28,13 @@ export type BaseGraphConfig = {
28
28
  };
29
29
  export type LegacyGraphConfig = BaseGraphConfig & {
30
30
  type?: 'standard';
31
- } & Omit<g.StandardGraphInput, 'provider' | 'clientOptions' | 'agents'> &
31
+ } & Omit<
32
+ g.StandardGraphInput,
33
+ /** `streamLimits` is excluded because legacy graphs receive limits only
34
+ * via the top-level `RunConfig.streamLimits`; accepting the field here
35
+ * would type-check but be silently ignored by `createLegacyGraph`. */
36
+ 'provider' | 'clientOptions' | 'agents' | 'streamLimits'
37
+ > &
32
38
  Omit<g.AgentInputs, 'provider' | 'clientOptions' | 'agentId'>;
33
39
 
34
40
  /* Supervised graph (opt-in) */
@@ -174,6 +180,41 @@ export type PreemptStats = {
174
180
  emptyBoundaries: number;
175
181
  };
176
182
 
183
+ /**
184
+ * Circuit breakers for pathological model streams. A malformed generation
185
+ * can stream a single tool call's arguments for many minutes while they
186
+ * never become executable (observed live: one 149,923-char SQL argument
187
+ * streamed for 26 minutes before the 64k output-token ceiling ended the
188
+ * run). When a limit trips, the run aborts with a `StreamLimitExceededError`
189
+ * and the in-flight provider request is torn down mid-stream.
190
+ */
191
+ export interface StreamLimits {
192
+ /**
193
+ * Max cumulative UTF-8 bytes a single streamed tool call's arguments may
194
+ * reach before the run is aborted. Defaults to
195
+ * `DEFAULT_MAX_TOOL_CALL_ARG_BYTES` (64 KiB); `0` disables the guard.
196
+ */
197
+ maxToolCallArgBytes?: number;
198
+ /**
199
+ * Per-tool overrides for `maxToolCallArgBytes`, keyed by the model-facing
200
+ * tool name. A matching entry replaces the global cap for that tool's
201
+ * calls (`0` disables the guard for that tool only). Tools that
202
+ * legitimately stream whole documents as arguments (e.g. file creation)
203
+ * can run with a higher cap without loosening every other tool's guard.
204
+ * Calls whose tool name has not yet arrived on the stream use the global
205
+ * cap; providers send the name in the first chunk, so this only matters
206
+ * for malformed streams.
207
+ */
208
+ maxToolCallArgBytesByTool?: Record<string, number>;
209
+ /**
210
+ * Max streamed chunk events a single model generation (turn) may emit
211
+ * before the run is aborted. Defense in depth against looping or
212
+ * duplicated provider streams that a byte limit cannot see (e.g. endless
213
+ * empty chunks). Disabled by default; `0` also disables.
214
+ */
215
+ maxDeltaEventsPerTurn?: number;
216
+ }
217
+
177
218
  export type RunConfig = {
178
219
  runId: string;
179
220
  graphConfig: LegacyGraphConfig | StandardGraphConfig | MultiAgentGraphConfig;
@@ -213,6 +254,13 @@ export type RunConfig = {
213
254
  * tool boundary.
214
255
  */
215
256
  preemption?: StreamPreemption;
257
+ /**
258
+ * Circuit breakers for pathological model streams (runaway tool-call
259
+ * argument generation, looping delta streams). Omit for defaults: the
260
+ * tool-call argument byte cap is ON by default, the per-turn event cap is
261
+ * opt-in. See {@link StreamLimits}.
262
+ */
263
+ streamLimits?: StreamLimits;
216
264
  returnContent?: boolean;
217
265
  tokenCounter?: TokenCounter;
218
266
  indexTokenCountMap?: Record<string, number>;
@@ -3,6 +3,7 @@ import type { StructuredToolInterface } from '@langchain/core/tools';
3
3
  import type { RunnableToolLike } from '@langchain/core/runnables';
4
4
  import type { ToolCall } from '@langchain/core/messages/tool';
5
5
  import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
6
+ import type { RunBreakerScope } from '@/llm/streamLimits';
6
7
  import type { MessageContentComplex, ToolErrorData } from './stream';
7
8
  import type { HumanInTheLoopConfig } from './hitl';
8
9
  import type { LangfuseConfig } from './graph';
@@ -259,6 +260,20 @@ export type ToolNodeOptions = {
259
260
  * `resolveLocalExecutionTools`.
260
261
  */
261
262
  fileCheckpointer?: LocalFileCheckpointer;
263
+ /**
264
+ * Returns the owning graph's run-scoped breaker signal. Read once per
265
+ * `run()` invocation and composed into the batch config's `signal`, so
266
+ * every tool execution in the batch aborts when a stream circuit breaker
267
+ * trips elsewhere in the run.
268
+ */
269
+ getBreakerSignal?: () => AbortSignal | undefined;
270
+ /**
271
+ * Returns the owning graph's immutable run scope. Read once per batch,
272
+ * BEFORE hooks, and threaded to tools that spawn runs (subagents) so a
273
+ * reset during a hook cannot rebind their children to a newer run's
274
+ * controller.
275
+ */
276
+ getRunScope?: () => RunBreakerScope;
262
277
  };
263
278
 
264
279
  export type ToolNodeConstructorParams = ToolRefs & ToolNodeOptions;
@@ -523,6 +538,12 @@ export type ToolExecuteBatchRequest = {
523
538
  configurable?: Record<string, unknown>;
524
539
  /** Runtime metadata from RunnableConfig (includes thread_id, run_id, provider, etc.) */
525
540
  metadata?: Record<string, unknown>;
541
+ /**
542
+ * Aborts when the run is cancelled or its stream circuit breaker trips.
543
+ * Handlers SHOULD forward this to their tool executions so in-flight work
544
+ * stops consuming quota once the run is already failing.
545
+ */
546
+ signal?: AbortSignal;
526
547
  /** Promise resolver - handler calls this with ALL results */
527
548
  resolve: (results: ToolExecuteResult[]) => void;
528
549
  /** Promise rejector - handler calls this on fatal error */
@@ -1,6 +1,7 @@
1
1
  export * from './graph';
2
2
  export * from './llm';
3
3
  export * from './misc';
4
+ export * from './proxy';
4
5
  export * from './handlers';
5
6
  export * from './run';
6
7
  export * from './tokens';
package/src/utils/misc.ts CHANGED
@@ -73,3 +73,22 @@ export function unescapeObject(obj: unknown, key?: string): unknown {
73
73
  }
74
74
  return obj;
75
75
  }
76
+
77
+ /**
78
+ * One signal that fires when either input fires. `AbortSignal.any` is skipped
79
+ * when the inputs collapse to a single signal — the composite is a fresh
80
+ * object per call, and the common cases (one channel, or the host reusing the
81
+ * same controller for both) don't need one.
82
+ */
83
+ export function composeAbortSignals(
84
+ a: AbortSignal | undefined,
85
+ b: AbortSignal | undefined
86
+ ): AbortSignal | undefined {
87
+ if (a == null || a === b) {
88
+ return b;
89
+ }
90
+ if (b == null) {
91
+ return a;
92
+ }
93
+ return AbortSignal.any([a, b]);
94
+ }
@@ -0,0 +1,176 @@
1
+ import { HttpsProxyAgent } from 'https-proxy-agent';
2
+ import { SocksProxyAgent } from 'socks-proxy-agent';
3
+ import { resolveFetchProxyAgent, shouldBypassProxy } from './proxy';
4
+
5
+ const CODE_ENDPOINT = 'http://codeapi:3112/v1/exec';
6
+
7
+ describe('shouldBypassProxy', () => {
8
+ it('is false without a NO_PROXY list', () => {
9
+ expect(shouldBypassProxy(CODE_ENDPOINT, undefined)).toBe(false);
10
+ expect(shouldBypassProxy(CODE_ENDPOINT, ' ')).toBe(false);
11
+ });
12
+
13
+ it('matches a bare hostname', () => {
14
+ expect(shouldBypassProxy(CODE_ENDPOINT, 'codeapi')).toBe(true);
15
+ expect(shouldBypassProxy(CODE_ENDPOINT, 'other')).toBe(false);
16
+ });
17
+
18
+ it('accepts comma and whitespace separated lists', () => {
19
+ expect(
20
+ shouldBypassProxy(CODE_ENDPOINT, 'localhost,codeapi,127.0.0.1')
21
+ ).toBe(true);
22
+ expect(shouldBypassProxy(CODE_ENDPOINT, 'localhost codeapi')).toBe(true);
23
+ });
24
+
25
+ it('treats a leading dot as host-and-subdomains', () => {
26
+ expect(
27
+ shouldBypassProxy('http://codeapi.internal:3112/v1', '.internal')
28
+ ).toBe(true);
29
+ expect(shouldBypassProxy('http://internal:3112/v1', '.internal')).toBe(
30
+ true
31
+ );
32
+ expect(shouldBypassProxy('http://notinternal:3112/v1', '.internal')).toBe(
33
+ false
34
+ );
35
+ });
36
+
37
+ it('honours a port suffix only when it matches', () => {
38
+ expect(shouldBypassProxy(CODE_ENDPOINT, 'codeapi:3112')).toBe(true);
39
+ expect(shouldBypassProxy(CODE_ENDPOINT, 'codeapi:9999')).toBe(false);
40
+ });
41
+
42
+ it('defaults the port from the scheme when the URL omits it', () => {
43
+ expect(
44
+ shouldBypassProxy('https://api.example.com/v1', 'api.example.com:443')
45
+ ).toBe(true);
46
+ expect(
47
+ shouldBypassProxy('http://api.example.com/v1', 'api.example.com:80')
48
+ ).toBe(true);
49
+ expect(
50
+ shouldBypassProxy('https://api.example.com/v1', 'api.example.com:80')
51
+ ).toBe(false);
52
+ });
53
+
54
+ it('supports the wildcard entry', () => {
55
+ expect(shouldBypassProxy('https://anything.example.com', '*')).toBe(true);
56
+ });
57
+
58
+ it('does not claim a bypass for an unparsable target', () => {
59
+ expect(shouldBypassProxy('not a url', '*')).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe('resolveFetchProxyAgent', () => {
64
+ const saved = { ...process.env };
65
+
66
+ afterEach(() => {
67
+ for (const key of ['PROXY', 'NO_PROXY', 'no_proxy']) {
68
+ delete process.env[key];
69
+ }
70
+ Object.assign(process.env, saved);
71
+ });
72
+
73
+ function clearProxyEnv(): void {
74
+ for (const key of ['PROXY', 'NO_PROXY', 'no_proxy']) {
75
+ delete process.env[key];
76
+ }
77
+ }
78
+
79
+ it('returns undefined when no proxy is configured', () => {
80
+ clearProxyEnv();
81
+ expect(resolveFetchProxyAgent(CODE_ENDPOINT)).toBeUndefined();
82
+ });
83
+
84
+ /**
85
+ * The defect this module exists for: HttpsProxyAgent speaks HTTP CONNECT, so
86
+ * driving a socks5 proxy with it produces "Proxy connection ended before
87
+ * receiving CONNECT response" rather than a working tunnel.
88
+ */
89
+ it('uses a SOCKS agent for socks schemes', () => {
90
+ clearProxyEnv();
91
+ process.env.PROXY = 'socks5://127.0.0.1:1080';
92
+
93
+ const agent = resolveFetchProxyAgent('https://api.example.com/v1');
94
+
95
+ expect(agent).toBeInstanceOf(SocksProxyAgent);
96
+ expect(agent).not.toBeInstanceOf(HttpsProxyAgent);
97
+ });
98
+
99
+ it.each(['socks://host:1080', 'socks4://host:1080', 'socks5h://host:1080'])(
100
+ 'recognises %s as SOCKS',
101
+ (proxy) => {
102
+ clearProxyEnv();
103
+ process.env.PROXY = proxy;
104
+ expect(
105
+ resolveFetchProxyAgent('https://api.example.com/v1')
106
+ ).toBeInstanceOf(SocksProxyAgent);
107
+ }
108
+ );
109
+
110
+ it('uses an HTTP agent for http schemes', () => {
111
+ clearProxyEnv();
112
+ process.env.PROXY = 'http://proxy.internal:3128';
113
+
114
+ expect(resolveFetchProxyAgent('https://api.example.com/v1')).toBeInstanceOf(
115
+ HttpsProxyAgent
116
+ );
117
+ });
118
+
119
+ /**
120
+ * Code execution targets an internal endpoint. Without NO_PROXY support the
121
+ * feature is unusable for anyone with PROXY set, because the internal host is
122
+ * not reachable through an external proxy and there is no way to opt out.
123
+ */
124
+ it('bypasses the proxy for a NO_PROXY host', () => {
125
+ clearProxyEnv();
126
+ process.env.PROXY = 'socks5://127.0.0.1:1080';
127
+ process.env.NO_PROXY = 'codeapi,localhost';
128
+
129
+ expect(resolveFetchProxyAgent(CODE_ENDPOINT)).toBeUndefined();
130
+ });
131
+
132
+ it('still proxies hosts outside NO_PROXY', () => {
133
+ clearProxyEnv();
134
+ process.env.PROXY = 'socks5://127.0.0.1:1080';
135
+ process.env.NO_PROXY = 'codeapi';
136
+
137
+ expect(resolveFetchProxyAgent('https://api.example.com/v1')).toBeInstanceOf(
138
+ SocksProxyAgent
139
+ );
140
+ });
141
+
142
+ it('honours the lowercase no_proxy spelling', () => {
143
+ clearProxyEnv();
144
+ process.env.PROXY = 'http://proxy.internal:3128';
145
+ process.env.no_proxy = 'codeapi';
146
+
147
+ expect(resolveFetchProxyAgent(CODE_ENDPOINT)).toBeUndefined();
148
+ });
149
+
150
+ it('prefers an explicit proxy argument over the environment', () => {
151
+ clearProxyEnv();
152
+ process.env.PROXY = 'http://from-env:3128';
153
+
154
+ expect(
155
+ resolveFetchProxyAgent(
156
+ 'https://api.example.com/v1',
157
+ 'socks5://explicit:1080'
158
+ )
159
+ ).toBeInstanceOf(SocksProxyAgent);
160
+ });
161
+
162
+ /**
163
+ * An empty explicit proxy means "no proxy for this call", not "fall back to
164
+ * the environment" — matching the original `proxy != null && proxy !== ''`
165
+ * guard, where callers resolve `initParams.proxy ?? process.env.PROXY`
166
+ * themselves and an empty result disables proxying.
167
+ */
168
+ it('treats an empty explicit proxy as no proxy rather than falling back to env', () => {
169
+ clearProxyEnv();
170
+ process.env.PROXY = 'http://from-env:3128';
171
+
172
+ expect(
173
+ resolveFetchProxyAgent('https://api.example.com/v1', '')
174
+ ).toBeUndefined();
175
+ });
176
+ });
@@ -0,0 +1,93 @@
1
+ import { HttpsProxyAgent } from 'https-proxy-agent';
2
+ import { SocksProxyAgent } from 'socks-proxy-agent';
3
+ import type { RequestInit } from 'node-fetch';
4
+
5
+ type FetchAgent = NonNullable<RequestInit['agent']>;
6
+
7
+ /**
8
+ * Whether `targetUrl` is excluded from proxying by a NO_PROXY list.
9
+ *
10
+ * Follows the de facto convention: comma or whitespace separated entries, an
11
+ * optional leading dot meaning "this host and its subdomains", an optional
12
+ * `:port` suffix that must match when present, and `*` meaning "bypass
13
+ * everything".
14
+ */
15
+ export function shouldBypassProxy(
16
+ targetUrl: string,
17
+ noProxy?: string
18
+ ): boolean {
19
+ const list = (noProxy ?? '').trim();
20
+ if (list === '') {
21
+ return false;
22
+ }
23
+
24
+ let hostname: string;
25
+ let port: string;
26
+ try {
27
+ const url = new URL(targetUrl);
28
+ hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
29
+ port = url.port || (url.protocol === 'https:' ? '443' : '80');
30
+ } catch {
31
+ /* An unparsable target tells us nothing, so do not claim a bypass. */
32
+ return false;
33
+ }
34
+
35
+ return list
36
+ .split(/[\s,]+/)
37
+ .filter(Boolean)
38
+ .some((raw) => {
39
+ if (raw === '*') {
40
+ return true;
41
+ }
42
+ const match = /^(.*?)(?::(\d+))?$/.exec(raw);
43
+ if (!match) {
44
+ return false;
45
+ }
46
+ const entryHost = (match[1] ?? '').replace(/^\./, '').toLowerCase();
47
+ const entryPort = match[2];
48
+ if (entryHost === '') {
49
+ return false;
50
+ }
51
+ if (entryPort != null && entryPort !== port) {
52
+ return false;
53
+ }
54
+ return hostname === entryHost || hostname.endsWith(`.${entryHost}`);
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Build the proxy agent for a `node-fetch` request, or `undefined` when the
60
+ * request should go direct.
61
+ *
62
+ * Two rules that a bare `new HttpsProxyAgent(process.env.PROXY)` gets wrong:
63
+ *
64
+ * - The agent must match the proxy scheme. `HttpsProxyAgent` speaks HTTP
65
+ * CONNECT; handing it a `socks5://` URL produces a request the proxy never
66
+ * answers in that protocol, surfacing as "Proxy connection ended before
67
+ * receiving CONNECT response".
68
+ * - NO_PROXY has to be honoured. Code execution targets an internal endpoint
69
+ * (`http://codeapi:3112`), and forcing that through an external proxy makes
70
+ * the whole feature unusable for anyone who has PROXY set, with no way to
71
+ * opt out.
72
+ *
73
+ * @param targetUrl Absolute URL the request is going to.
74
+ * @param proxyUrl Explicit proxy; falls back to the PROXY environment variable.
75
+ */
76
+ export function resolveFetchProxyAgent(
77
+ targetUrl: string,
78
+ proxyUrl?: string
79
+ ): FetchAgent | undefined {
80
+ const proxy = (proxyUrl ?? process.env.PROXY ?? '').trim();
81
+ if (proxy === '') {
82
+ return undefined;
83
+ }
84
+
85
+ const noProxy = process.env.NO_PROXY ?? process.env.no_proxy;
86
+ if (shouldBypassProxy(targetUrl, noProxy)) {
87
+ return undefined;
88
+ }
89
+
90
+ return /^socks/i.test(proxy)
91
+ ? (new SocksProxyAgent(proxy) as unknown as FetchAgent)
92
+ : (new HttpsProxyAgent(proxy) as unknown as FetchAgent);
93
+ }