@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,450 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type { ToolCallChunk } from '@langchain/core/messages/tool';
3
+ import type { StreamLimitState } from '@/llm/streamLimits';
4
+ import {
5
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
6
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
7
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
8
+ BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
9
+ } from '@/tools/streamedToolCallSeals';
10
+ import {
11
+ DEFAULT_MAX_TOOL_CALL_ARG_BYTES,
12
+ STREAM_LIMIT_REDISPATCH_KEY,
13
+ STREAM_LIMIT_ATTEMPT_KEY,
14
+ enforceStreamedToolCallArgLimit,
15
+ enforceStreamDeltaEventLimit,
16
+ StreamLimitExceededError,
17
+ resolveGenerationKey,
18
+ resolveStreamLimits,
19
+ } from '@/llm/streamLimits';
20
+
21
+ const chunk = (fields: Partial<ToolCallChunk>): ToolCallChunk =>
22
+ ({ type: 'tool_call_chunk', ...fields }) as ToolCallChunk;
23
+
24
+ const generation = (step: number): Record<string, unknown> => ({
25
+ langgraph_checkpoint_ns: '',
26
+ langgraph_node: 'agent',
27
+ langgraph_step: step,
28
+ });
29
+
30
+ describe('resolveStreamLimits', () => {
31
+ it('defaults the byte cap ON and the event cap OFF', () => {
32
+ expect(resolveStreamLimits()).toEqual({
33
+ maxToolCallArgBytes: DEFAULT_MAX_TOOL_CALL_ARG_BYTES,
34
+ maxDeltaEventsPerTurn: 0,
35
+ hasEnforceableToolCallArgLimit: true,
36
+ });
37
+ expect(resolveStreamLimits({})).toEqual({
38
+ maxToolCallArgBytes: DEFAULT_MAX_TOOL_CALL_ARG_BYTES,
39
+ maxDeltaEventsPerTurn: 0,
40
+ hasEnforceableToolCallArgLimit: true,
41
+ });
42
+ });
43
+
44
+ it('honors explicit values and floors fractions', () => {
45
+ expect(
46
+ resolveStreamLimits({ maxToolCallArgBytes: 100.9, maxDeltaEventsPerTurn: 5 })
47
+ ).toEqual({
48
+ maxToolCallArgBytes: 100,
49
+ maxDeltaEventsPerTurn: 5,
50
+ hasEnforceableToolCallArgLimit: true,
51
+ });
52
+ });
53
+
54
+ it('treats 0, negatives, and Infinity as disabled', () => {
55
+ expect(resolveStreamLimits({ maxToolCallArgBytes: 0 }).maxToolCallArgBytes).toBe(0);
56
+ expect(resolveStreamLimits({ maxToolCallArgBytes: -5 }).maxToolCallArgBytes).toBe(0);
57
+ expect(
58
+ resolveStreamLimits({ maxToolCallArgBytes: Infinity }).maxToolCallArgBytes
59
+ ).toBe(0);
60
+ expect(
61
+ resolveStreamLimits({ maxDeltaEventsPerTurn: -1 }).maxDeltaEventsPerTurn
62
+ ).toBe(0);
63
+ });
64
+
65
+ it('falls back to the default on NaN', () => {
66
+ expect(resolveStreamLimits({ maxToolCallArgBytes: NaN }).maxToolCallArgBytes).toBe(
67
+ DEFAULT_MAX_TOOL_CALL_ARG_BYTES
68
+ );
69
+ });
70
+ });
71
+
72
+ describe('resolveGenerationKey', () => {
73
+ it('derives a stable key from langgraph node-execution metadata', () => {
74
+ expect(resolveGenerationKey(generation(3))).toBe('|agent|3|');
75
+ expect(resolveGenerationKey(generation(3))).toBe(resolveGenerationKey(generation(3)));
76
+ });
77
+
78
+ it('separates supersteps, nodes, and namespaces', () => {
79
+ expect(resolveGenerationKey(generation(3))).not.toBe(resolveGenerationKey(generation(4)));
80
+ expect(
81
+ resolveGenerationKey({ ...generation(3), langgraph_node: 'other_agent' })
82
+ ).not.toBe(resolveGenerationKey(generation(3)));
83
+ expect(
84
+ resolveGenerationKey({ ...generation(3), langgraph_checkpoint_ns: 'child' })
85
+ ).not.toBe(resolveGenerationKey(generation(3)));
86
+ });
87
+
88
+ it('degrades to a shared bucket when metadata is absent', () => {
89
+ expect(resolveGenerationKey(undefined)).toBe('');
90
+ expect(resolveGenerationKey({})).toBe('|||');
91
+ });
92
+
93
+ it('scopes model attempts via the attempt stamp, even for identical models', () => {
94
+ const attemptOne = { ...generation(1), [STREAM_LIMIT_ATTEMPT_KEY]: 7 };
95
+ const attemptTwo = { ...generation(1), [STREAM_LIMIT_ATTEMPT_KEY]: 8 };
96
+ expect(resolveGenerationKey(attemptOne)).not.toBe(resolveGenerationKey(attemptTwo));
97
+
98
+ const graph: StreamLimitState = {
99
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 10 }),
100
+ };
101
+ enforceStreamedToolCallArgLimit({
102
+ graph,
103
+ metadata: attemptOne,
104
+ toolCallChunks: [chunk({ args: '12345678', index: 0 })],
105
+ });
106
+ enforceStreamedToolCallArgLimit({
107
+ graph,
108
+ metadata: attemptTwo,
109
+ toolCallChunks: [chunk({ args: '12345678', index: 0 })],
110
+ });
111
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|7:i:0')?.bytes).toBe(8);
112
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|8:i:0')?.bytes).toBe(8);
113
+ });
114
+ });
115
+
116
+ describe('enforceStreamedToolCallArgLimit', () => {
117
+ it('accumulates across chunk events and throws past the limit', () => {
118
+ const graph: StreamLimitState = {
119
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 10 }),
120
+ };
121
+ const metadata = generation(1);
122
+ enforceStreamedToolCallArgLimit({
123
+ graph,
124
+ metadata,
125
+ toolCallChunks: [chunk({ name: 'db_query', id: 'call_1', args: '', index: 0 })],
126
+ });
127
+ enforceStreamedToolCallArgLimit({
128
+ graph,
129
+ metadata,
130
+ toolCallChunks: [chunk({ args: '12345', index: 0 })],
131
+ });
132
+ enforceStreamedToolCallArgLimit({
133
+ graph,
134
+ metadata,
135
+ toolCallChunks: [chunk({ args: '67890', index: 0 })],
136
+ });
137
+ let caught: unknown;
138
+ try {
139
+ enforceStreamedToolCallArgLimit({
140
+ graph,
141
+ metadata,
142
+ toolCallChunks: [chunk({ args: '!', index: 0 })],
143
+ });
144
+ } catch (error) {
145
+ caught = error;
146
+ }
147
+ expect(caught).toBeInstanceOf(StreamLimitExceededError);
148
+ const limitError = caught as StreamLimitExceededError;
149
+ expect(limitError.kind).toBe('tool_call_args');
150
+ expect(limitError.limit).toBe(10);
151
+ expect(limitError.observed).toBe(11);
152
+ expect(limitError.toolName).toBe('db_query');
153
+ expect(limitError.message).toContain('10-byte');
154
+ expect(limitError.message).toContain('(tool call: db_query)');
155
+ expect(limitError.message).toContain('maxToolCallArgBytes');
156
+ });
157
+
158
+ it('counts UTF-8 bytes, not string length', () => {
159
+ const graph: StreamLimitState = {
160
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 5 }),
161
+ };
162
+ expect(() =>
163
+ enforceStreamedToolCallArgLimit({
164
+ graph,
165
+ metadata: generation(1),
166
+ toolCallChunks: [chunk({ args: '€€', index: 0 })],
167
+ })
168
+ ).toThrow(StreamLimitExceededError);
169
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|:i:0')?.bytes).toBe(6);
170
+ });
171
+
172
+ it('reconciles surrogate pairs split across chunk boundaries', () => {
173
+ const graph: StreamLimitState = {
174
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 4 }),
175
+ };
176
+ const metadata = generation(1);
177
+ const [high, low] = ['\uD83D', '\uDE00'];
178
+ enforceStreamedToolCallArgLimit({
179
+ graph,
180
+ metadata,
181
+ toolCallChunks: [chunk({ args: high, index: 0 })],
182
+ });
183
+ enforceStreamedToolCallArgLimit({
184
+ graph,
185
+ metadata,
186
+ toolCallChunks: [chunk({ args: low, index: 0 })],
187
+ });
188
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|:i:0')?.bytes).toBe(4);
189
+ expect(() =>
190
+ enforceStreamedToolCallArgLimit({
191
+ graph,
192
+ metadata,
193
+ toolCallChunks: [chunk({ args: 'x', index: 0 })],
194
+ })
195
+ ).toThrow(StreamLimitExceededError);
196
+ });
197
+
198
+ it('keeps counting unpaired surrogates conservatively', () => {
199
+ const graph: StreamLimitState = {
200
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 100 }),
201
+ };
202
+ const metadata = generation(1);
203
+ enforceStreamedToolCallArgLimit({
204
+ graph,
205
+ metadata,
206
+ toolCallChunks: [chunk({ args: '\uD83D', index: 0 })],
207
+ });
208
+ enforceStreamedToolCallArgLimit({
209
+ graph,
210
+ metadata,
211
+ toolCallChunks: [chunk({ args: 'abc', index: 0 })],
212
+ });
213
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|:i:0')?.bytes).toBe(6);
214
+ });
215
+
216
+ it('tallies parallel tool calls and generations independently', () => {
217
+ const graph: StreamLimitState = {
218
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 8 }),
219
+ };
220
+ enforceStreamedToolCallArgLimit({
221
+ graph,
222
+ metadata: generation(1),
223
+ toolCallChunks: [
224
+ chunk({ name: 'first', args: '123456', index: 0 }),
225
+ chunk({ name: 'second', args: '123456', index: 1 }),
226
+ ],
227
+ });
228
+ enforceStreamedToolCallArgLimit({
229
+ graph,
230
+ metadata: generation(2),
231
+ toolCallChunks: [chunk({ args: '123456', index: 0 })],
232
+ });
233
+ expect(() =>
234
+ enforceStreamedToolCallArgLimit({
235
+ graph,
236
+ metadata: generation(1),
237
+ toolCallChunks: [chunk({ args: '789', index: 1 })],
238
+ })
239
+ ).toThrow('(tool call: second)');
240
+ });
241
+
242
+ it('falls back to the chunk id when index is absent', () => {
243
+ const graph: StreamLimitState = {
244
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 8 }),
245
+ };
246
+ const metadata = generation(1);
247
+ enforceStreamedToolCallArgLimit({
248
+ graph,
249
+ metadata,
250
+ toolCallChunks: [chunk({ id: 'call_a', name: 'a_tool', args: '123456' })],
251
+ });
252
+ enforceStreamedToolCallArgLimit({
253
+ graph,
254
+ metadata,
255
+ toolCallChunks: [chunk({ id: 'call_b', name: 'b_tool', args: '123456' })],
256
+ });
257
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|:c:call_a')?.bytes).toBe(6);
258
+ expect(() =>
259
+ enforceStreamedToolCallArgLimit({
260
+ graph,
261
+ metadata,
262
+ toolCallChunks: [chunk({ id: 'call_b', args: '789' })],
263
+ })
264
+ ).toThrow('(tool call: b_tool)');
265
+ });
266
+
267
+ it('keys anonymous chunks by batch position so parallel calls stay separate', () => {
268
+ const graph: StreamLimitState = {
269
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 8 }),
270
+ };
271
+ const metadata = generation(1);
272
+ enforceStreamedToolCallArgLimit({
273
+ graph,
274
+ metadata,
275
+ toolCallChunks: [
276
+ chunk({ name: 'anon_a', args: '123456' }),
277
+ chunk({ name: 'anon_b', args: '123456' }),
278
+ ],
279
+ });
280
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|:#0')?.bytes).toBe(6);
281
+ expect(graph.streamedToolCallArgTallies?.get('|agent|1|:#1')?.bytes).toBe(6);
282
+ expect(() =>
283
+ enforceStreamedToolCallArgLimit({
284
+ graph,
285
+ metadata,
286
+ toolCallChunks: [chunk({ args: '789' })],
287
+ })
288
+ ).toThrow(StreamLimitExceededError);
289
+ });
290
+
291
+ it('checks arrival-sealed complete calls standalone, keeping no tally', () => {
292
+ const graph: StreamLimitState = {
293
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 64 }),
294
+ };
295
+ const sealAll = { [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'all' } };
296
+ enforceStreamedToolCallArgLimit({
297
+ graph,
298
+ metadata: generation(1),
299
+ toolCallChunks: [
300
+ chunk({ args: 'x'.repeat(40) }),
301
+ chunk({ args: 'y'.repeat(40) }),
302
+ ],
303
+ responseMetadata: sealAll,
304
+ });
305
+ expect(graph.streamedToolCallArgTallies?.size).toBe(0);
306
+ expect(() =>
307
+ enforceStreamedToolCallArgLimit({
308
+ graph,
309
+ metadata: generation(1),
310
+ toolCallChunks: [chunk({ name: 'big_call', args: 'z'.repeat(70) })],
311
+ responseMetadata: sealAll,
312
+ })
313
+ ).toThrow('(tool call: big_call)');
314
+ });
315
+
316
+ it('replaces the tally when a single-seal chunk restates complete args (OpenAI Responses)', () => {
317
+ const graph: StreamLimitState = {
318
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 50 }),
319
+ };
320
+ const metadata = generation(1);
321
+ const fullArgs = 'x'.repeat(40);
322
+ enforceStreamedToolCallArgLimit({
323
+ graph,
324
+ metadata,
325
+ toolCallChunks: [chunk({ args: fullArgs.slice(0, 20), index: 0 })],
326
+ });
327
+ enforceStreamedToolCallArgLimit({
328
+ graph,
329
+ metadata,
330
+ toolCallChunks: [chunk({ args: fullArgs.slice(20), index: 0 })],
331
+ });
332
+ enforceStreamedToolCallArgLimit({
333
+ graph,
334
+ metadata,
335
+ toolCallChunks: [chunk({ name: 'writer', args: fullArgs, index: 0 })],
336
+ responseMetadata: {
337
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
338
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
339
+ [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'single', index: 0 },
340
+ },
341
+ });
342
+ expect(graph.streamedToolCallArgTallies?.has('|agent|1|:i:0')).toBe(false);
343
+ });
344
+
345
+ it('releases the tally on a Bedrock Converse stop chunk without counting it', () => {
346
+ const graph: StreamLimitState = {
347
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 50 }),
348
+ };
349
+ const metadata = generation(1);
350
+ enforceStreamedToolCallArgLimit({
351
+ graph,
352
+ metadata,
353
+ toolCallChunks: [chunk({ args: '1234567890', index: 0 })],
354
+ });
355
+ enforceStreamedToolCallArgLimit({
356
+ graph,
357
+ metadata,
358
+ toolCallChunks: [chunk({ args: '', index: 0 })],
359
+ responseMetadata: {
360
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
361
+ BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
362
+ [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'single', index: 0 },
363
+ },
364
+ });
365
+ expect(graph.streamedToolCallArgTallies?.has('|agent|1|:i:0')).toBe(false);
366
+ });
367
+
368
+ it('does nothing when disabled', () => {
369
+ const graph: StreamLimitState = {
370
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 0 }),
371
+ };
372
+ enforceStreamedToolCallArgLimit({
373
+ graph,
374
+ metadata: generation(1),
375
+ toolCallChunks: [chunk({ args: 'x'.repeat(1_000_000), index: 0 })],
376
+ });
377
+ expect(graph.streamedToolCallArgTallies).toBeUndefined();
378
+ });
379
+
380
+ it('applies the default limit when graph state was never resolved', () => {
381
+ const graph: StreamLimitState = {};
382
+ const metadata = generation(1);
383
+ enforceStreamedToolCallArgLimit({
384
+ graph,
385
+ metadata,
386
+ toolCallChunks: [
387
+ chunk({ args: 'x'.repeat(DEFAULT_MAX_TOOL_CALL_ARG_BYTES), index: 0 }),
388
+ ],
389
+ });
390
+ expect(() =>
391
+ enforceStreamedToolCallArgLimit({
392
+ graph,
393
+ metadata,
394
+ toolCallChunks: [chunk({ args: 'x', index: 0 })],
395
+ })
396
+ ).toThrow(StreamLimitExceededError);
397
+ });
398
+ });
399
+
400
+ describe('enforceStreamDeltaEventLimit', () => {
401
+ it('throws once a single generation exceeds the event limit', () => {
402
+ const graph: StreamLimitState = {
403
+ streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 3 }),
404
+ };
405
+ const metadata = generation(1);
406
+ enforceStreamDeltaEventLimit({ graph, metadata });
407
+ enforceStreamDeltaEventLimit({ graph, metadata });
408
+ enforceStreamDeltaEventLimit({ graph, metadata });
409
+ let caught: unknown;
410
+ try {
411
+ enforceStreamDeltaEventLimit({ graph, metadata });
412
+ } catch (error) {
413
+ caught = error;
414
+ }
415
+ expect(caught).toBeInstanceOf(StreamLimitExceededError);
416
+ const limitError = caught as StreamLimitExceededError;
417
+ expect(limitError.kind).toBe('delta_events');
418
+ expect(limitError.limit).toBe(3);
419
+ expect(limitError.observed).toBe(4);
420
+ expect(limitError.message).toContain('maxDeltaEventsPerTurn');
421
+ enforceStreamDeltaEventLimit({ graph, metadata: generation(2) });
422
+ expect(graph.streamDeltaEventCounts?.get('|agent|2|')?.count).toBe(1);
423
+ });
424
+
425
+ it('is disabled by default and allocates nothing', () => {
426
+ const graph: StreamLimitState = {};
427
+ for (let i = 0; i < 10; i++) {
428
+ enforceStreamDeltaEventLimit({ graph, metadata: generation(1) });
429
+ }
430
+ expect(graph.streamDeltaEventCounts).toBeUndefined();
431
+ });
432
+
433
+ it('does not double-count inline re-dispatched transformed chunks', () => {
434
+ const graph: StreamLimitState = {
435
+ streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 2 }),
436
+ };
437
+ const redispatch = {
438
+ ...generation(1),
439
+ [STREAM_LIMIT_REDISPATCH_KEY]: true,
440
+ };
441
+ for (let i = 0; i < 10; i++) {
442
+ enforceStreamDeltaEventLimit({ graph, metadata: redispatch });
443
+ }
444
+ enforceStreamDeltaEventLimit({ graph, metadata: generation(1) });
445
+ enforceStreamDeltaEventLimit({ graph, metadata: generation(1) });
446
+ expect(() =>
447
+ enforceStreamDeltaEventLimit({ graph, metadata: generation(1) })
448
+ ).toThrow(StreamLimitExceededError);
449
+ });
450
+ });