@librechat/agents 3.3.11 → 3.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist/cjs/graphs/Graph.cjs +246 -25
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  4. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  5. package/dist/cjs/llm/invoke.cjs +146 -36
  6. package/dist/cjs/llm/invoke.cjs.map +1 -1
  7. package/dist/cjs/llm/openai/index.cjs +2 -2
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/llm/streamLimits.cjs +723 -0
  10. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  11. package/dist/cjs/main.cjs +8 -0
  12. package/dist/cjs/run.cjs +6 -2
  13. package/dist/cjs/run.cjs.map +1 -1
  14. package/dist/cjs/session/AgentSession.cjs +4 -1
  15. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  16. package/dist/cjs/stream.cjs +103 -12
  17. package/dist/cjs/stream.cjs.map +1 -1
  18. package/dist/cjs/summarization/node.cjs +157 -37
  19. package/dist/cjs/summarization/node.cjs.map +1 -1
  20. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  21. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  22. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  23. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  24. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  25. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  26. package/dist/cjs/tools/ToolNode.cjs +76 -3
  27. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  28. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  29. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  30. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  31. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  32. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  33. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  34. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  35. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  36. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  37. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  38. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  39. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  40. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  41. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  42. package/dist/cjs/tools/search/search.cjs +30 -10
  43. package/dist/cjs/tools/search/search.cjs.map +1 -1
  44. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  45. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  46. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  47. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  48. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  49. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  50. package/dist/cjs/tools/search/tool.cjs +16 -2
  51. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  52. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  53. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  54. package/dist/cjs/utils/index.cjs +1 -0
  55. package/dist/cjs/utils/misc.cjs +12 -0
  56. package/dist/cjs/utils/misc.cjs.map +1 -1
  57. package/dist/cjs/utils/proxy.cjs +63 -0
  58. package/dist/cjs/utils/proxy.cjs.map +1 -0
  59. package/dist/esm/graphs/Graph.mjs +245 -24
  60. package/dist/esm/graphs/Graph.mjs.map +1 -1
  61. package/dist/esm/llm/bedrock/index.mjs +13 -2
  62. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  63. package/dist/esm/llm/invoke.mjs +146 -36
  64. package/dist/esm/llm/invoke.mjs.map +1 -1
  65. package/dist/esm/llm/openai/index.mjs +2 -2
  66. package/dist/esm/llm/openai/index.mjs.map +1 -1
  67. package/dist/esm/llm/streamLimits.mjs +704 -0
  68. package/dist/esm/llm/streamLimits.mjs.map +1 -0
  69. package/dist/esm/main.mjs +4 -2
  70. package/dist/esm/run.mjs +6 -2
  71. package/dist/esm/run.mjs.map +1 -1
  72. package/dist/esm/session/AgentSession.mjs +4 -1
  73. package/dist/esm/session/AgentSession.mjs.map +1 -1
  74. package/dist/esm/stream.mjs +103 -12
  75. package/dist/esm/stream.mjs.map +1 -1
  76. package/dist/esm/summarization/node.mjs +157 -37
  77. package/dist/esm/summarization/node.mjs.map +1 -1
  78. package/dist/esm/tools/BashExecutor.mjs +3 -2
  79. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  80. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  81. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  82. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  83. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  84. package/dist/esm/tools/ToolNode.mjs +76 -3
  85. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  86. package/dist/esm/tools/ToolSearch.mjs +3 -2
  87. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  88. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  89. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  90. package/dist/esm/tools/search/crw-search.mjs +3 -1
  91. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  92. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  93. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  94. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  95. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  96. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  97. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  98. package/dist/esm/tools/search/rerankers.mjs +26 -8
  99. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  100. package/dist/esm/tools/search/search.mjs +30 -10
  101. package/dist/esm/tools/search/search.mjs.map +1 -1
  102. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  103. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  104. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  105. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  106. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  107. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  108. package/dist/esm/tools/search/tool.mjs +16 -2
  109. package/dist/esm/tools/search/tool.mjs.map +1 -1
  110. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  111. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  112. package/dist/esm/utils/index.mjs +1 -0
  113. package/dist/esm/utils/misc.mjs +12 -1
  114. package/dist/esm/utils/misc.mjs.map +1 -1
  115. package/dist/esm/utils/proxy.mjs +62 -0
  116. package/dist/esm/utils/proxy.mjs.map +1 -0
  117. package/dist/types/graphs/Graph.d.ts +53 -1
  118. package/dist/types/index.d.ts +2 -0
  119. package/dist/types/llm/invoke.d.ts +25 -4
  120. package/dist/types/llm/openai/index.d.ts +3 -0
  121. package/dist/types/llm/streamLimits.d.ts +314 -0
  122. package/dist/types/run.d.ts +1 -0
  123. package/dist/types/summarization/node.d.ts +27 -2
  124. package/dist/types/tools/BashExecutor.d.ts +2 -2
  125. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  126. package/dist/types/tools/ToolNode.d.ts +11 -1
  127. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  128. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  129. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  130. package/dist/types/tools/search/rerankers.d.ts +9 -5
  131. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  132. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  133. package/dist/types/tools/search/types.d.ts +28 -33
  134. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  135. package/dist/types/types/graph.d.ts +7 -1
  136. package/dist/types/types/run.d.ts +46 -1
  137. package/dist/types/types/tools.d.ts +21 -0
  138. package/dist/types/utils/index.d.ts +1 -0
  139. package/dist/types/utils/misc.d.ts +7 -0
  140. package/dist/types/utils/proxy.d.ts +31 -0
  141. package/package.json +2 -1
  142. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  143. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  144. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  145. package/src/graphs/Graph.ts +302 -27
  146. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  147. package/src/index.ts +11 -0
  148. package/src/llm/bedrock/index.ts +22 -2
  149. package/src/llm/invoke.streamLimits.test.ts +142 -0
  150. package/src/llm/invoke.test.ts +89 -1
  151. package/src/llm/invoke.ts +197 -20
  152. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  153. package/src/llm/openai/index.ts +14 -6
  154. package/src/llm/streamLimits.test.ts +450 -0
  155. package/src/llm/streamLimits.ts +1158 -0
  156. package/src/run.ts +4 -0
  157. package/src/session/AgentSession.ts +5 -0
  158. package/src/stream.ts +141 -6
  159. package/src/summarization/__tests__/node.test.ts +269 -0
  160. package/src/summarization/chunkHandler.test.ts +196 -0
  161. package/src/summarization/node.ts +203 -6
  162. package/src/tools/BashExecutor.ts +4 -3
  163. package/src/tools/CodeExecutor.ts +5 -4
  164. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  165. package/src/tools/ToolNode.ts +109 -6
  166. package/src/tools/ToolSearch.ts +4 -3
  167. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  168. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  169. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  170. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  171. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  172. package/src/tools/search/crw-scraper.ts +6 -0
  173. package/src/tools/search/crw-search.ts +6 -1
  174. package/src/tools/search/firecrawl.ts +6 -0
  175. package/src/tools/search/http-agent.test.ts +133 -0
  176. package/src/tools/search/keenable-scraper.ts +6 -0
  177. package/src/tools/search/keenable-search.ts +6 -1
  178. package/src/tools/search/rerankers.ts +36 -10
  179. package/src/tools/search/search.ts +29 -11
  180. package/src/tools/search/serper-scraper.ts +6 -0
  181. package/src/tools/search/tavily-scraper.ts +6 -0
  182. package/src/tools/search/tavily-search.ts +2 -0
  183. package/src/tools/search/tool.ts +16 -0
  184. package/src/tools/search/types.ts +31 -33
  185. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  186. package/src/types/graph.ts +7 -0
  187. package/src/types/run.ts +49 -1
  188. package/src/types/tools.ts +21 -0
  189. package/src/utils/index.ts +1 -0
  190. package/src/utils/misc.ts +19 -0
  191. package/src/utils/proxy.test.ts +176 -0
  192. package/src/utils/proxy.ts +93 -0
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type { RunnableConfig } from '@langchain/core/runnables';
3
+ import type * as t from '@/types';
4
+ import { StreamLimitExceededError } from '@/llm/streamLimits';
5
+ import { Providers } from '@/common';
6
+ import { StandardGraph } from '../Graph';
7
+
8
+ const makeAgent = (agentId: string): t.AgentInputs => ({
9
+ agentId,
10
+ provider: Providers.OPENAI,
11
+ instructions: `You are ${agentId}.`,
12
+ });
13
+
14
+ const makeGraph = (): StandardGraph =>
15
+ new StandardGraph({
16
+ runId: 'run_1',
17
+ agents: [makeAgent('agent')],
18
+ });
19
+
20
+ const makeTrip = (): StreamLimitExceededError =>
21
+ new StreamLimitExceededError({
22
+ kind: 'tool_call_args',
23
+ limit: 10,
24
+ observed: 11,
25
+ toolName: 'db_query',
26
+ });
27
+
28
+ describe('run breaker lifecycle', () => {
29
+ it('replaces the breaker at every run start, even when un-aborted', () => {
30
+ const graph = makeGraph();
31
+ const previous = graph.breakerAbort;
32
+ expect(previous.signal.aborted).toBe(false);
33
+
34
+ graph.resetValues();
35
+
36
+ expect(graph.breakerAbort).not.toBe(previous);
37
+ /** A straggler from the failed run trips the controller it captured;
38
+ * the run starting now must not observe it. */
39
+ previous.abort(makeTrip());
40
+ expect(graph.breakerAbort.signal.aborted).toBe(false);
41
+ });
42
+
43
+ it('keeps straggler accounting through cleanup and one reset, then sweeps it', () => {
44
+ const graph = makeGraph();
45
+ const epoch = graph.breakerEpoch;
46
+ graph.streamedToolCallArgTallies.set('gen:i:0', { bytes: 42, epoch });
47
+ graph.streamDeltaEventCounts.set('gen', { count: 7, epoch });
48
+ graph.streamLimitChargeCredits = new WeakMap();
49
+
50
+ /** Cleanup runs while sibling attempts can still be unwinding on the
51
+ * retained breaker; clearing here would hand a cancellation-ignoring
52
+ * provider's late chunks a fresh budget. */
53
+ graph.clearHeavyState();
54
+ expect(graph.streamedToolCallArgTallies.get('gen:i:0')?.bytes).toBe(42);
55
+ expect(graph.streamDeltaEventCounts.get('gen')?.count).toBe(7);
56
+ expect(graph.streamLimitChargeCredits).toBeDefined();
57
+
58
+ /** Producer loops of straggling attempts sit OUTSIDE the consumer-only
59
+ * epoch gate, so their entries must survive the next run start too —
60
+ * one grace reset keeps them on their original budgets. */
61
+ graph.resetValues();
62
+ expect(graph.streamedToolCallArgTallies.get('gen:i:0')?.bytes).toBe(42);
63
+ expect(graph.streamDeltaEventCounts.get('gen')?.count).toBe(7);
64
+ expect(graph.streamLimitChargeCredits).toBeUndefined();
65
+
66
+ /** A second reset sweeps entries from older epochs. */
67
+ graph.resetValues();
68
+ expect(graph.streamedToolCallArgTallies.size).toBe(0);
69
+ expect(graph.streamDeltaEventCounts.size).toBe(0);
70
+ });
71
+
72
+ it('holds a leased producer straggler to its budget across two run resets', async () => {
73
+ const { attemptInvoke } = await import('@/llm/invoke');
74
+ const { resolveStreamLimits } = await import('@/llm/streamLimits');
75
+ const { AIMessageChunk, HumanMessage } = await import(
76
+ '@langchain/core/messages'
77
+ );
78
+ const graph = makeGraph();
79
+ graph.streamLimits = resolveStreamLimits({ maxToolCallArgBytes: 100 });
80
+ const chunkOf = (args: string): InstanceType<typeof AIMessageChunk> =>
81
+ new AIMessageChunk({
82
+ content: '',
83
+ tool_call_chunks: [
84
+ {
85
+ type: 'tool_call_chunk',
86
+ id: 'call_1',
87
+ name: 'writer',
88
+ args,
89
+ index: 0,
90
+ },
91
+ ],
92
+ });
93
+ let yields = 0;
94
+ const model = {
95
+ stream: async function* (): AsyncGenerator<
96
+ InstanceType<typeof AIMessageChunk>
97
+ > {
98
+ yields += 1;
99
+ yield chunkOf('x'.repeat(60));
100
+ /** Two prompt runs start and reset while this attempt is still
101
+ * draining. Its lease must keep the tally alive — reset counting
102
+ * cannot prove the attempt finished. */
103
+ graph.resetValues();
104
+ graph.resetValues();
105
+ yields += 1;
106
+ yield chunkOf('x'.repeat(60));
107
+ yields += 1;
108
+ yield chunkOf('x');
109
+ },
110
+ };
111
+
112
+ await expect(
113
+ attemptInvoke(
114
+ {
115
+ model: model as unknown as t.ChatModel,
116
+ messages: [new HumanMessage('hi')],
117
+ provider: Providers.OPENAI,
118
+ context: graph as Parameters<
119
+ typeof attemptInvoke
120
+ >[0]['context'],
121
+ onChunk: () => {
122
+ /* producer loop enforcement is under test */
123
+ },
124
+ },
125
+ { metadata: {} }
126
+ )
127
+ ).rejects.toMatchObject({ kind: 'tool_call_args', limit: 100 });
128
+ expect(yields).toBe(2);
129
+ /** The attempt's finally released its lease and entries. */
130
+ expect(graph.activeStreamLimitGenerations?.size ?? 0).toBe(0);
131
+ });
132
+
133
+ it('takes no attempt lease when every guard is disabled', async () => {
134
+ const { attemptInvoke } = await import('@/llm/invoke');
135
+ const { resolveStreamLimits } = await import('@/llm/streamLimits');
136
+ const { AIMessage, HumanMessage } = await import(
137
+ '@langchain/core/messages'
138
+ );
139
+ const graph = makeGraph();
140
+ graph.streamLimits = resolveStreamLimits({
141
+ maxToolCallArgBytes: 0,
142
+ maxToolCallArgBytesByTool: { writer: 0 },
143
+ });
144
+ const model = {
145
+ invoke: async (): Promise<InstanceType<typeof AIMessage>> =>
146
+ new AIMessage({ content: 'ok' }),
147
+ };
148
+
149
+ await attemptInvoke(
150
+ {
151
+ model: model as unknown as t.ChatModel,
152
+ messages: [new HumanMessage('hi')],
153
+ provider: Providers.OPENAI,
154
+ context: graph as Parameters<typeof attemptInvoke>[0]['context'],
155
+ },
156
+ { metadata: {} }
157
+ );
158
+
159
+ /** The lease only protects accounting entries; with no guard able to
160
+ * fire there are none, and per-attempt bookkeeping must not allocate. */
161
+ expect(graph.activeStreamLimitGenerations).toBeUndefined();
162
+ });
163
+
164
+ it('holds a post-reset producer straggler to its original byte budget', async () => {
165
+ const { enforceStreamLimitsForWireChunk } = await import(
166
+ '@/llm/streamLimits'
167
+ );
168
+ const graph = makeGraph();
169
+ graph.streamLimits = {
170
+ maxToolCallArgBytes: 100,
171
+ maxDeltaEventsPerTurn: 0,
172
+ hasEnforceableToolCallArgLimit: true,
173
+ };
174
+ const metadata = {
175
+ langgraph_checkpoint_ns: '',
176
+ langgraph_node: 'agent',
177
+ langgraph_step: 1,
178
+ };
179
+ const chunkOf = (
180
+ args: string
181
+ ): { tool_call_chunks: Array<Record<string, unknown>> } => ({
182
+ tool_call_chunks: [{ id: 'call_1', name: 'writer', args, index: 0 }],
183
+ });
184
+
185
+ enforceStreamLimitsForWireChunk({
186
+ graph,
187
+ metadata,
188
+ chunk: chunkOf('x'.repeat(60)) as Parameters<
189
+ typeof enforceStreamLimitsForWireChunk
190
+ >[0]['chunk'],
191
+ });
192
+
193
+ /** The next run starts while this producer is still draining. Its next
194
+ * chunk must land on the ORIGINAL 60-byte tally and trip, not open a
195
+ * fresh allowance. */
196
+ graph.resetValues();
197
+ expect(() =>
198
+ enforceStreamLimitsForWireChunk({
199
+ graph,
200
+ metadata,
201
+ chunk: chunkOf('x'.repeat(60)) as Parameters<
202
+ typeof enforceStreamLimitsForWireChunk
203
+ >[0]['chunk'],
204
+ })
205
+ ).toThrow(StreamLimitExceededError);
206
+ });
207
+
208
+ it('rejects a model node at entry when the breaker has already tripped', async () => {
209
+ const graph = makeGraph();
210
+ const trip = makeTrip();
211
+ graph.breakerAbort.abort(trip);
212
+
213
+ const node = graph.createCallModel('agent');
214
+ await expect(
215
+ node(
216
+ { messages: [] } as unknown as t.AgentSubgraphState,
217
+ {} as RunnableConfig
218
+ )
219
+ ).rejects.toBe(trip);
220
+ });
221
+
222
+ it('lets a model node proceed past entry when the breaker is live', async () => {
223
+ const graph = makeGraph();
224
+ const node = graph.createCallModel('agent');
225
+ /** With a live breaker the entry guard must not fire; the node then
226
+ * fails later, on the missing config, proving it got past the guard. */
227
+ await expect(
228
+ node(
229
+ { messages: [] } as unknown as t.AgentSubgraphState,
230
+ undefined
231
+ )
232
+ ).rejects.toThrow('No config provided');
233
+ });
234
+ });
package/src/index.ts CHANGED
@@ -82,3 +82,14 @@ export { initializeModel } from './llm/init';
82
82
  export { attemptInvoke, tryFallbackProviders } from './llm/invoke';
