@librechat/agents 3.3.11 → 3.3.13

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 (205) 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/instrumentation.cjs +1 -0
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuseSpanRegistry.cjs +6 -3
  6. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -1
  7. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  8. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  9. package/dist/cjs/llm/invoke.cjs +146 -36
  10. package/dist/cjs/llm/invoke.cjs.map +1 -1
  11. package/dist/cjs/llm/openai/index.cjs +2 -2
  12. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  13. package/dist/cjs/llm/streamLimits.cjs +723 -0
  14. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  15. package/dist/cjs/main.cjs +8 -0
  16. package/dist/cjs/run.cjs +6 -2
  17. package/dist/cjs/run.cjs.map +1 -1
  18. package/dist/cjs/session/AgentSession.cjs +4 -1
  19. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  20. package/dist/cjs/stream.cjs +103 -12
  21. package/dist/cjs/stream.cjs.map +1 -1
  22. package/dist/cjs/summarization/node.cjs +157 -37
  23. package/dist/cjs/summarization/node.cjs.map +1 -1
  24. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  25. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  26. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  27. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  28. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  29. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  30. package/dist/cjs/tools/ToolNode.cjs +76 -3
  31. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  32. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  33. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  34. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  35. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  36. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  37. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  38. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  39. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  40. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  41. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  42. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  43. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  44. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  45. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  46. package/dist/cjs/tools/search/search.cjs +30 -10
  47. package/dist/cjs/tools/search/search.cjs.map +1 -1
  48. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  49. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  50. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  51. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  52. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  53. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  54. package/dist/cjs/tools/search/tool.cjs +16 -2
  55. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  56. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  57. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  58. package/dist/cjs/utils/index.cjs +1 -0
  59. package/dist/cjs/utils/misc.cjs +12 -0
  60. package/dist/cjs/utils/misc.cjs.map +1 -1
  61. package/dist/cjs/utils/proxy.cjs +63 -0
  62. package/dist/cjs/utils/proxy.cjs.map +1 -0
  63. package/dist/esm/graphs/Graph.mjs +245 -24
  64. package/dist/esm/graphs/Graph.mjs.map +1 -1
  65. package/dist/esm/instrumentation.mjs +1 -0
  66. package/dist/esm/instrumentation.mjs.map +1 -1
  67. package/dist/esm/langfuseSpanRegistry.mjs +6 -3
  68. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -1
  69. package/dist/esm/llm/bedrock/index.mjs +13 -2
  70. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  71. package/dist/esm/llm/invoke.mjs +146 -36
  72. package/dist/esm/llm/invoke.mjs.map +1 -1
  73. package/dist/esm/llm/openai/index.mjs +2 -2
  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 +4 -2
  78. package/dist/esm/run.mjs +6 -2
  79. package/dist/esm/run.mjs.map +1 -1
  80. package/dist/esm/session/AgentSession.mjs +4 -1
  81. package/dist/esm/session/AgentSession.mjs.map +1 -1
  82. package/dist/esm/stream.mjs +103 -12
  83. package/dist/esm/stream.mjs.map +1 -1
  84. package/dist/esm/summarization/node.mjs +157 -37
  85. package/dist/esm/summarization/node.mjs.map +1 -1
  86. package/dist/esm/tools/BashExecutor.mjs +3 -2
  87. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  88. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  89. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  90. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  91. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  92. package/dist/esm/tools/ToolNode.mjs +76 -3
  93. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  94. package/dist/esm/tools/ToolSearch.mjs +3 -2
  95. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  96. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  97. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  98. package/dist/esm/tools/search/crw-search.mjs +3 -1
  99. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  100. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  101. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  102. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  103. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  104. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  105. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  106. package/dist/esm/tools/search/rerankers.mjs +26 -8
  107. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  108. package/dist/esm/tools/search/search.mjs +30 -10
  109. package/dist/esm/tools/search/search.mjs.map +1 -1
  110. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  111. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  112. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  113. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  114. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  115. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  116. package/dist/esm/tools/search/tool.mjs +16 -2
  117. package/dist/esm/tools/search/tool.mjs.map +1 -1
  118. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  119. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  120. package/dist/esm/utils/index.mjs +1 -0
  121. package/dist/esm/utils/misc.mjs +12 -1
  122. package/dist/esm/utils/misc.mjs.map +1 -1
  123. package/dist/esm/utils/proxy.mjs +62 -0
  124. package/dist/esm/utils/proxy.mjs.map +1 -0
  125. package/dist/types/graphs/Graph.d.ts +53 -1
  126. package/dist/types/index.d.ts +2 -0
  127. package/dist/types/llm/invoke.d.ts +25 -4
  128. package/dist/types/llm/openai/index.d.ts +3 -0
  129. package/dist/types/llm/streamLimits.d.ts +314 -0
  130. package/dist/types/run.d.ts +1 -0
  131. package/dist/types/summarization/node.d.ts +27 -2
  132. package/dist/types/tools/BashExecutor.d.ts +2 -2
  133. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  134. package/dist/types/tools/ToolNode.d.ts +11 -1
  135. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  136. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  137. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  138. package/dist/types/tools/search/rerankers.d.ts +9 -5
  139. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  140. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  141. package/dist/types/tools/search/types.d.ts +28 -33
  142. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  143. package/dist/types/types/graph.d.ts +12 -1
  144. package/dist/types/types/run.d.ts +46 -1
  145. package/dist/types/types/tools.d.ts +21 -0
  146. package/dist/types/utils/index.d.ts +1 -0
  147. package/dist/types/utils/misc.d.ts +7 -0
  148. package/dist/types/utils/proxy.d.ts +31 -0
  149. package/package.json +2 -1
  150. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  151. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  152. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  153. package/src/graphs/Graph.ts +302 -27
  154. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  155. package/src/index.ts +11 -0
  156. package/src/instrumentation.ts +1 -0
  157. package/src/langfuseSpanRegistry.ts +9 -0
  158. package/src/llm/bedrock/index.ts +22 -2
  159. package/src/llm/invoke.streamLimits.test.ts +142 -0
  160. package/src/llm/invoke.test.ts +89 -1
  161. package/src/llm/invoke.ts +197 -20
  162. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  163. package/src/llm/openai/index.ts +14 -6
  164. package/src/llm/streamLimits.test.ts +450 -0
  165. package/src/llm/streamLimits.ts +1158 -0
  166. package/src/run.ts +4 -0
  167. package/src/session/AgentSession.ts +5 -0
  168. package/src/specs/langfuse-instrumentation.test.ts +26 -0
  169. package/src/specs/langfuse-span-registry.test.ts +17 -0
  170. package/src/specs/summarization.test.ts +5 -2
  171. package/src/stream.ts +141 -6
  172. package/src/summarization/__tests__/node.test.ts +269 -0
  173. package/src/summarization/chunkHandler.test.ts +196 -0
  174. package/src/summarization/node.ts +203 -6
  175. package/src/tools/BashExecutor.ts +4 -3
  176. package/src/tools/CodeExecutor.ts +5 -4
  177. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  178. package/src/tools/ToolNode.ts +109 -6
  179. package/src/tools/ToolSearch.ts +4 -3
  180. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  181. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  182. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  183. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  184. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  185. package/src/tools/search/crw-scraper.ts +6 -0
  186. package/src/tools/search/crw-search.ts +6 -1
  187. package/src/tools/search/firecrawl.ts +6 -0
  188. package/src/tools/search/http-agent.test.ts +133 -0
  189. package/src/tools/search/keenable-scraper.ts +6 -0
  190. package/src/tools/search/keenable-search.ts +6 -1
  191. package/src/tools/search/rerankers.ts +36 -10
  192. package/src/tools/search/search.ts +29 -11
  193. package/src/tools/search/serper-scraper.ts +6 -0
  194. package/src/tools/search/tavily-scraper.ts +6 -0
  195. package/src/tools/search/tavily-search.ts +2 -0
  196. package/src/tools/search/tool.ts +16 -0
  197. package/src/tools/search/types.ts +31 -33
  198. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  199. package/src/types/graph.ts +12 -0
  200. package/src/types/run.ts +49 -1
  201. package/src/types/tools.ts +21 -0
  202. package/src/utils/index.ts +1 -0
  203. package/src/utils/misc.ts +19 -0
  204. package/src/utils/proxy.test.ts +176 -0
  205. package/src/utils/proxy.ts +93 -0
