@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
@@ -0,0 +1,1158 @@
1
+ // src/llm/streamLimits.ts
2
+ import type { ToolCallChunk } from '@langchain/core/messages/tool';
3
+ import type * as t from '@/types';
4
+ import { getStreamedToolCallSeal } from '@/tools/streamedToolCallSeals';
5
+
6
+ /**
7
+ * Circuit breakers for pathological model streams.
8
+ *
9
+ * A malformed generation can stream a single tool call's arguments for many
10
+ * minutes at the provider's full token rate while the arguments never become
11
+ * executable (observed live: one 149,923-char SQL argument streamed for 26
12
+ * minutes before the 64k output-token ceiling finally ended the run). These
13
+ * guards fail fast instead: when a limit trips, the stream handler throws
14
+ * `StreamLimitExceededError` out of the run's `streamEvents` loop, which
15
+ * tears down the in-flight provider request where it stands (see the
16
+ * mid-flight halt notes in `Run.processStream`: leaving the loop cancels the
17
+ * reader and langgraph aborts the model call).
18
+ */
19
+
20
+ /** Default cap on a single streamed tool call's cumulative argument bytes (64 KiB). */
21
+ export const DEFAULT_MAX_TOOL_CALL_ARG_BYTES = 65_536;
22
+
23
+ /** Limits normalized by {@link resolveStreamLimits}; `0` uniformly means disabled. */
24
+ export interface ResolvedStreamLimits {
25
+ maxToolCallArgBytes: number;
26
+ /** Per-tool overrides of the byte cap, keyed by model-facing tool name. */
27
+ maxToolCallArgBytesByTool?: Readonly<Record<string, number>>;
28
+ maxDeltaEventsPerTurn: number;
29
+ /** Precomputed once: whether ANY argument byte limit can fire. False when
30
+ * the global cap is disabled and every per-tool entry is a zero-valued
31
+ * disable — accounting must not allocate for guards that judge nothing. */
32
+ hasEnforceableToolCallArgLimit: boolean;
33
+ }
34
+
35
+ /** Cumulative streamed argument bytes for one in-flight tool call. */
36
+ export interface StreamedToolCallArgTally {
37
+ bytes: number;
38
+ name?: string;
39
+ /** The graph's breaker epoch when this tally was created. Entries from
40
+ * the epoch that is ending survive one `resetValues` sweep, so producer
41
+ * loops of straggling attempts — which are not behind the consumer-only
42
+ * epoch gate — stay on their original budgets instead of receiving a
43
+ * fresh allowance at every run start. */
44
+ epoch?: number;
45
+ /** Every tally-map key this tally is registered under: its primary key
46
+ * (which can migrate through identifier transitions), the batch-position
47
+ * fallback for id-bearing chunks, and the id for chunks carrying both
48
+ * identifiers. Release deletes all of them — a call sealed through one
49
+ * identity must not leave entries behind under another. */
50
+ keys?: string[];
51
+ /**
52
+ * True when the previous chunk ended on an unpaired UTF-16 high surrogate.
53
+ * Counting each half of a split surrogate pair alone yields 3 bytes per
54
+ * half (the replacement-character encoding) versus 4 for the pair, so the
55
+ * next chunk starting with the low surrogate reconciles by subtracting 2.
56
+ */
57
+ pendingHighSurrogate?: boolean;
58
+ }
59
+
60
+ /** Immutable snapshot of one run's breaker identity. Captured at
61
+ * model/event/tool-batch entry and revalidated by REFERENCE after awaits —
62
+ * one identity comparison proves no reset interleaved. */
63
+ export interface RunBreakerScope {
64
+ readonly epoch: number;
65
+ readonly controller: AbortController;
66
+ }
67
+
68
+ /** Streamed chunk events counted against one generation's event cap. */
69
+ export interface StreamDeltaEventTally {
70
+ count: number;
71
+ /** Creation-epoch tag, same grace semantics as
72
+ * {@link StreamedToolCallArgTally.epoch}. */
73
+ epoch?: number;
74
+ }
75
+
76
+ /**
77
+ * The graph-owned state the guards read and write. Structural on purpose:
78
+ * handler-level tests stub graphs with plain objects, and the guards lazily
79
+ * create the tally maps so partial stubs need no extra setup.
80
+ */
81
+ export interface StreamLimitState {
82
+ streamLimits?: ResolvedStreamLimits;
83
+ streamedToolCallArgTallies?: Map<string, StreamedToolCallArgTally>;
84
+ streamDeltaEventCounts?: Map<string, StreamDeltaEventTally>;
85
+ /** The graph's live breaker epoch; new accounting entries are tagged with
86
+ * it so `resetValues` can sweep by age instead of clearing outright. */
87
+ breakerEpoch?: number;
88
+ /** Generation keys of model attempts still in flight. Each attempt leases
89
+ * its generation at entry and releases it (with its accounting entries)
90
+ * from its `finally`, so retention follows ATTEMPT LIFETIME — a
91
+ * cancellation-ignoring straggler keeps its original budget no matter how
92
+ * many runs start and reset while it drains. */
93
+ activeStreamLimitGenerations?: Set<string>;
94
+ /** Per-chunk-object, per-generation charge balance: producer visits
95
+ * increment, consumer (handler echo) visits decrement, and a visit only
96
+ * charges when the other side has not pre-charged the same emission.
97
+ * Count-balancing rather than a lifetime set, because a streaming model
98
+ * may mutate and re-yield the same chunk object; scoped by generation so
99
+ * parallel generations sharing one reused object cannot cancel each
100
+ * other's charges. */
101
+ streamLimitChargeCredits?: WeakMap<object, Map<string, number>>;
102
+ }
103
+
104
+ function resolveLimit(value: number | undefined, fallback: number): number {
105
+ if (value == null || Number.isNaN(value)) {
106
+ return fallback;
107
+ }
108
+ if (!Number.isFinite(value)) {
109
+ return 0;
110
+ }
111
+ const whole = Math.floor(value);
112
+ return whole > 0 ? whole : 0;
113
+ }
114
+
115
+ /**
116
+ * Normalizes host-supplied limits once at graph construction. `undefined`
117
+ * applies the default for each guard (the tool-argument byte cap is ON by
118
+ * default, the per-turn event cap is opt-in), `0` and negative values
119
+ * disable a guard, `Infinity` means "no limit" and also disables, and `NaN`
120
+ * falls back to the default.
121
+ */
122
+ export function resolveStreamLimits(
123
+ limits?: t.StreamLimits
124
+ ): ResolvedStreamLimits {
125
+ const maxToolCallArgBytes = resolveLimit(
126
+ limits?.maxToolCallArgBytes,
127
+ DEFAULT_MAX_TOOL_CALL_ARG_BYTES
128
+ );
129
+ let maxToolCallArgBytesByTool: Record<string, number> | undefined;
130
+ if (limits?.maxToolCallArgBytesByTool != null) {
131
+ for (const [name, value] of Object.entries(
132
+ limits.maxToolCallArgBytesByTool
133
+ )) {
134
+ /** An unusable entry (NaN) falls back to the global cap by omission,
135
+ * matching how the global field treats NaN. */
136
+ if (name === '' || Number.isNaN(value)) {
137
+ continue;
138
+ }
139
+ /** Prototype-free: bracket-assigning a `__proto__` tool name into a
140
+ * plain object invokes the prototype setter instead of creating an own
141
+ * property, silently dropping that tool's configured override. */
142
+ maxToolCallArgBytesByTool ??= Object.create(null) as Record<
143
+ string,
144
+ number
145
+ >;
146
+ maxToolCallArgBytesByTool[name] = resolveLimit(
147
+ value,
148
+ maxToolCallArgBytes
149
+ );
150
+ }
151
+ }
152
+ const hasEnforceableToolCallArgLimit =
153
+ maxToolCallArgBytes > 0 ||
154
+ (maxToolCallArgBytesByTool != null &&
155
+ Object.values(maxToolCallArgBytesByTool).some((value) => value > 0));
156
+ return {
157
+ maxToolCallArgBytes,
158
+ ...(maxToolCallArgBytesByTool != null && { maxToolCallArgBytesByTool }),
159
+ maxDeltaEventsPerTurn: resolveLimit(limits?.maxDeltaEventsPerTurn, 0),
160
+ hasEnforceableToolCallArgLimit,
161
+ };
162
+ }
163
+
164
+ const DEFAULT_RESOLVED_LIMITS: ResolvedStreamLimits = resolveStreamLimits();
165
+
166
+ export type StreamLimitKind = 'tool_call_args' | 'delta_events';
167
+
168
+ function buildLimitMessage(
169
+ kind: StreamLimitKind,
170
+ limit: number,
171
+ toolName?: string
172
+ ): string {
173
+ if (kind === 'tool_call_args') {
174
+ const named =
175
+ toolName != null && toolName !== '' ? ` (tool call: ${toolName})` : '';
176
+ return (
177
+ `Streamed tool call arguments exceeded the ${limit}-byte safety limit${named}. ` +
178
+ 'The generation was aborted mid-stream: arguments growing this large without completing ' +
179
+ 'usually indicate a runaway or malformed tool call. Raise \'maxToolCallArgBytes\' (or the ' +
180
+ 'tool\'s \'maxToolCallArgBytesByTool\' entry) if your tools legitimately need larger arguments.'
181
+ );
182
+ }
183
+ return (
184
+ `Model stream exceeded the ${limit}-event safety limit for a single generation turn. ` +
185
+ 'The generation was aborted mid-stream: this usually indicates a looping or duplicated ' +
186
+ 'provider stream. Raise \'maxDeltaEventsPerTurn\' if legitimate generations need more stream events.'
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Raised when a {@link t.StreamLimits} guard trips. Thrown from inside the
192
+ * run's `streamEvents` loop, so the in-flight provider request is torn down
193
+ * and `processStream` rejects with this error.
194
+ */
195
+ export class StreamLimitExceededError extends Error {
196
+ readonly kind: StreamLimitKind;
197
+ readonly limit: number;
198
+ readonly observed: number;
199
+ readonly toolName?: string;
200
+
201
+ constructor({
202
+ kind,
203
+ limit,
204
+ observed,
205
+ toolName,
206
+ }: {
207
+ kind: StreamLimitKind;
208
+ limit: number;
209
+ observed: number;
210
+ toolName?: string;
211
+ }) {
212
+ super(buildLimitMessage(kind, limit, toolName));
213
+ this.name = 'StreamLimitExceededError';
214
+ this.kind = kind;
215
+ this.limit = limit;
216
+ this.observed = observed;
217
+ this.toolName = toolName;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Identity of one model generation, derived from langgraph's node-execution
223
+ * metadata. Deliberately NOT `Graph.getStepKey()`: the step key forks within
224
+ * a single generation on reasoning transitions (`'reasoning'` /
225
+ * `post-reasoning-<n>` suffixes in `getKeyList`) and on mid-turn server-tool
226
+ * results (`invokedToolIds` count), which would hand a fresh budget to each
227
+ * segment. One agent-node execution is one superstep, so
228
+ * `checkpoint_ns + node + step` stays stable for the whole generation and
229
+ * distinguishes parallel agents in the same superstep.
230
+ *
231
+ * The attempt stamp scopes attempts within one node execution:
232
+ * `attemptInvoke` is the single funnel for primary, fallback, and
233
+ * summarization model calls and stamps {@link STREAM_LIMIT_ATTEMPT_KEY}
234
+ * with a unique sequence number into each attempt's callback metadata. A
235
+ * fallback's chunks therefore key separately from the failed primary's,
236
+ * even for two fallbacks configured with the same provider and model name,
237
+ * and even when the decoupled `streamEvents` reader drains a failed
238
+ * attempt's buffered chunks late. Those late chunks land in their own
239
+ * attempt's bucket instead of polluting the next one's.
240
+ */
241
+ export function resolveGenerationKey(
242
+ metadata: Record<string, unknown> | undefined
243
+ ): string {
244
+ if (metadata == null) {
245
+ return '';
246
+ }
247
+ const checkpointNs = metadata.langgraph_checkpoint_ns ?? '';
248
+ const node = metadata.langgraph_node ?? '';
249
+ const step = metadata.langgraph_step ?? '';
250
+ const attempt = metadata[STREAM_LIMIT_ATTEMPT_KEY] ?? '';
251
+ return `${checkpointNs}|${node}|${step}|${attempt}`;
252
+ }
253
+
254
+ /**
255
+ * Metadata key carrying the unique per-model-attempt sequence number that
256
+ * `attemptInvoke` stamps into every attempt's callback metadata. Part of
257
+ * the generation key so budgets never alias across attempts.
258
+ */
259
+ export const STREAM_LIMIT_ATTEMPT_KEY = 'lc_stream_limit_attempt';
260
+
261
+ /**
262
+ * Event-metadata marker for a chunk the SDK re-dispatches inline after
263
+ * transforming it (`attemptInvoke`'s OpenRouter final-reasoning replay). The
264
+ * original wire chunk still reaches the handler through `streamEvents` and
265
+ * is counted there, so the re-dispatch must not consume a second
266
+ * event-budget slot.
267
+ */
268
+ export const STREAM_LIMIT_REDISPATCH_KEY = 'lc_stream_limit_redispatch';
269
+
270
+ /**
271
+ * Metadata key carrying the graph's breaker epoch at the time a model
272
+ * attempt started. The stream handler trips the shared breaker only when
273
+ * the event's epoch matches the live controller's — a straggling chunk from
274
+ * a failed run that outlived `resetValues()` must fail its own (dead) run,
275
+ * not abort the controller now serving the next one. A primitive rather
276
+ * than the controller itself so attempt metadata stays serialization-safe
277
+ * for tracing.
278
+ */
279
+ export const STREAM_LIMIT_EPOCH_KEY = 'lc_stream_limit_epoch';
280
+
281
+ /**
282
+ * Configurable key carrying the tool batch's entry-captured
283
+ * {@link RunBreakerScope} to tools that spawn their own runs (subagents).
284
+ * Captured BEFORE PreToolUse hooks, so a reset during a hook cannot rebind
285
+ * the spawned child to the new run's controller. Stripped from host-facing
286
+ * batch requests and from child-graph configurables.
287
+ */
288
+ export const RUN_BREAKER_SCOPE_CONFIG_KEY = 'lc_run_breaker_scope';
289
+
290
+ /**
291
+ * True when the event's `single` seal marks this chunk's own call as
292
+ * complete. On the OpenAI Responses adapter the sealing chunk RESTATES the
293
+ * full argument string (`response.function_call_arguments.done`), so summing
294
+ * it would double-count every legitimate call; on Bedrock Converse the
295
+ * sealing chunk carries empty args. Either way the call is finished: its
296
+ * tally is replaced by the sealing chunk's own bytes, checked, and released.
297
+ */
298
+ function sealsChunk(
299
+ seal: ReturnType<typeof getStreamedToolCallSeal>,
300
+ chunk: ToolCallChunk
301
+ ): boolean {
302
+ if (seal == null || seal.kind !== 'single') {
303
+ return false;
304
+ }
305
+ /** Either supplied identifier agreeing is sufficient (matching the
306
+ * eager-call seal handling in stream.ts): a mismatch on one identifier
307
+ * must not veto a match on the other, or an OpenAI-style full-argument
308
+ * restatement gets ADDED to the tally instead of replacing it, and an
309
+ * empty seal leaves the tally unreleased. */
310
+ if (
311
+ seal.index != null &&
312
+ chunk.index != null &&
313
+ seal.index === chunk.index
314
+ ) {
315
+ return true;
316
+ }
317
+ const sealId = seal.id != null && seal.id !== '' ? seal.id : undefined;
318
+ return sealId != null && sealId === chunkCallId(chunk);
319
+ }
320
+
321
+ function chunkToolName(chunk: ToolCallChunk): string | undefined {
322
+ return chunk.name != null && chunk.name !== '' ? chunk.name : undefined;
323
+ }
324
+
325
+ /** The chunk's id with empty-string placeholders treated as absent —
326
+ * OpenAI-compatible adapters emit `id: ''` on continuation deltas, and a
327
+ * shared placeholder must not merge independent calls onto one identity
328
+ * (mirrors `getEagerToolChunkKey`). */
329
+ function chunkCallId(chunk: ToolCallChunk): string | undefined {
330
+ return chunk.id != null && chunk.id !== '' ? chunk.id : undefined;
331
+ }
332
+
333
+ function isHighSurrogate(code: number): boolean {
334
+ return code >= 0xd800 && code <= 0xdbff;
335
+ }
336
+
337
+ function isLowSurrogate(code: number): boolean {
338
+ return code >= 0xdc00 && code <= 0xdfff;
339
+ }
340
+
341
+ /**
342
+ * Accumulates the UTF-8 byte size of streamed tool-call argument chunks per
343
+ * in-flight tool call and throws once a single call's cumulative bytes
344
+ * exceed `maxToolCallArgBytes`. Runs once per streamed chunk event, before
345
+ * complete tool calls are dispatched or eagerly executed and before chunks
346
+ * are recorded, so a tripped limit stops the run without dispatching the
347
+ * offending call.
348
+ *
349
+ * Calls are keyed by generation and chunk `index`, falling back to the
350
+ * chunk `id` and then to the chunk's position within the event's batch when
351
+ * a provider identifies chunks by neither (Google emits complete parallel
352
+ * calls with optional ids and no index). A `kind: 'all'` arrival seal marks
353
+ * every chunk in the event as its own complete call, so those are checked
354
+ * standalone and never share a budget; a matching `kind: 'single'` seal
355
+ * replaces the call's tally with the sealing chunk's own bytes (the OpenAI
356
+ * Responses done-chunk restates the full argument string) and releases it.
357
+ */
358
+ export function enforceStreamedToolCallArgLimit({
359
+ graph,
360
+ metadata,
361
+ toolCallChunks,
362
+ responseMetadata,
363
+ parsedToolCalls,
364
+ }: {
365
+ graph: StreamLimitState;
366
+ metadata: Record<string, unknown> | undefined;
367
+ toolCallChunks: ToolCallChunk[];
368
+ responseMetadata?: Record<string, unknown>;
369
+ /** Complete parsed calls from the same event, used to name anonymous raw
370
+ * chunks so per-tool overrides are honored before the global cap trips. */
371
+ parsedToolCalls?: CompleteToolCallLike[];
372
+ }): void {
373
+ const resolved = graph.streamLimits ?? DEFAULT_RESOLVED_LIMITS;
374
+ const globalLimit = resolved.maxToolCallArgBytes;
375
+ const byTool = resolved.maxToolCallArgBytesByTool;
376
+ if (!resolved.hasEnforceableToolCallArgLimit) {
377
+ return;
378
+ }
379
+ /** An inline re-dispatch of a transformed chunk (OpenRouter final-reasoning
380
+ * replay) duplicates tool-call chunks the original `streamEvents` event
381
+ * already charged — the original always survives the content-specific
382
+ * skips when it carries tool calls, so counting the marked copy would
383
+ * double every legitimate argument byte. */
384
+ if (metadata?.[STREAM_LIMIT_REDISPATCH_KEY] === true) {
385
+ return;
386
+ }
387
+ const tallies: Map<string, StreamedToolCallArgTally> =
388
+ (graph.streamedToolCallArgTallies ??= new Map<
389
+ string,
390
+ StreamedToolCallArgTally
391
+ >());
392
+ const generationKey = resolveGenerationKey(metadata);
393
+ const seal = getStreamedToolCallSeal(responseMetadata);
394
+ /** The complete call in this event sharing the chunk's id — the strongest
395
+ * name evidence: adapters may stream raw names in FRAGMENTS, and an
396
+ * id-correlated complete call states the full name outright. */
397
+ const correlateParsedNameById = (id: string): string | undefined => {
398
+ if (parsedToolCalls == null) {
399
+ return undefined;
400
+ }
401
+ for (const parsed of parsedToolCalls) {
402
+ if (parsed.id === id && parsed.name != null && parsed.name !== '') {
403
+ return parsed.name;
404
+ }
405
+ }
406
+ return undefined;
407
+ };
408
+ const generationPrefix = `${generationKey}:`;
409
+ const getLiveGenerationTallies = (): StreamedToolCallArgTally[] => {
410
+ const live = new Set<StreamedToolCallArgTally>();
411
+ for (const [tallyKey, tally] of tallies) {
412
+ if (tallyKey.startsWith(generationPrefix)) {
413
+ live.add(tally);
414
+ }
415
+ }
416
+ return [...live];
417
+ };
418
+ const resolveChunkName = (chunk: ToolCallChunk): string | undefined => {
419
+ const chunkId = chunkCallId(chunk);
420
+ const correlated =
421
+ chunkId != null ? correlateParsedNameById(chunkId) : undefined;
422
+ if (correlated != null) {
423
+ return correlated;
424
+ }
425
+ const name = chunkToolName(chunk);
426
+ if (name != null || parsedToolCalls == null || chunkId != null) {
427
+ return name;
428
+ }
429
+ /** No id to correlate on; positional association is unambiguous only
430
+ * when the event carries exactly one raw chunk AND one parsed call —
431
+ * with several id-less raw chunks in flight, handing one parsed call's
432
+ * name (and its override) to all of them would let an unrelated
433
+ * still-partial call bypass the global cap. */
434
+ if (parsedToolCalls.length === 1 && toolCallChunks.length === 1) {
435
+ const only = parsedToolCalls[0];
436
+ if (only.name != null && only.name !== '') {
437
+ return only.name;
438
+ }
439
+ }
440
+ return undefined;
441
+ };
442
+ const enforceTallyLimit = (target: StreamedToolCallArgTally): void => {
443
+ const tallyLimit =
444
+ byTool != null &&
445
+ target.name != null &&
446
+ Object.hasOwn(byTool, target.name)
447
+ ? byTool[target.name]
448
+ : globalLimit;
449
+ if (tallyLimit > 0 && target.bytes > tallyLimit) {
450
+ throw new StreamLimitExceededError({
451
+ kind: 'tool_call_args',
452
+ limit: tallyLimit,
453
+ observed: target.bytes,
454
+ toolName: target.name,
455
+ });
456
+ }
457
+ };
458
+ for (let i = 0; i < toolCallChunks.length; i++) {
459
+ const chunk = toolCallChunks[i];
460
+ const args = chunk.args;
461
+ const hasArgs = typeof args === 'string' && args !== '';
462
+ if (seal?.kind === 'all') {
463
+ if (!hasArgs) {
464
+ continue;
465
+ }
466
+ const bytes = Buffer.byteLength(args, 'utf8');
467
+ const sealedName = resolveChunkName(chunk);
468
+ const limit =
469
+ byTool != null && sealedName != null && Object.hasOwn(byTool, sealedName)
470
+ ? byTool[sealedName]
471
+ : globalLimit;
472
+ if (limit > 0 && bytes > limit) {
473
+ throw new StreamLimitExceededError({
474
+ kind: 'tool_call_args',
475
+ limit,
476
+ observed: bytes,
477
+ toolName: sealedName,
478
+ });
479
+ }
480
+ continue;
481
+ }
482
+ /** Keys are namespaced by identity kind (`i:` index, `c:` id, `#`
483
+ * batch position) so an index and a string id with the same textual
484
+ * value — index 0 and id "0" — cannot alias distinct calls onto one
485
+ * tally. Empty-string ids are placeholders, not identities. */
486
+ const chunkId = chunkCallId(chunk);
487
+ const sealed = sealsChunk(seal, chunk);
488
+ /** Applies this chunk's name contribution and reports whether the
489
+ * effective name changed. Adapters may stream the name in FRAGMENTS
490
+ * ("create_" then "file"), so unsealed fragments append — matching
491
+ * langchain's own tool-call-chunk merge — while a sealing chunk's name
492
+ * is a full restatement and replaces, mirroring how sealed args replace
493
+ * the tally bytes. An id-correlated complete call in the same event
494
+ * outranks both; positional parsed-call correlation fills in only when
495
+ * the chunk carries no fragment. */
496
+ const applyChunkName = (target: StreamedToolCallArgTally): boolean => {
497
+ const correlated =
498
+ chunkId != null ? correlateParsedNameById(chunkId) : undefined;
499
+ const fragment = chunkToolName(chunk);
500
+ let next = target.name;
501
+ if (correlated != null) {
502
+ /** An id-correlated complete call wins over fragment accumulation:
503
+ * a raw name like "create_" must not hide the "create_file"
504
+ * override behind the global cap. */
505
+ next = correlated;
506
+ } else if (fragment != null) {
507
+ if (sealed || target.name == null) {
508
+ next = fragment;
509
+ } else if (fragment !== target.name) {
510
+ /** A fragment identical to the accumulated name is a repeated
511
+ * full-name delta (a common provider shape) and is a no-op; a
512
+ * differing fragment is a continuation and appends. */
513
+ next = target.name + fragment;
514
+ }
515
+ } else if (target.name == null) {
516
+ next = resolveChunkName(chunk);
517
+ }
518
+ if (next === target.name) {
519
+ return false;
520
+ }
521
+ target.name = next;
522
+ return true;
523
+ };
524
+ /** A single identifier-less event while calls are live is a sparse
525
+ * parallel continuation: its position is relative to this event, not to
526
+ * the original batch, so `#0` cannot identify which call it belongs to.
527
+ * With exactly one live call the association is unambiguous — charge
528
+ * that tally, keeping its budget continuous (a fresh `#0` tally here
529
+ * would RESET the sole remaining call's byte budget). With several,
530
+ * charge every live candidate. Each tally is judged under its OWN
531
+ * name-specific limit: comparing only against the global cap would let
532
+ * a call with a lower per-tool override stream past it indefinitely
533
+ * while its chunks stay anonymous. */
534
+ if (
535
+ chunk.index == null &&
536
+ chunkId == null &&
537
+ toolCallChunks.length === 1
538
+ ) {
539
+ const liveTallies = getLiveGenerationTallies();
540
+ if (liveTallies.length > 0) {
541
+ if (liveTallies.length === 1 && applyChunkName(liveTallies[0])) {
542
+ /** A late name can select a lower override than the limit under
543
+ * which prior bytes were accepted, so re-judge before charging
544
+ * this continuation. */
545
+ enforceTallyLimit(liveTallies[0]);
546
+ }
547
+ if (hasArgs) {
548
+ const argBytes = Buffer.byteLength(args, 'utf8');
549
+ for (const liveTally of liveTallies) {
550
+ const reconcilesSplitPair =
551
+ liveTally.pendingHighSurrogate === true &&
552
+ isLowSurrogate(args.charCodeAt(0));
553
+ liveTally.bytes += reconcilesSplitPair ? argBytes - 2 : argBytes;
554
+ liveTally.pendingHighSurrogate = isHighSurrogate(
555
+ args.charCodeAt(args.length - 1)
556
+ );
557
+ enforceTallyLimit(liveTally);
558
+ }
559
+ }
560
+ continue;
561
+ }
562
+ }
563
+ let key: string;
564
+ if (chunk.index != null) {
565
+ key = `${generationKey}:i:${chunk.index}`;
566
+ } else if (chunkId != null) {
567
+ key = `${generationKey}:c:${chunkId}`;
568
+ } else {
569
+ key = `${generationKey}:#${i}`;
570
+ }
571
+ /** Batch position is a stable identity only when the event carries a
572
+ * single chunk: sparse parallel events reuse position 0 for whichever
573
+ * call happens to continue, and a position alias would hand one call's
574
+ * tally — and its per-tool override — to another live call's deltas. */
575
+ const positionAliasSafe = toolCallChunks.length === 1;
576
+ /** Secondary keys this call is also reachable under, so a later delta
577
+ * that drops one or both identifiers still lands on the same tally:
578
+ * id-bearing chunks register the batch-position fallback (single-chunk
579
+ * events only), and chunks carrying both identifiers additionally
580
+ * register the id. */
581
+ const aliasCandidates: string[] = [];
582
+ if (chunkId != null) {
583
+ if (positionAliasSafe) {
584
+ aliasCandidates.push(`${generationKey}:#${i}`);
585
+ }
586
+ if (chunk.index != null) {
587
+ aliasCandidates.push(`${generationKey}:c:${chunkId}`);
588
+ }
589
+ }
590
+ let tally = tallies.get(key);
591
+ /** A later delta can also ADD an identifier, changing the primary key;
592
+ * adopt the call's existing tally through a stronger prior identity
593
+ * before allocating. An index maps to that call's original batch
594
+ * position, never this sparse event's position. A newly arrived id can
595
+ * adopt a position only when one anonymous tally is live; with several,
596
+ * positional association is ambiguous. The old identity is recorded as
597
+ * an alias and released together with the rest. */
598
+ if (tally == null) {
599
+ const adoptionCandidates: string[] = [];
600
+ if (chunkId != null) {
601
+ adoptionCandidates.push(`${generationKey}:c:${chunkId}`);
602
+ }
603
+ if (chunk.index != null) {
604
+ /** An index is stable across sparse events; the loop position is
605
+ * merely this event's position and may be 0 for call index 1. */
606
+ const indexedPositionKey = `${generationKey}:#${chunk.index}`;
607
+ adoptionCandidates.push(indexedPositionKey);
608
+ /** A single anonymous call may first appear alone at event position
609
+ * #0 and reveal a nonzero provider index only later. Exact indexed
610
+ * position wins above; when it does not exist, one anonymous tally
611
+ * is still an unambiguous fallback. */
612
+ const liveTallies = getLiveGenerationTallies();
613
+ const soleTally = liveTallies.length === 1 ? liveTallies[0] : undefined;
614
+ if (
615
+ soleTally?.keys?.[0]?.startsWith(`${generationPrefix}#`) === true
616
+ ) {
617
+ const anonymousKey = soleTally.keys[0];
618
+ if (anonymousKey !== indexedPositionKey) {
619
+ adoptionCandidates.push(anonymousKey);
620
+ }
621
+ }
622
+ } else if (chunkId != null) {
623
+ const anonymousTallies = getLiveGenerationTallies().filter(
624
+ (liveTally: StreamedToolCallArgTally) =>
625
+ liveTally.keys?.[0]?.startsWith(`${generationPrefix}#`) === true
626
+ );
627
+ if (anonymousTallies.length === 1) {
628
+ const anonymousKey = anonymousTallies[0].keys?.[0];
629
+ if (anonymousKey != null) {
630
+ adoptionCandidates.push(anonymousKey);
631
+ }
632
+ }
633
+ }
634
+ for (const adoptionKey of adoptionCandidates) {
635
+ if (adoptionKey === key) {
636
+ continue;
637
+ }
638
+ const existing = tallies.get(adoptionKey);
639
+ if (existing == null) {
640
+ continue;
641
+ }
642
+ /** A position entry may only be adopted when the tally was CREATED
643
+ * anonymous (its first key is the position): id-bearing calls also
644
+ * alias their position, and adopting a live parallel call's alias
645
+ * would merge distinct budgets. */
646
+ if (
647
+ adoptionKey.includes(':#') &&
648
+ existing.keys?.[0] !== adoptionKey
649
+ ) {
650
+ continue;
651
+ }
652
+ tally = existing;
653
+ tallies.set(key, existing);
654
+ if (existing.keys?.includes(adoptionKey) !== true) {
655
+ (existing.keys ??= []).push(adoptionKey);
656
+ }
657
+ if (!existing.keys.includes(key)) {
658
+ existing.keys.push(key);
659
+ }
660
+ break;
661
+ }
662
+ }
663
+ const registerAliasKeys = (
664
+ target: StreamedToolCallArgTally,
665
+ candidates: string[]
666
+ ): void => {
667
+ for (const aliasKey of candidates) {
668
+ const currentOwner = tallies.get(aliasKey);
669
+ if (currentOwner === target) {
670
+ continue;
671
+ }
672
+ /** Parallel calls can contend for one batch position, so the newest
673
+ * live call takes the alias over and the previous owner is disowned
674
+ * — its seal must not delete a key it no longer holds. */
675
+ if (currentOwner?.keys != null) {
676
+ const remaining = currentOwner.keys.filter(
677
+ (ownedKey: string) => ownedKey !== aliasKey
678
+ );
679
+ currentOwner.keys = remaining.length > 0 ? remaining : undefined;
680
+ }
681
+ tallies.set(aliasKey, target);
682
+ (target.keys ??= []).push(aliasKey);
683
+ }
684
+ };
685
+ const registerAliases = (target: StreamedToolCallArgTally): void => {
686
+ registerAliasKeys(target, aliasCandidates);
687
+ };
688
+ /** Index-only calls register their batch position ONCE, at creation:
689
+ * later deltas that drop the index then land on the same tally, while
690
+ * the per-delta hot path (indexed id-less deltas) stays free of alias
691
+ * work. */
692
+ const registerCreationPositionAlias = (
693
+ target: StreamedToolCallArgTally
694
+ ): void => {
695
+ if (chunkId != null || chunk.index == null || sealed) {
696
+ return;
697
+ }
698
+ if (!positionAliasSafe) {
699
+ return;
700
+ }
701
+ registerAliasKeys(target, [`${generationKey}:#${i}`]);
702
+ };
703
+ const releaseTally = (target: StreamedToolCallArgTally): void => {
704
+ tallies.delete(key);
705
+ if (target.keys == null) {
706
+ return;
707
+ }
708
+ for (const aliasKey of target.keys) {
709
+ if (tallies.get(aliasKey) === target) {
710
+ tallies.delete(aliasKey);
711
+ }
712
+ }
713
+ };
714
+ if (!hasArgs) {
715
+ if (tally == null) {
716
+ if (!sealed) {
717
+ tally = {
718
+ bytes: 0,
719
+ name: resolveChunkName(chunk),
720
+ keys: [key],
721
+ epoch: graph.breakerEpoch,
722
+ };
723
+ tallies.set(key, tally);
724
+ registerAliases(tally);
725
+ registerCreationPositionAlias(tally);
726
+ }
727
+ continue;
728
+ }
729
+ /** A sealing chunk is about to release this tally; taking the position
730
+ * alias here would steal it from a still-live parallel call. */
731
+ if (!sealed) {
732
+ registerAliases(tally);
733
+ }
734
+ if (applyChunkName(tally)) {
735
+ /** Bytes tallied under the previous (or absent) name were held
736
+ * against that name's limit; a changed name — late arrival or a
737
+ * completed fragment — must re-judge them, including on a sealing
738
+ * chunk about to release the tally. */
739
+ enforceTallyLimit(tally);
740
+ }
741
+ if (sealed) {
742
+ releaseTally(tally);
743
+ }
744
+ continue;
745
+ }
746
+ if (tally == null) {
747
+ tally = { bytes: 0, keys: [key], epoch: graph.breakerEpoch };
748
+ tallies.set(key, tally);
749
+ registerCreationPositionAlias(tally);
750
+ }
751
+ if (!sealed) {
752
+ registerAliases(tally);
753
+ }
754
+ applyChunkName(tally);
755
+ const argBytes = Buffer.byteLength(args, 'utf8');
756
+ if (sealed) {
757
+ tally.bytes = argBytes;
758
+ } else {
759
+ const reconcilesSplitPair =
760
+ tally.pendingHighSurrogate === true &&
761
+ isLowSurrogate(args.charCodeAt(0));
762
+ tally.bytes += reconcilesSplitPair ? argBytes - 2 : argBytes;
763
+ }
764
+ tally.pendingHighSurrogate =
765
+ !sealed && isHighSurrogate(args.charCodeAt(args.length - 1));
766
+ const toolName = tally.name ?? resolveChunkName(chunk);
767
+ const limit =
768
+ byTool != null && toolName != null && Object.hasOwn(byTool, toolName)
769
+ ? byTool[toolName]
770
+ : globalLimit;
771
+ if (limit > 0 && tally.bytes > limit) {
772
+ throw new StreamLimitExceededError({
773
+ kind: 'tool_call_args',
774
+ limit,
775
+ observed: tally.bytes,
776
+ toolName,
777
+ });
778
+ }
779
+ if (sealed) {
780
+ releaseTally(tally);
781
+ }
782
+ }
783
+ }
784
+
785
+ /** Structural subset of a complete parsed tool call. */
786
+ interface CompleteToolCallLike {
787
+ id?: string;
788
+ name?: string;
789
+ args?: unknown;
790
+ }
791
+
792
+ /**
793
+ * Standalone byte check for complete parsed tool calls that arrive without a
794
+ * raw chunk representation: a streaming custom or OpenAI-compatible
795
+ * `ChatModel` can yield fully parsed `tool_calls` with empty
796
+ * `tool_call_chunks`, which would otherwise dispatch without consuming any
797
+ * byte budget. Complete calls are self-contained, so each is judged
798
+ * standalone without tallying.
799
+ */
800
+ export function enforceCompleteToolCallArgLimit({
801
+ graph,
802
+ metadata,
803
+ toolCalls,
804
+ }: {
805
+ graph: StreamLimitState;
806
+ metadata: Record<string, unknown> | undefined;
807
+ toolCalls: CompleteToolCallLike[];
808
+ }): void {
809
+ const resolved = graph.streamLimits ?? DEFAULT_RESOLVED_LIMITS;
810
+ const globalLimit = resolved.maxToolCallArgBytes;
811
+ const byTool = resolved.maxToolCallArgBytesByTool;
812
+ if (!resolved.hasEnforceableToolCallArgLimit) {
813
+ return;
814
+ }
815
+ if (metadata?.[STREAM_LIMIT_REDISPATCH_KEY] === true) {
816
+ return;
817
+ }
818
+ for (const toolCall of toolCalls) {
819
+ const name =
820
+ toolCall.name != null && toolCall.name !== '' ? toolCall.name : undefined;
821
+ const limit =
822
+ byTool != null && name != null && Object.hasOwn(byTool, name)
823
+ ? byTool[name]
824
+ : globalLimit;
825
+ if (limit <= 0) {
826
+ continue;
827
+ }
828
+ const args = toolCall.args;
829
+ let serialized: string;
830
+ if (typeof args === 'string') {
831
+ serialized = args;
832
+ } else if (args == null) {
833
+ serialized = '';
834
+ } else {
835
+ serialized = JSON.stringify(args);
836
+ }
837
+ const bytes = Buffer.byteLength(serialized, 'utf8');
838
+ if (bytes > limit) {
839
+ throw new StreamLimitExceededError({
840
+ kind: 'tool_call_args',
841
+ limit,
842
+ observed: bytes,
843
+ toolName: name,
844
+ });
845
+ }
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Whether a chunk needs charge accounting at all. With the event cap off
851
+ * (the default) and no tool payload on the chunk, there is nothing a claim
852
+ * could ever gate — skipping restores the documented zero-cost-disabled
853
+ * behavior by avoiding a WeakMap entry and nested Map per ordinary text
854
+ * delta. Both the producer and consumer paths use this same predicate on
855
+ * the same chunk, so claim pairing is unaffected.
856
+ */
857
+ export function requiresStreamLimitAccounting(
858
+ graph: StreamLimitState,
859
+ chunk: {
860
+ tool_call_chunks?: unknown[];
861
+ tool_calls?: unknown[];
862
+ invalid_tool_calls?: unknown[];
863
+ }
864
+ ): boolean {
865
+ const resolved = graph.streamLimits ?? DEFAULT_RESOLVED_LIMITS;
866
+ if (resolved.maxDeltaEventsPerTurn > 0) {
867
+ return true;
868
+ }
869
+ /** With no argument limit able to fire (byte cap disabled and every
870
+ * per-tool entry a zero-valued disable), neither enforcement function can
871
+ * trip — hosts that explicitly disable the guards for legitimate large
872
+ * arguments must not pay per-chunk claim allocations (generation keys,
873
+ * WeakMap entries, nested Maps) for bookkeeping that judges nothing. */
874
+ if (!resolved.hasEnforceableToolCallArgLimit) {
875
+ return false;
876
+ }
877
+ return (
878
+ (chunk.tool_call_chunks?.length ?? 0) > 0 ||
879
+ (chunk.tool_calls?.length ?? 0) > 0 ||
880
+ (chunk.invalid_tool_calls?.length ?? 0) > 0
881
+ );
882
+ }
883
+
884
+ /** True when any stream-limit guard can fire for this graph. The attempt
885
+ * lease and per-chunk claims are both gated on it — fully disabled guards
886
+ * must allocate no bookkeeping at all, per-attempt included. */
887
+ export function streamLimitAccountingEnabled(
888
+ graph: StreamLimitState
889
+ ): boolean {
890
+ const resolved = graph.streamLimits ?? DEFAULT_RESOLVED_LIMITS;
891
+ return (
892
+ resolved.hasEnforceableToolCallArgLimit ||
893
+ resolved.maxDeltaEventsPerTurn > 0
894
+ );
895
+ }
896
+
897
+ /** Leases a model attempt's generation: entries under it are exempt from
898
+ * the reset sweep until {@link releaseStreamLimitGeneration} runs from the
899
+ * attempt's `finally`. */
900
+ export function registerActiveStreamLimitGeneration(
901
+ graph: StreamLimitState,
902
+ generationKey: string
903
+ ): void {
904
+ (graph.activeStreamLimitGenerations ??= new Set()).add(generationKey);
905
+ }
906
+
907
+ /** Ends an attempt's lease and deletes its accounting entries — the
908
+ * authoritative retirement point for attempt-scoped state. */
909
+ export function releaseStreamLimitGeneration(
910
+ graph: StreamLimitState,
911
+ generationKey: string
912
+ ): void {
913
+ graph.activeStreamLimitGenerations?.delete(generationKey);
914
+ deleteGenerationEntries(graph.streamedToolCallArgTallies, generationKey);
915
+ deleteGenerationEntries(graph.streamDeltaEventCounts, generationKey);
916
+ }
917
+
918
+ function deleteGenerationEntries(
919
+ entries: Map<string, unknown> | undefined,
920
+ generationKey: string
921
+ ): void {
922
+ if (entries == null) {
923
+ return;
924
+ }
925
+ const prefix = `${generationKey}:`;
926
+ for (const key of entries.keys()) {
927
+ if (key === generationKey || key.startsWith(prefix)) {
928
+ entries.delete(key);
929
+ }
930
+ }
931
+ }
932
+
933
+ function isOwnedByActiveGeneration(
934
+ key: string,
935
+ activeGenerations: ReadonlySet<string>
936
+ ): boolean {
937
+ for (const generationKey of activeGenerations) {
938
+ if (key === generationKey || key.startsWith(`${generationKey}:`)) {
939
+ return true;
940
+ }
941
+ }
942
+ return false;
943
+ }
944
+
945
+ /**
946
+ * Deletes accounting entries older than the epoch that is ending, EXCEPT
947
+ * entries leased by a still-active attempt. Called by `resetValues` instead
948
+ * of clearing: producer loops of straggling attempts use the graph's maps
949
+ * directly and are not behind the consumer-only epoch gate, so a clear
950
+ * would hand a cancellation-ignoring provider a fresh allowance at every
951
+ * run start. Leased entries live until their attempt's `finally` releases
952
+ * them; unleased entries (direct callers with no attempt stamp) get one
953
+ * grace reset via their epoch tag.
954
+ */
955
+ export function sweepStaleStreamLimitEntries(
956
+ entries: Map<string, { epoch?: number }>,
957
+ endingEpoch: number,
958
+ activeGenerations?: ReadonlySet<string>
959
+ ): void {
960
+ const hasActive = activeGenerations != null && activeGenerations.size > 0;
961
+ for (const [key, value] of entries) {
962
+ if (value.epoch === endingEpoch) {
963
+ continue;
964
+ }
965
+ if (hasActive && isOwnedByActiveGeneration(key, activeGenerations)) {
966
+ continue;
967
+ }
968
+ entries.delete(key);
969
+ }
970
+ }
971
+
972
+ /** Links a secondary representation of a wire chunk (e.g. a provider
973
+ * adapter's callback copy) to its canonical emission object, so claim
974
+ * accounting treats both as one emission even though their identities
975
+ * differ. Non-enumerable, so the link stays out of serialization and chunk
976
+ * merges. */
977
+ const STREAM_LIMIT_CANONICAL: unique symbol = Symbol('streamLimitCanonical');
978
+
979
+ export function linkStreamLimitCanonical(
980
+ copy: object,
981
+ canonical: object
982
+ ): void {
983
+ Object.defineProperty(copy, STREAM_LIMIT_CANONICAL, {
984
+ value: canonical,
985
+ enumerable: false,
986
+ configurable: true,
987
+ });
988
+ }
989
+
990
+ function canonicalChunk(chunk: object): object {
991
+ const linked = (chunk as Record<PropertyKey, unknown>)[
992
+ STREAM_LIMIT_CANONICAL
993
+ ];
994
+ return typeof linked === 'object' && linked != null ? linked : chunk;
995
+ }
996
+
997
+ /**
998
+ * Claims accounting ownership of one EMISSION of a wire chunk. LangChain can
999
+ * hand the same chunk object to the decoupled `streamEvents` handler
1000
+ * (`consumer`) and to the dispatch loop (`producer`) in either order, and a
1001
+ * streaming model may mutate and re-yield the same object across emissions —
1002
+ * so dedup is a signed credit balance per object rather than a lifetime set.
1003
+ * Each producer visit adds a credit, each consumer visit removes one, and a
1004
+ * visit charges only when the other side has not already charged that
1005
+ * emission (positive balance = producer ahead, negative = consumer ahead).
1006
+ * Paths where only one side ever observes the chunk (summarization, local
1007
+ * replay-skip) charge every visit, since their balance never crosses zero
1008
+ * the other way. Balances are scoped by generation identity so parallel
1009
+ * generations sharing one reused chunk object cannot cancel each other's
1010
+ * charges. Non-object chunks cannot be identity-tracked and are always
1011
+ * claimable.
1012
+ */
1013
+ export function claimStreamLimitCharge(
1014
+ graph: StreamLimitState,
1015
+ chunk: unknown,
1016
+ side: 'producer' | 'consumer',
1017
+ metadata: Record<string, unknown> | undefined
1018
+ ): boolean {
1019
+ if (typeof chunk !== 'object' || chunk == null) {
1020
+ return true;
1021
+ }
1022
+ const emission = canonicalChunk(chunk);
1023
+ const credits = (graph.streamLimitChargeCredits ??= new WeakMap());
1024
+ let byGeneration = credits.get(emission);
1025
+ if (byGeneration == null) {
1026
+ byGeneration = new Map();
1027
+ credits.set(emission, byGeneration);
1028
+ }
1029
+ const generationKey = resolveGenerationKey(metadata);
1030
+ const balance = byGeneration.get(generationKey) ?? 0;
1031
+ if (side === 'producer') {
1032
+ byGeneration.set(generationKey, balance + 1);
1033
+ return balance >= 0;
1034
+ }
1035
+ byGeneration.set(generationKey, balance - 1);
1036
+ return balance <= 0;
1037
+ }
1038
+
1039
+ /**
1040
+ * Synchronous producer-side accounting for wire chunks that would otherwise
1041
+ * be judged only when the decoupled `streamEvents` reader catches up — or,
1042
+ * on replay-skipped and summarization chunks, not at all. A lagging reader
1043
+ * would let an oversized complete call return to LangGraph and reach
1044
+ * `ToolNode` before the queued handler throws; charging in the producer
1045
+ * loop keeps the breaker ahead of graph progression. Claim-based, so
1046
+ * whichever of this path and the handler echo sees the chunk object first
1047
+ * charges it and the other skips.
1048
+ */
1049
+ export function enforceStreamLimitsForWireChunk({
1050
+ graph,
1051
+ metadata,
1052
+ chunk,
1053
+ side = 'producer',
1054
+ }: {
1055
+ graph: StreamLimitState;
1056
+ metadata: Record<string, unknown> | undefined;
1057
+ chunk: {
1058
+ tool_call_chunks?: ToolCallChunk[];
1059
+ tool_calls?: CompleteToolCallLike[];
1060
+ invalid_tool_calls?: CompleteToolCallLike[];
1061
+ response_metadata?: Record<string, unknown>;
1062
+ };
1063
+ /** Claim side for the credit balance. The local dispatch branch charges
1064
+ * as `consumer` because its handler-handled and replay-skipped emissions
1065
+ * of one reused object ALTERNATE — mixed sides would pair them as
1066
+ * producer/echo and swallow a charge. */
1067
+ side?: 'producer' | 'consumer';
1068
+ }): void {
1069
+ if (!requiresStreamLimitAccounting(graph, chunk)) {
1070
+ return;
1071
+ }
1072
+ if (!claimStreamLimitCharge(graph, chunk, side, metadata)) {
1073
+ return;
1074
+ }
1075
+ enforceStreamDeltaEventLimit({ graph, metadata });
1076
+ /** Combined view computed first so the raw-chunk guard can correlate
1077
+ * names from BOTH parsed and invalid calls in the same event; an unnamed
1078
+ * raw chunk twinned with a named invalid call must select that tool's
1079
+ * override, not the global cap. */
1080
+ const completeCalls = combineCompleteToolCalls(chunk);
1081
+ if (chunk.tool_call_chunks != null && chunk.tool_call_chunks.length > 0) {
1082
+ enforceStreamedToolCallArgLimit({
1083
+ graph,
1084
+ metadata,
1085
+ toolCallChunks: chunk.tool_call_chunks,
1086
+ responseMetadata: chunk.response_metadata,
1087
+ parsedToolCalls: completeCalls,
1088
+ });
1089
+ }
1090
+ /** Judged whenever parsed calls are present, not only when raw chunks are
1091
+ * absent: an adapter can pair an empty or partial raw chunk with a
1092
+ * complete parsed call, and the standalone check is stateless so the
1093
+ * common both-present case is not double-tallied. `invalid_tool_calls`
1094
+ * are included — ToolNode processes and promotes them, and a malformed
1095
+ * call streaming oversized arguments is the exact pathology this breaker
1096
+ * exists to stop. */
1097
+ if (completeCalls != null) {
1098
+ enforceCompleteToolCallArgLimit({ graph, metadata, toolCalls: completeCalls });
1099
+ }
1100
+ }
1101
+
1102
+ /** Combined view of a chunk's parsed and invalid complete calls, avoiding
1103
+ * allocation on the common paths where one or both are absent. */
1104
+ export function combineCompleteToolCalls(chunk: {
1105
+ tool_calls?: CompleteToolCallLike[];
1106
+ invalid_tool_calls?: CompleteToolCallLike[];
1107
+ }): CompleteToolCallLike[] | undefined {
1108
+ const parsed = chunk.tool_calls;
1109
+ const invalid = chunk.invalid_tool_calls;
1110
+ const hasParsed = parsed != null && parsed.length > 0;
1111
+ const hasInvalid = invalid != null && invalid.length > 0;
1112
+ if (hasParsed && hasInvalid) {
1113
+ return [...parsed, ...invalid];
1114
+ }
1115
+ if (hasParsed) {
1116
+ return parsed;
1117
+ }
1118
+ return hasInvalid ? invalid : undefined;
1119
+ }
1120
+
1121
+ /**
1122
+ * Counts streamed chunk events per model generation and throws once a single
1123
+ * generation exceeds `maxDeltaEventsPerTurn`. Opt-in defense in depth for
1124
+ * pathologies a byte cap cannot see, such as a provider stream looping on
1125
+ * empty chunks. Zero cost while disabled.
1126
+ */
1127
+ export function enforceStreamDeltaEventLimit({
1128
+ graph,
1129
+ metadata,
1130
+ }: {
1131
+ graph: StreamLimitState;
1132
+ metadata: Record<string, unknown> | undefined;
1133
+ }): void {
1134
+ const limit = (graph.streamLimits ?? DEFAULT_RESOLVED_LIMITS)
1135
+ .maxDeltaEventsPerTurn;
1136
+ if (limit <= 0) {
1137
+ return;
1138
+ }
1139
+ if (metadata?.[STREAM_LIMIT_REDISPATCH_KEY] === true) {
1140
+ return;
1141
+ }
1142
+ const counts = (graph.streamDeltaEventCounts ??= new Map());
1143
+ const key = resolveGenerationKey(metadata);
1144
+ const existing = counts.get(key);
1145
+ const next = (existing?.count ?? 0) + 1;
1146
+ if (next > limit) {
1147
+ throw new StreamLimitExceededError({
1148
+ kind: 'delta_events',
1149
+ limit,
1150
+ observed: next,
1151
+ });
1152
+ }
1153
+ if (existing != null) {
1154
+ existing.count = next;
1155
+ } else {
1156
+ counts.set(key, { count: next, epoch: graph.breakerEpoch });
1157
+ }
1158
+ }