83
83
  export { canSealPreempt } from './llm/preempt';
84
84
  export { isThinkingEnabled, getMaxOutputTokensKey } from './llm/request';
85
+ export {
86
+ DEFAULT_MAX_TOOL_CALL_ARG_BYTES,
87
+ StreamLimitExceededError,
88
+ resolveStreamLimits,
89
+ } from './llm/streamLimits';
90
+ export type {
91
+ StreamedToolCallArgTally,
92
+ ResolvedStreamLimits,
93
+ StreamLimitState,
94
+ StreamLimitKind,
95
+ } from './llm/streamLimits';
@@ -46,6 +46,7 @@ import {
46
46
  supportsBedrockToolCache,
47
47
  type PromptCacheTtl,
48
48
  } from '@/messages/cache';
49
+ import { linkStreamLimitCanonical } from '@/llm/streamLimits';
49
50
  import { applyCachePointsToConversePayload } from './cachePoints';
50
51
  import { insertBedrockToolCachePoint } from './toolCache';
51
52
 
@@ -572,8 +573,17 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
572
573
  }
573
574
 
574
575
  const deltaChunk = handleConverseStreamContentBlockDelta(splitDelta);
576
+ const enrichedChunk = this.enrichChunk(deltaChunk, seenBlockIndices);
577
+ if (enrichedChunk !== deltaChunk) {
578
+ /** The callback copy is the same emission as the enriched yield;
579
+ * without the link, stream-limit accounting charges both message
580
+ * objects and Bedrock tool arguments falsely trip near half the
581
+ * cap. Linked on the messages, which are what the accounting
582
+ * observes. */
583
+ linkStreamLimitCanonical(deltaChunk.message, enrichedChunk.message);
584
+ }
575
585
  await enqueueChunk({
576
- chunk: this.enrichChunk(deltaChunk, seenBlockIndices),
586
+ chunk: enrichedChunk,
577
587
  callbackChunk: deltaChunk,
578
588
  callbackToken: deltaChunk.text,
579
589
  smooth,
@@ -602,8 +612,18 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
602
612
  toolUseBlockIndices.add(idx);
603
613
  }
604
614
  }