package/src/run.ts CHANGED
@@ -148,6 +148,7 @@ export class Run<_T extends t.BaseGraphState> {
148
148
  private toolExecution?: t.ToolExecutionConfig;
149
149
  private subagentUsageSink?: t.SubagentUsageSink;
150
150
  private preemption?: t.StreamPreemption;
151
+ private streamLimits?: t.StreamLimits;
151
152
  private indexTokenCountMap?: Record<string, number>;
152
153
  calibrationRatio: number = 1;
153
154
  graphRunnable?: t.CompiledStateWorkflow;
@@ -210,6 +211,7 @@ export class Run<_T extends t.BaseGraphState> {
210
211
  this.toolExecution = config.toolExecution;
211
212
  this.subagentUsageSink = config.subagentUsageSink;
212
213
  this.preemption = config.preemption;
214
+ this.streamLimits = config.streamLimits;
213
215
 
214
216
  if (!config.graphConfig) {
215
217
  throw new Error('Graph config not provided');
@@ -285,6 +287,7 @@ export class Run<_T extends t.BaseGraphState> {
285
287
  calibrationRatio: this.calibrationRatio,
286
288
  subagentUsageSink: this.subagentUsageSink,
287
289
  preemption: this.preemption,
290
+ streamLimits: this.streamLimits,
288
291
  });
289
292
  /** Propagate compile options from graph config */
290
293
  standardGraph.compileOptions = this.applyHITLCheckpointerFallback(
@@ -317,6 +320,7 @@ export class Run<_T extends t.BaseGraphState> {
317
320
  calibrationRatio: this.calibrationRatio,
318
321
  subagentUsageSink: this.subagentUsageSink,
319
322
  preemption: this.preemption,
323
+ streamLimits: this.streamLimits,
320
324
  });
321
325
 
322
326
  multiAgentGraph.compileOptions =
@@ -25,6 +25,7 @@ import type { HookRegistry } from '@/hooks';
25
25
  import type * as t from '@/types';
26
26
  import { deserializeMessage } from './messageSerialization';
27
27
  import { createSummarizeNode } from '@/summarization/node';
28
+ import { resolveStreamLimits } from '@/llm/streamLimits';
28
29
  import { JsonlSessionStore } from './JsonlSessionStore';
29
30
  import { AgentContext } from '@/agents/AgentContext';
30
31
  import { ContentTypes, GraphEvents } from '@/common';
@@ -610,6 +611,8 @@ function createManualCompactGraph(params: {
610
611
  runId: string;
611
612
  customHandlers?: Record<string, t.EventHandler>;
612
613
  hooks?: HookRegistry;
614
+ /** Session run-level stream limits, so manual compaction honors them. */
615
+ streamLimits?: t.StreamLimits;
613
616
  }): {
614
617
  graph: Parameters<typeof createSummarizeNode>[0]['graph'];
615
618
  completedSummary?: t.SummaryContentBlock;
@@ -626,6 +629,7 @@ function createManualCompactGraph(params: {
626
629
  runId: params.runId,
627
630
  isMultiAgent: false,
628
631
  hookRegistry: params.hooks,
632
+ streamLimits: resolveStreamLimits(params.streamLimits),
629
633
  dispatchRunStep: async (runStep): Promise<void> => {
630
634
  contentData.push(runStep);
631
635
  contentIndexMap.set(runStep.id, runStep.index);
@@ -1251,6 +1255,7 @@ export class AgentSession {
1251
1255
  runId: compactRunId,
1252
1256
  customHandlers: this.runConfig.customHandlers,
1253
1257
  hooks: this.runConfig.hooks,
1258
+ streamLimits: this.runConfig.streamLimits,
1254
1259
  });
1255
1260
  const summarizeNode = createSummarizeNode({
1256
1261
  agentContext,
@@ -320,6 +320,32 @@ describe('Langfuse instrumentation', () => {
320
320
  });
321
321
  });
322
322
 
323
+ it('does not reuse processors across different media upload policies', async () => {
324
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
325
+ initializeLangfuseTracing({
326
+ publicKey: 'pk-media',
327
+ secretKey: 'sk-media',
328
+ baseUrl: 'https://langfuse.media',
329
+ mediaUploadEnabled: false,
330
+ });
331
+ initializeLangfuseTracing({
332
+ publicKey: 'pk-media',
333
+ secretKey: 'sk-media',
334
+ baseUrl: 'https://langfuse.media',
335
+ mediaUploadEnabled: true,
336
+ });
337
+
338
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledTimes(2);
339
+ expect(mockLangfuseSpanProcessor).toHaveBeenNthCalledWith(
340
+ 1,
341
+ expect.objectContaining({ mediaUploadEnabled: false })
342
+ );
343
+ expect(mockLangfuseSpanProcessor).toHaveBeenNthCalledWith(
344
+ 2,
345
+ expect.objectContaining({ mediaUploadEnabled: true })
346
+ );
347
+ });
348
+
323
349
  it('reuses the isolated provider after initialization', async () => {
324
350
  process.env.LANGFUSE_SECRET_KEY = 'sk-test';
325
351
  process.env.LANGFUSE_PUBLIC_KEY = 'pk-test';
@@ -1,6 +1,7 @@
1
1
  import type { Span } from '@opentelemetry/api';
2
2
  import type * as t from '@/types';
3
3
  import {
4
+ getLangfuseSpanProcessorParams,
4
5
  getLangfuseManagedSpanDestination,
5
6
  registerLangfuseManagedSpan,
6
7
  resolveLangfuseDestinationKey,
@@ -41,7 +42,23 @@ describe('Langfuse span registry', () => {
41
42
  const redacting = resolveLangfuseDestinationKey(
42
43
  tenantConfig({ toolOutputTracing: { enabled: false } })
43
44
  );
45
+ const mediaDisabled = resolveLangfuseDestinationKey(
46
+ tenantConfig({ mediaUploadEnabled: false })
47
+ );
44
48
  expect(redacting).toBe(base);
49
+ expect(mediaDisabled).toBe(base);
50
+ });
51
+
52
+ it('passes media upload policy to the Langfuse span processor params', () => {
53
+ expect(
54
+ getLangfuseSpanProcessorParams(
55
+ tenantConfig({ mediaUploadEnabled: false })
56
+ )
57
+ ).toEqual(
58
+ expect.objectContaining({
59
+ mediaUploadEnabled: false,
60
+ })
61
+ );
45
62
  });
46
63
 
47
64
  it('separates destinations by credentials, endpoint, and environment', () => {
@@ -1162,7 +1162,7 @@ const hasBedrock = hasEveryEnv(requiredBedrockEnv);
1162
1162
 
1163
1163
  const hasOpenAI = hasEnv('OPENAI_API_KEY');
1164
1164
  (hasOpenAI ? describe : describe.skip)('OpenAI Summarization E2E', () => {
1165
- jest.setTimeout(120_000);
1165
+ jest.setTimeout(240_000);
1166
1166
 
1167
1167
  const agentProvider = Providers.OPENAI;
1168
1168
  const streamConfig = {
@@ -1190,6 +1190,9 @@ const hasOpenAI = hasEnv('OPENAI_API_KEY');
1190
1190
  agentProvider,
1191
1191
  summarizationProvider: Providers.OPENAI,
1192
1192
  summarizationModel: 'gpt-4.1-mini',
1193
+ llmConfigOverride: {
1194
+ model: 'gpt-4.1-mini',
1195
+ },
1193
1196
  maxContextTokens: maxTokens,
1194
1197
  instructions:
1195
1198
  'You are a helpful math tutor. Use the calculator tool for ALL computations. Keep responses concise.',
@@ -1320,7 +1323,7 @@ const hasOpenAI = hasEnv('OPENAI_API_KEY');
1320
1323
  ` OpenAI summary: "${getSummaryText(completePayload.summary).substring(0, 200)}…"`
1321
1324
  );
1322
1325
  console.log(` Final messages: ${conversationHistory.length}`);
1323
- }, 120_000);
1326
+ }, 240_000);
1324
1327
  });
1325
1328
 
1326
1329
  // ---------------------------------------------------------------------------
package/src/stream.ts CHANGED
@@ -39,9 +39,21 @@ import {
39
39
  calculateMaxToolResultChars,
40
40
  truncateToolResultContent,
41
41
  } from '@/utils/truncation';
42
+ import type { RunBreakerScope } from '@/llm/streamLimits';
43
+ import {
44
+ claimStreamLimitCharge,
45
+ combineCompleteToolCalls,
46
+ enforceCompleteToolCallArgLimit,
47
+ enforceStreamedToolCallArgLimit,
48
+ enforceStreamDeltaEventLimit,
49
+ requiresStreamLimitAccounting,
50
+ StreamLimitExceededError,
51
+ STREAM_LIMIT_EPOCH_KEY,
52
+ } from '@/llm/streamLimits';
42
53
  import { resolveToolOutcome, outcomeFieldsFromResult } from '@/tools/intentArg';
43
54
  import { TOOL_OUTPUT_REF_PATTERN } from '@/tools/toolOutputReferences';
44
55
  import { safeDispatchCustomEvent } from '@/utils/events';
56
+ import { composeAbortSignals } from '@/utils/misc';
45
57
  import { isGoogleLike } from '@/utils/llm';
46
58
  import { getMessageId } from '@/messages';
47
59
 
@@ -166,7 +178,10 @@ function isEagerExecutionExcludedTool(
166
178
  // the final request ("changed after eager execution started"), stop
167
179
  // prestarting it so the model's retry executes normally instead of
168
180
  // re-diverging in a loop (LibreChat#14371).
169
- if (graph.eagerEventToolSuppressions?.has(name) === true) {
181
+ if (
182
+ (graph.eagerEventToolSuppressions as Set<string> | undefined)?.has(name) ===
183
+ true
184
+ ) {
170
185
  return true;
171
186
  }
172
187
  // A code-session participant writes to the shared sandbox, so it is
@@ -778,6 +793,10 @@ function startEagerToolExecutions(args: {
778
793
  | Record<string, unknown>
779
794
  | undefined,
780
795
  metadata,
796
+ signal: composeAbortSignals(
797
+ graph.config?.signal,
798
+ graph.breakerAbort.signal
799
+ ),
781
800
  resolve: (results): void => {
782
801
  resultSettled = true;
783
802
  settledResults = results;
@@ -1069,9 +1088,8 @@ function recordEagerToolCallChunks(args: {
1069
1088
  const sealCoversChunk =
1070
1089
  seal != null &&
1071
1090
  (seal.kind === 'all' ||
1072
- (seal.kind === 'single' &&
1073
- ((seal.id != null && seal.id === id) ||
1074
- (seal.index != null && seal.index === index))));
1091
+ (seal.id != null && seal.id === id) ||
1092
+ (seal.index != null && seal.index === index));
1075
1093
  const next = {
1076
1094
  id,
1077
1095
  name,
@@ -1512,10 +1530,119 @@ export class ChatModelStreamHandler implements t.EventHandler {
1512
1530
  return;
1513
1531
  }
1514
1532
 
1515
- const agentContext = graph.getAgentContext(metadata);
1516
-
1517
1533
  const chunk = data.chunk as Partial<AIMessageChunk>;
1518
1534
 
1535
+ /** Attempts stamp their breaker epoch into event metadata; a mismatch
1536
+ * marks a straggling chunk from a failed run that outlived
1537
+ * `resetValues()`. Dropped OUTRIGHT: content handling and the eager
1538
+ * paths below compose the LIVE controller, so acting on a dead run's
1539
+ * chunk could dispatch host tools into the run now using it. Events
1540
+ * without a stamp (direct handler callers, partial stubs) keep the
1541
+ * live-controller behavior. */
1542
+ const eventEpoch = metadata?.[STREAM_LIMIT_EPOCH_KEY];
1543
+ /** Runtime-honest widening: partial handler stubs carry neither an
1544
+ * epoch nor a run scope despite the field types. */
1545
+ const liveEpoch = graph.breakerEpoch as number | undefined;
1546
+ if (eventEpoch != null && liveEpoch != null && eventEpoch !== liveEpoch) {
1547
+ return;
1548
+ }
1549
+ const eventBreaker =
1550
+ graph.breakerAbort instanceof AbortController
1551
+ ? graph.breakerAbort
1552
+ : undefined;
1553
+ /** Immutable scope captured at handler entry. A reset while this
1554
+ * handler is suspended in an await replaces the object, so ONE
1555
+ * reference comparison proves the event still belongs to the live run
1556
+ * before anything composes `graph.breakerAbort` or `graph.config`. */
1557
+ const entryRunScope = graph.runScope as RunBreakerScope | undefined;
1558
+ const runScopeInvalidated = (): boolean =>
1559
+ entryRunScope != null && graph.runScope !== entryRunScope;
1560
+ const throwIfRunBreakerTripped = (): void => {
1561
+ if (
1562
+ eventBreaker != null &&
1563
+ eventBreaker.signal.aborted &&
1564
+ eventBreaker.signal.reason instanceof StreamLimitExceededError
1565
+ ) {
1566
+ throw eventBreaker.signal.reason;
1567
+ }
1568
+ };
1569
+
1570
+ /**
1571
+ * Enforced before every content-specific early return below
1572
+ * (server-tool results, deferred mixed reasoning, late OpenRouter
1573
+ * reasoning): a looping provider can flood through any of those paths,
1574
+ * a coalesced event can carry client `tool_call_chunks` alongside a
1575
+ * server-tool result, and the complete-call dispatch branch further
1576
+ * down can prestart a side-effecting tool from an arrival-sealed
1577
+ * oversized call. Charging is claim-based: the producer loop and this
1578
+ * decoupled echo can observe the same chunk object in either order, and
1579
+ * only the first claimer charges it. The argument guard is
1580
+ * deliberately NOT gated on numeric chunk indices, so id-only or
1581
+ * index-less runaway streams stay bounded, and complete parsed
1582
+ * `tool_calls` without a raw chunk representation are judged standalone.
1583
+ */
1584
+ if (
1585
+ requiresStreamLimitAccounting(graph, chunk) &&
1586
+ claimStreamLimitCharge(graph, data.chunk, 'consumer', metadata)
1587
+ ) {
1588
+ try {
1589
+ enforceStreamDeltaEventLimit({ graph, metadata });
1590
+ /** Combined first so raw-chunk name correlation sees invalid calls
1591
+ * too; an unnamed raw chunk twinned with a named invalid call must
1592
+ * select that tool's override, not the global cap. */
1593
+ const completeCalls = combineCompleteToolCalls(chunk);
1594
+ if (chunk.tool_call_chunks && chunk.tool_call_chunks.length > 0) {
1595
+ enforceStreamedToolCallArgLimit({
1596
+ graph,
1597
+ metadata,
1598
+ toolCallChunks: chunk.tool_call_chunks,
1599
+ responseMetadata: chunk.response_metadata as
1600
+ | Record<string, unknown>
1601
+ | undefined,
1602
+ parsedToolCalls: completeCalls,
1603
+ });
1604
+ }
1605
+ /** Judged whenever parsed calls are present, not only when raw
1606
+ * chunks are absent — an adapter can pair an empty or partial raw
1607
+ * chunk with a complete parsed call; the standalone check is
1608
+ * stateless, so the common both-present case is not double-tallied.
1609
+ * Invalid calls are included because ToolNode processes and
1610
+ * promotes them. */
1611
+ if (completeCalls != null) {
1612
+ enforceCompleteToolCallArgLimit({
1613
+ graph,
1614
+ metadata,
1615
+ toolCalls: completeCalls,
1616
+ });
1617
+ }
1618
+ } catch (error) {
1619
+ /** A breach detected on this consumer path must still stop parallel
1620
+ * fan-out work: the producer skips its own enforcement once this
1621
+ * side has claimed the emission, so createCallModel's breaker-abort
1622
+ * never fires for it. Trip the EVENT's run-bound breaker before the
1623
+ * throw rejects the run — never the live controller of a newer run. */
1624
+ if (error instanceof StreamLimitExceededError && eventBreaker != null) {
1625
+ eventBreaker.abort(error);
1626
+ }
1627
+ throw error;
1628
+ }
1629
+ }
1630
+
1631
+ /** A parallel producer can trip the shared breaker while this event was
1632
+ * already queued in `streamEvents`. Stop before content handling or the
1633
+ * eager-tool paths below — those can dispatch a side-effecting host
1634
+ * tool with an already-aborted signal the handler never inspects.
1635
+ * Rechecked again immediately before each eager dispatch: the awaits in
1636
+ * between (server-tool results, tool-call handling, content dispatch)
1637
+ * are windows for a sibling's trip — or for a full reset, after which
1638
+ * this event belongs to a dead run and is dropped. */
1639
+ if (runScopeInvalidated()) {
1640
+ return;
1641
+ }
1642
+ throwIfRunBreakerTripped();
1643
+
1644
+ const agentContext = graph.getAgentContext(metadata);
1645
+
1519
1646
  const content = getChunkContent({
1520
1647
  chunk,
1521
1648
  reasoningKey: agentContext.reasoningKey,
@@ -1576,6 +1703,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
1576
1703
  ) {
1577
1704
  hasToolCalls = true;
1578
1705
  await handleToolCalls(chunk.tool_calls, metadata, graph);
1706
+ if (runScopeInvalidated()) {
1707
+ return;
1708
+ }
1709
+ throwIfRunBreakerTripped();
1579
1710
  if (hasFinalToolCallSignal(chunk)) {
1580
1711
  startEagerToolExecutions({
1581
1712
  graph,
@@ -1655,6 +1786,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
1655
1786
  metadata,
1656
1787
  });
1657
1788
  if (canStreamEager) {
1789
+ if (runScopeInvalidated()) {
1790
+ return;
1791
+ }
1792
+ throwIfRunBreakerTripped();
1658
1793
  startReadyStreamedEagerToolExecutions({
1659
1794
  graph,
1660
1795
  metadata,
@@ -7,6 +7,7 @@ import {
7
7
  DEFAULT_SUMMARIZATION_PROMPT,
8
8
  DEFAULT_UPDATE_SUMMARIZATION_PROMPT,
9
9
  } from '@/summarization/node';
10
+ import { StreamLimitExceededError } from '@/llm/streamLimits';
10
11
  import { convertInjectedMessages } from '@/messages/injected';
11
12
  import { Constants, GraphEvents, Providers } from '@/common';
12
13
  import { AgentContext } from '@/agents/AgentContext';
@@ -1707,3 +1708,271 @@ describe('createSummarizeNode — overflow recovery', () => {
1707
1708
  expect(agentContext.hasSummary()).toBe(true);
1708
1709
  });
1709
1710
  });
1711
+
1712
+ describe('summarize node breaker capture', () => {
1713
+ it('stamps the entry-captured breaker epoch into summary attempt metadata', async () => {
1714
+ captureEvents();
1715
+ const capturedConfigs: Array<{ metadata?: Record<string, unknown> }> = [];
1716
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
1717
+ class {
1718
+ constructor() {
1719
+ return {
1720
+ invoke: jest
1721
+ .fn()
1722
+ .mockImplementation(
1723
+ async (_messages: unknown, config?: unknown) => {
1724
+ capturedConfigs.push(
1725
+ config as { metadata?: Record<string, unknown> }
1726
+ );
1727
+ return { content: 'Summary text' };
1728
+ }
1729
+ ),
1730
+ };
1731
+ }
1732
+ } as never
1733
+ );
1734
+
1735
+ const agentContext = createAgentContext();
1736
+ const graph = {
1737
+ ...mockGraph(),
1738
+ getBreakerEpoch: (): number => 7,
1739
+ };
1740
+ const node = createSummarizeNode({
1741
+ agentContext,
1742
+ graph,
1743
+ generateStepId,
1744
+ });
1745
+
1746
+ await node(
1747
+ {
1748
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1749
+ summarizationRequest: {
1750
+ remainingContextTokens: 1000,
1751
+ agentId: 'agent_0',
1752
+ },
1753
+ },
1754
+ {} as RunnableConfig
1755
+ );
1756
+
1757
+ expect(capturedConfigs.length).toBeGreaterThan(0);
1758
+ for (const config of capturedConfigs) {
1759
+ expect(config.metadata?.lc_stream_limit_epoch).toBe(7);
1760
+ }
1761
+ });
1762
+
1763
+ it('rejects before the model call when the breaker trips during pre-call awaits', async () => {
1764
+ const trip = new StreamLimitExceededError({
1765
+ kind: 'tool_call_args',
1766
+ limit: 10,
1767
+ observed: 11,
1768
+ toolName: 'db_query',
1769
+ });
1770
+ const entryBreaker = new AbortController();
1771
+ /** The trip lands while ON_SUMMARIZE_START is awaited — after the
1772
+ * entry check already passed. */
1773
+ jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
1774
+ ...args: unknown[]
1775
+ ) => {
1776
+ if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1777
+ entryBreaker.abort(trip);
1778
+ }
1779
+ }) as never);
1780
+ const modelClassSpy = jest
1781
+ .spyOn(providers, 'getChatModelClass')
1782
+ .mockReturnValue(
1783
+ class {
1784
+ constructor() {
1785
+ return mockInvokeModel('should never run');
1786
+ }
1787
+ } as never
1788
+ );
1789
+
1790
+ const agentContext = createAgentContext();
1791
+ const graph = {
1792
+ ...mockGraph(),
1793
+ getBreakerSignal: (): AbortSignal => entryBreaker.signal,
1794
+ };
1795
+ const node = createSummarizeNode({
1796
+ agentContext,
1797
+ graph,
1798
+ generateStepId,
1799
+ });
1800
+
1801
+ await expect(
1802
+ node(
1803
+ {
1804
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1805
+ summarizationRequest: {
1806
+ remainingContextTokens: 1000,
1807
+ agentId: 'agent_0',
1808
+ },
1809
+ },
1810
+ {} as RunnableConfig
1811
+ )
1812
+ ).rejects.toBe(trip);
1813
+ expect(modelClassSpy).not.toHaveBeenCalled();
1814
+ });
1815
+
1816
+ it('rethrows a parent trip on the config signal instead of degrading to the stub', async () => {
1817
+ const trip = new StreamLimitExceededError({
1818
+ kind: 'tool_call_args',
1819
+ limit: 10,
1820
+ observed: 11,
1821
+ toolName: 'db_query',
1822
+ });
1823
+ /** Child graph's own breaker stays live: in a subagent, a ROOT
1824
+ * sibling's trip arrives only through the composed invocation signal. */
1825
+ const childBreaker = new AbortController();
1826
+ const configAbort = new AbortController();
1827
+ captureEvents();
1828
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
1829
+ class {
1830
+ constructor() {
1831
+ return {
1832
+ invoke: jest.fn().mockImplementation(async () => {
1833
+ configAbort.abort(trip);
1834
+ throw new Error('The operation was aborted');
1835
+ }),
1836
+ };
1837
+ }
1838
+ } as never
1839
+ );
1840
+
1841
+ const agentContext = createAgentContext();
1842
+ const graph = {
1843
+ ...mockGraph(),
1844
+ getBreakerSignal: (): AbortSignal => childBreaker.signal,
1845
+ };
1846
+ const node = createSummarizeNode({
1847
+ agentContext,
1848
+ graph,
1849
+ generateStepId,
1850
+ });
1851
+
1852
+ await expect(
1853
+ node(
1854
+ {
1855
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1856
+ summarizationRequest: {
1857
+ remainingContextTokens: 1000,
1858
+ agentId: 'agent_0',
1859
+ },
1860
+ },
1861
+ { signal: configAbort.signal } as RunnableConfig
1862
+ )
1863
+ ).rejects.toBe(trip);
1864
+ });
1865
+
1866
+ it('rejects at entry when the breaker has already tripped', async () => {
1867
+ const trip = new StreamLimitExceededError({
1868
+ kind: 'tool_call_args',
1869
+ limit: 10,
1870
+ observed: 11,
1871
+ toolName: 'db_query',
1872
+ });
1873
+ const entryBreaker = new AbortController();
1874
+ entryBreaker.abort(trip);
1875
+
1876
+ const modelClassSpy = jest
1877
+ .spyOn(providers, 'getChatModelClass')
1878
+ .mockReturnValue(
1879
+ class {
1880
+ constructor() {
1881
+ return mockInvokeModel('should never run');
1882
+ }
1883
+ } as never
1884
+ );
1885
+
1886
+ const agentContext = createAgentContext();
1887
+ const graph = {
1888
+ ...mockGraph(),
1889
+ getBreakerSignal: (): AbortSignal => entryBreaker.signal,
1890
+ };
1891
+ const node = createSummarizeNode({
1892
+ agentContext,
1893
+ graph,
1894
+ generateStepId,
1895
+ });
1896
+
1897
+ await expect(
1898
+ node(
1899
+ {
1900
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1901
+ summarizationRequest: {
1902
+ remainingContextTokens: 1000,
1903
+ agentId: 'agent_0',
1904
+ },
1905
+ },
1906
+ {} as RunnableConfig
1907
+ )
1908
+ ).rejects.toBe(trip);
1909
+
1910
+ expect(graph.contentData).toHaveLength(0);
1911
+ expect(modelClassSpy).not.toHaveBeenCalled();
1912
+ });
1913
+
1914
+ it('binds the model call to the breaker signal read at node entry', async () => {
1915
+ const entryBreaker = new AbortController();
1916
+ const lateBreaker = new AbortController();
1917
+ let started = false;
1918
+ jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
1919
+ ...args: unknown[]
1920
+ ) => {
1921
+ if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1922
+ started = true;
1923
+ }
1924
+ }) as never);
1925
+
1926
+ const capturedSignals: Array<AbortSignal | undefined> = [];
1927
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
1928
+ class {
1929
+ constructor() {
1930
+ return {
1931
+ invoke: jest
1932
+ .fn()
1933
+ .mockImplementation(
1934
+ async (_messages: unknown, config?: unknown) => {
1935
+ capturedSignals.push(
1936
+ (config as RunnableConfig | undefined)?.signal
1937
+ );
1938
+ return { content: 'Summary text' };
1939
+ }
1940
+ ),
1941
+ };
1942
+ }
1943
+ } as never
1944
+ );
1945
+
1946
+ const agentContext = createAgentContext();
1947
+ /** Simulates a graph reset between node entry and the model call: once
1948
+ * ON_SUMMARIZE_START has been awaited, the live accessor hands out a
1949
+ * fresh controller's signal. The model call must still see the signal
1950
+ * captured at entry. */
1951
+ const graph = {
1952
+ ...mockGraph(),
1953
+ getBreakerSignal: (): AbortSignal =>
1954
+ started ? lateBreaker.signal : entryBreaker.signal,
1955
+ };
1956
+ const node = createSummarizeNode({
1957
+ agentContext,
1958
+ graph,
1959
+ generateStepId,
1960
+ });
1961
+
1962
+ await node(
1963
+ {
1964
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1965
+ summarizationRequest: {
1966
+ remainingContextTokens: 1000,
1967
+ agentId: 'agent_0',
1968
+ },
1969
+ },
1970
+ {} as RunnableConfig
1971
+ );
1972
+
1973
+ expect(capturedSignals.length).toBeGreaterThan(0);
1974
+ for (const signal of capturedSignals) {
1975
+ expect(signal).toBe(entryBreaker.signal);
1976
+ }
1977
+ });
1978
+ });