615
+ const enrichedStart = this.enrichChunk(
616
+ startChunk,
617
+ seenBlockIndices
618
+ );
619
+ if (enrichedStart !== startChunk) {
620
+ linkStreamLimitCanonical(
621
+ startChunk.message,
622
+ enrichedStart.message
623
+ );
624
+ }
605
625
  await enqueueChunk({
606
- chunk: this.enrichChunk(startChunk, seenBlockIndices),
626
+ chunk: enrichedStart,
607
627
  callbackChunk: startChunk,
608
628
  callbackToken: startChunk.text,
609
629
  });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `attemptInvoke`'s local stream branch (no registered SDK dispatcher) must
3
+ * charge the event budget even for chunks that produce no handling chunk:
4
+ * a pure OpenRouter reasoning-replay chunk is skipped for content handling,
5
+ * and in this branch no `streamEvents` consumer counts the wire event
6
+ * either, so a looping replay stream would otherwise bypass an enabled
7
+ * `maxDeltaEventsPerTurn` indefinitely.
8
+ */
9
+ import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
10
+ import { describe, it, expect, jest } from '@jest/globals';
11
+ import type { AgentContext } from '@/agents/AgentContext';
12
+ import type { StandardGraph } from '@/graphs';
13
+ import type * as t from '@/types';
14
+ import {
15
+ StreamLimitExceededError,
16
+ resolveStreamLimits,
17
+ } from '@/llm/streamLimits';
18
+ import { attemptInvoke } from '@/llm/invoke';
19
+ import { ContentTypes, Providers } from '@/common';
20
+
21
+ function createContext(
22
+ overrides: Partial<StandardGraph> = {}
23
+ ): StandardGraph {
24
+ const runSteps = new Map<string, t.RunStep>();
25
+ const stepIdsByKey = new Map<string, string>();
26
+ let stepCounter = 0;
27
+
28
+ const graph = {
29
+ config: { configurable: { user_id: 'user_1' }, metadata: { run_id: 'r1' } },
30
+ eagerEventToolExecution: undefined,
31
+ eagerEventToolExecutions: new Map(),
32
+ eagerEventToolCallChunks: new Map(),
33
+ eagerEventToolSuppressions: new Set<string>(),
34
+ handlerRegistry: undefined,
35
+ hookRegistry: undefined,
36
+ humanInTheLoop: undefined,
37
+ toolOutputReferences: undefined,
38
+ sessions: new Map(),
39
+ toolCallStepIds: new Map(),
40
+ messageIdsByStepKey: new Map(),
41
+ messageStepHasToolCalls: new Map(),
42
+ prelimMessageIdsByStepKey: new Map([['step-key', 'msg_1']]),
43
+ getAgentContext: jest.fn(
44
+ (): Partial<AgentContext> => ({
45
+ provider: Providers.OPENAI,
46
+ reasoningKey: 'reasoning_content',
47
+ currentTokenType: ContentTypes.TEXT,
48
+ toolDefinitions: [],
49
+ graphTools: [],
50
+ agentId: 'agent_1',
51
+ })
52
+ ),
53
+ getOrCreateToolOutputRegistry: jest.fn(() => undefined),
54
+ shouldPreemptStream: jest.fn(() => false),
55
+ claimPreemptSeal: jest.fn(() => false),
56
+ getStepKey: jest.fn(() => 'step-key'),
57
+ getStepIdByKey: jest.fn((stepKey: string) => {
58
+ const stepId = stepIdsByKey.get(stepKey);
59
+ if (stepId == null) {
60
+ throw new Error('no current step');
61
+ }
62
+ return stepId;
63
+ }),
64
+ getRunStep: jest.fn((stepId: string) => runSteps.get(stepId)),
65
+ dispatchRunStep: jest.fn(async (stepKey: string, details: unknown) => {
66
+ const id = `step_${++stepCounter}`;
67
+ stepIdsByKey.set(stepKey, id);
68
+ runSteps.set(id, {
69
+ id,
70
+ type: (details as { type: t.RunStep['type'] }).type,
71
+ stepDetails: details as t.RunStep['stepDetails'],
72
+ } as t.RunStep);
73
+ return id;
74
+ }),
75
+ dispatchRunStepDelta: jest.fn(async () => undefined),
76
+ dispatchMessageDelta: jest.fn(async () => undefined),
77
+ dispatchReasoningDelta: jest.fn(async () => undefined),
78
+ ...overrides,
79
+ };
80
+ return graph as unknown as StandardGraph;
81
+ }
82
+
83
+ const replayChunk = (): AIMessageChunk =>
84
+ new AIMessageChunk({
85
+ content: 'abc',
86
+ additional_kwargs: { reasoning_details: [{ type: 'reasoning.text' }] },
87
+ });
88
+
89
+ function createReplayModel(replays: number): t.ChatModel {
90
+ return {
91
+ stream: async function* stream(): AsyncGenerator<AIMessageChunk> {
92
+ yield new AIMessageChunk({ content: 'abc' });
93
+ for (let i = 0; i < replays; i++) {
94
+ yield replayChunk();
95
+ }
96
+ },
97
+ } as unknown as t.ChatModel;
98
+ }
99
+
100
+ describe('attemptInvoke local branch stream limits', () => {
101
+ it('charges skipped OpenRouter replay chunks against the event budget', async () => {
102
+ const context = createContext({
103
+ streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 3 }),
104
+ });
105
+
106
+ let caught: unknown;
107
+ try {
108
+ await attemptInvoke(
109
+ {
110
+ model: createReplayModel(10),
111
+ messages: [new HumanMessage('hi')],
112
+ provider: Providers.OPENROUTER,
113
+ context: context as unknown as Parameters<
114
+ typeof attemptInvoke
115
+ >[0]['context'],
116
+ },
117
+ { metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
118
+ );
119
+ } catch (error) {
120
+ caught = error;
121
+ }
122
+ expect(caught).toBeInstanceOf(StreamLimitExceededError);
123
+ expect((caught as StreamLimitExceededError).kind).toBe('delta_events');
124
+ });
125
+
126
+ it('leaves replay streams unbounded when the cap is disabled', async () => {
127
+ const context = createContext({ streamLimits: resolveStreamLimits() });
128
+
129
+ const result = await attemptInvoke(
130
+ {
131
+ model: createReplayModel(10),
132
+ messages: [new HumanMessage('hi')],
133
+ provider: Providers.OPENROUTER,
134
+ context: context as unknown as Parameters<
135
+ typeof attemptInvoke
136
+ >[0]['context'],
137
+ },
138
+ { metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
139
+ );
140
+ expect(result.messages).toHaveLength(1);
141
+ });
142
+ });
@@ -19,6 +19,7 @@ import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/messag
19
19
  import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
20
20
  import { convertMessageContentToParts } from '@/llm/google/utils/common';
21
21
  import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
22
+ import { StreamLimitExceededError } from '@/llm/streamLimits';
22
23
  import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
23
24
  import { toLangChainContent } from '@/messages/langchain';
24
25
  import { Constants, Providers } from '@/common';
@@ -556,7 +557,7 @@ describe('attemptInvoke applies lazy ref annotation', () => {
556
557
  responses.model.defaultOptions = { tools: [] };
557
558
  responses.model._useResponsesApi = (options) =>
558
559
  Array.isArray((options as { tools?: unknown[] } | undefined)?.tools) &&
559
- ((options as { tools: unknown[] }).tools.length ?? 0) > 0;
560
+ (options as { tools: unknown[] }).tools.length > 0;
560
561
 
561
562
  await attemptInvoke(
562
563
  {
@@ -829,6 +830,93 @@ describe('tryFallbackProviders applies the same lazy annotation transform', () =
829
830
  jest.resetModules();
830
831
  });
831
832
 
833
+ it('stops draining a cancellation-ignoring stream once the breaker trips', async () => {
834
+ const abort = new AbortController();
835
+ /** Statically imported class: neighboring tests reset the module
836
+ * registry, and a dynamically imported instance would fail the
837
+ * statically-linked drain loop's instanceof check. */
838
+ const trip = new StreamLimitExceededError({
839
+ kind: 'tool_call_args',
840
+ limit: 10,
841
+ observed: 11,
842
+ toolName: 'db_query',
843
+ });
844
+ let yields = 0;
845
+ const model: StubModel = {
846
+ stream: jest.fn(async function* (): AsyncGenerator<AIMessageChunk> {
847
+ for (let i = 0; i < 5; i++) {
848
+ yields += 1;
849
+ yield new AIMessageChunk({ content: `c${i}` });
850
+ }
851
+ }),
852
+ };
853
+
854
+ /** The stub ignores the abort signal entirely; only the per-chunk
855
+ * breaker check in the drain loop can stop it. */
856
+ await expect(
857
+ attemptInvoke(
858
+ {
859
+ model: model as t.ChatModel,
860
+ messages: [new HumanMessage('hi')],
861
+ provider: Providers.ANTHROPIC,
862
+ onChunk: () => {
863
+ abort.abort(trip);
864
+ },
865
+ },
866
+ { signal: abort.signal }
867
+ )
868
+ ).rejects.toBe(trip);
869
+ expect(yields).toBeLessThanOrEqual(2);
870
+ });
871
+
872
+ it('rejects before invoking a fallback when the breaker tripped during preparation', async () => {
873
+ const abort = new AbortController();
874
+ const { invokeMessages, model } = buildCapturingModel();
875
+ await jest.isolateModulesAsync(async () => {
876
+ jest.doMock('@/llm/init', () => ({
877
+ initializeModel: (): unknown => model,
878
+ }));
879
+ const { tryFallbackProviders: freshTry } = (await import(
880
+ '@/llm/invoke'
881
+ )) as { tryFallbackProviders: typeof tryFallbackProviders };
882
+ const { StreamLimitExceededError: FreshStreamLimitError } = (await import(
883
+ '@/llm/streamLimits'
884
+ )) as typeof import('@/llm/streamLimits');
885
+ const trip = new FreshStreamLimitError({
886
+ kind: 'tool_call_args',
887
+ limit: 10,
888
+ observed: 11,
889
+ toolName: 'db_query',
890
+ });
891
+
892
+ /** The sibling's trip lands while fallback preparation is awaited; the
893
+ * fallback must not be invoked at all — a provider that ignores the
894
+ * aborted signal and SUCCEEDS would resolve a run that must reject. */
895
+ await expect(
896
+ freshTry({
897
+ fallbacks: [{ provider: Providers.ANTHROPIC }],
898
+ messages: [
899
+ new ToolMessage({
900
+ name: 'echo',
901
+ tool_call_id: 'tc1',
902
+ status: 'success',
903
+ content: 'output',
904
+ }),
905
+ ],
906
+ primaryError: new Error('primary failed'),
907
+ config: { signal: abort.signal },
908
+ prepareProviderMessages: async ({ messages: preparedMessages }) => {
909
+ abort.abort(trip);
910
+ return preparedMessages;
911
+ },
912
+ })
913
+ ).rejects.toBe(trip);
914
+ });
915
+ expect(invokeMessages).toHaveLength(0);
916
+
917
+ jest.dontMock('@/llm/init');
918
+ });
919
+
832
920
  it('neutralizes preempted Responses history before an OpenAI Chat fallback', async () => {
833
921
  const reasoning = {
834
922
  id: 'rs_fallback',