@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
@@ -6,14 +6,21 @@
6
6
  * the dispatch-synchronous loop in `attemptInvoke` (no registered
7
7
  * CHAT_MODEL_STREAM handler), which is the only loop allowed to seal.
8
8
  */
9
- import { HumanMessage } from '@langchain/core/messages';
10
- import type { BaseMessage } from '@langchain/core/messages';
11
9
  import { RunnableBinding } from '@langchain/core/runnables';
10
+ import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
11
+ import {
12
+ type OpenAIClient,
13
+ convertMessagesToResponsesInput,
14
+ convertResponsesDeltaToChatGenerationChunk,
15
+ } from '@langchain/openai';
16
+ import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';
17
+ import type { BaseMessage } from '@langchain/core/messages';
18
+ import type { HookCallback } from '@/hooks/types';
12
19
  import type * as t from '@/types';
13
- import { Providers } from '@/common';
14
20
  import { HookRegistry } from '@/hooks/HookRegistry';
15
- import type { HookCallback } from '@/hooks/types';
16
21
  import { FakeChatModel } from '@/llm/fake';
22
+ import { ChatOpenAI } from '@/llm/openai';
23
+ import { Providers } from '@/common';
17
24
  import { Run } from '@/run';
18
25
 
19
26
  const FULL_RESPONSE = 'Alpha beta gamma delta epsilon zeta';
@@ -89,6 +96,133 @@ class CountingChatModel extends FakeChatModel {
89
96
  }
90
97
  }
91
98
 
99
+ class ResponsesReasoningChatModel extends FakeChatModel {
100
+ invocations: BaseMessage[][] = [];
101
+ includeServerToolResult = false;
102
+
103
+ _useResponsesApi(): boolean {
104
+ return true;
105
+ }
106
+
107
+ override async *_streamResponseChunks(
108
+ ...args: Parameters<FakeChatModel['_streamResponseChunks']>
109
+ ): ReturnType<FakeChatModel['_streamResponseChunks']> {
110
+ const [messages] = args;
111
+ this.invocations.push(messages);
112
+ if (this.invocations.length !== 1) {
113
+ yield* super._streamResponseChunks(...args);
114
+ return;
115
+ }
116
+
117
+ const outputOffset = this.includeServerToolResult ? 1 : 0;
118
+ const events: Parameters<
119
+ typeof convertResponsesDeltaToChatGenerationChunk
120
+ >[0][] = [
121
+ {
122
+ type: 'response.created',
123
+ sequence_number: 0,
124
+ response: {
125
+ id: 'resp_interrupted',
126
+ created_at: 0,
127
+ output_text: '',
128
+ error: null,
129
+ incomplete_details: null,
130
+ instructions: null,
131
+ metadata: null,
132
+ model: 'gpt-5.6',
133
+ object: 'response',
134
+ output: [],
135
+ parallel_tool_calls: true,
136
+ temperature: null,
137
+ tool_choice: 'auto',
138
+ tools: [],
139
+ top_p: null,
140
+ status: 'in_progress',
141
+ },
142
+ },
143
+ ...(this.includeServerToolResult
144
+ ? [
145
+ {
146
+ type: 'response.output_item.done' as const,
147
+ sequence_number: 1,
148
+ output_index: 0,
149
+ item: {
150
+ id: 'ci_interrupted',
151
+ type: 'code_interpreter_call' as const,
152
+ status: 'completed' as const,
153
+ code: 'print("server result")',
154
+ container_id: 'container_interrupted',
155
+ outputs: [
156
+ { type: 'logs' as const, logs: 'server result' },
157
+ {
158
+ type: 'image' as const,
159
+ url: 'https://example.com/ephemeral-chart.png',
160
+ },
161
+ ],
162
+ },
163
+ },
164
+ ]
165
+ : []),
166
+ {
167
+ type: 'response.output_item.added',
168
+ sequence_number: 1 + outputOffset,
169
+ output_index: outputOffset,
170
+ item: {
171
+ id: 'rs_interrupted',
172
+ type: 'reasoning',
173
+ status: 'in_progress',
174
+ summary: [],
175
+ },
176
+ },
177
+ {
178
+ type: 'response.output_item.done',
179
+ sequence_number: 2 + outputOffset,
180
+ output_index: outputOffset,
181
+ item: {
182
+ id: 'rs_interrupted',
183
+ type: 'reasoning',
184
+ status: 'completed',
185
+ summary: [],
186
+ encrypted_content: 'encrypted-reasoning',
187
+ },
188
+ },
189
+ {
190
+ type: 'response.output_item.added',
191
+ sequence_number: 3 + outputOffset,
192
+ output_index: 1 + outputOffset,
193
+ item: {
194
+ id: 'msg_interrupted',
195
+ type: 'message',
196
+ role: 'assistant',
197
+ status: 'in_progress',
198
+ content: [],
199
+ },
200
+ },
201
+ {
202
+ type: 'response.output_text.delta',
203
+ sequence_number: 4 + outputOffset,
204
+ output_index: 1 + outputOffset,
205
+ content_index: 0,
206
+ item_id: 'msg_interrupted',
207
+ delta: 'Partial answer.',
208
+ logprobs: [],
209
+ },
210
+ ];
211
+ for (const event of events) {
212
+ const chunk = convertResponsesDeltaToChatGenerationChunk(event);
213
+ if (chunk != null) {
214
+ yield chunk;
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ type StreamingResponsesDelegate = {
221
+ completionWithRetry: (
222
+ request: OpenAIClient.Responses.ResponseCreateParamsStreaming
223
+ ) => Promise<AsyncIterable<OpenAIClient.Responses.ResponseStreamEvent>>;
224
+ };
225
+
92
226
  const aiContents = (messages: BaseMessage[]): string[] =>
93
227
  messages
94
228
  .filter((message) => message.getType() === 'ai')
@@ -204,6 +338,43 @@ describe('cooperative seal (end-to-end via Run)', () => {
204
338
  expect(ends).toBe(2);
205
339
  });
206
340
 
341
+ it('preserves the preempted marker through constructor-kwargs rehydration', async () => {
342
+ let sealedChunk: AIMessageChunk | undefined;
343
+ const run = await createSealRun({
344
+ runId: 'seal-serialized-preempted-marker',
345
+ hook: async () => ({ preventContinuation: true }),
346
+ responses: [FULL_RESPONSE],
347
+ modelCallbacks: [
348
+ {
349
+ handleLLMEnd(output: LLMResult): void {
350
+ const generation = output.generations[0]?.[0] as
351
+ | ChatGeneration
352
+ | undefined;
353
+ const message = generation?.message;
354
+ if (
355
+ AIMessageChunk.isInstance(message) &&
356
+ message.response_metadata.preempted === true
357
+ ) {
358
+ sealedChunk = message;
359
+ }
360
+ },
361
+ },
362
+ ],
363
+ });
364
+
365
+ await run.processStream(
366
+ { messages: [new HumanMessage('hello there')] },
367
+ streamConfig
368
+ );
369
+
370
+ expect(sealedChunk).toBeDefined();
371
+ const serializedFields = JSON.parse(
372
+ JSON.stringify(sealedChunk!.lc_kwargs)
373
+ ) as ConstructorParameters<typeof AIMessageChunk>[0];
374
+ const rehydrated = new AIMessageChunk(serializedFields);
375
+ expect(rehydrated.response_metadata.preempted).toBe(true);
376
+ });
377
+
207
378
  it('a halting boundary stops multi-agent successors, not just the sealed subgraph', async () => {
208
379
  const registry = new HookRegistry();
209
380
  registry.register('PreemptBoundary', {
@@ -272,7 +443,11 @@ describe('cooperative seal (end-to-end via Run)', () => {
272
443
  runId: 'seal-inject-resume',
273
444
  hook: async () => ({
274
445
  injectedMessages: [
275
- { role: 'user' as const, content: 'Make it shorter.', source: 'steer' },
446
+ {
447
+ role: 'user' as const,
448
+ content: 'Make it shorter.',
449
+ source: 'steer',
450
+ },
276
451
  ],
277
452
  }),
278
453
  responses: [FULL_RESPONSE, RESUMED_RESPONSE],
@@ -306,4 +481,198 @@ describe('cooperative seal (end-to-end via Run)', () => {
306
481
  expect(contents[0].length).toBeLessThan(FULL_RESPONSE.length);
307
482
  expect(contents[1]).toBe(RESUMED_RESPONSE);
308
483
  });
484
+
485
+ it.each(['v0', 'v1'] as const)(
486
+ 'does not replay interrupted OpenAI Responses item ids on %s resume',
487
+ async (outputVersion) => {
488
+ const run = await createSealRun({
489
+ runId: `seal-openai-responses-reasoning-${outputVersion}`,
490
+ hook: async () => ({
491
+ injectedMessages: [
492
+ { role: 'user' as const, content: 'Go on.', source: 'steer' },
493
+ ],
494
+ }),
495
+ responses: [],
496
+ });
497
+ const model = new ResponsesReasoningChatModel({
498
+ responses: [RESUMED_RESPONSE],
499
+ });
500
+ model.outputVersion = outputVersion;
501
+ run.Graph!.overrideModel = model;
502
+
503
+ await run.processStream(
504
+ { messages: [new HumanMessage('hello there')] },
505
+ streamConfig
506
+ );
507
+
508
+ expect(model.invocations).toHaveLength(2);
509
+ const sealedMessage = model.invocations[1].find(
510
+ (message) => message.getType() === 'ai'
511
+ );
512
+ expect(sealedMessage).toBeDefined();
513
+ expect(sealedMessage?.text).toBe('Partial answer.');
514
+ expect(sealedMessage?.response_metadata).not.toHaveProperty('id');
515
+ const actualOutputVersion = (
516
+ sealedMessage?.response_metadata as { output_version?: unknown }
517
+ ).output_version;
518
+ expect(actualOutputVersion).toBe(
519
+ outputVersion === 'v1' ? 'v1' : undefined
520
+ );
521
+
522
+ const providerInput = convertMessagesToResponsesInput({
523
+ messages: model.invocations[1],
524
+ model: 'gpt-5.6',
525
+ zdrEnabled: false,
526
+ });
527
+ const unsafeReasoning = providerInput.find(
528
+ (item) =>
529
+ item.type === 'reasoning' &&
530
+ item.id === 'rs_interrupted' &&
531
+ (typeof item.encrypted_content !== 'string' ||
532
+ item.encrypted_content.length === 0)
533
+ );
534
+ expect(unsafeReasoning).toBeUndefined();
535
+ expect(JSON.stringify(providerInput)).toContain('Partial answer.');
536
+ }
537
+ );
538
+
539
+ it.each(['v0', 'v1'] as const)(
540
+ 'preserves completed Responses server results on %s resume without ids',
541
+ async (outputVersion) => {
542
+ const run = await createSealRun({
543
+ runId: `seal-openai-responses-server-result-${outputVersion}`,
544
+ hook: async () => ({
545
+ injectedMessages: [
546
+ { role: 'user' as const, content: 'Go on.', source: 'steer' },
547
+ ],
548
+ }),
549
+ responses: [],
550
+ });
551
+ const model = new ResponsesReasoningChatModel({
552
+ responses: [RESUMED_RESPONSE],
553
+ });
554
+ model.outputVersion = outputVersion;
555
+ model.includeServerToolResult = true;
556
+ run.Graph!.overrideModel = model;
557
+
558
+ await run.processStream(
559
+ { messages: [new HumanMessage('hello there')] },
560
+ streamConfig
561
+ );
562
+
563
+ expect(model.invocations).toHaveLength(2);
564
+ const sealedMessage = model.invocations[1].find(
565
+ (message) => message.getType() === 'ai'
566
+ );
567
+ expect(sealedMessage).toBeDefined();
568
+ expect(sealedMessage?.text).toContain('Partial answer.');
569
+ expect(sealedMessage?.text).toContain('server result');
570
+ expect(sealedMessage?.text).toContain('ephemeral-chart.png');
571
+ expect(
572
+ (sealedMessage?.response_metadata as { output_version?: unknown })
573
+ .output_version
574
+ ).toBe('v1');
575
+
576
+ const providerInput = convertMessagesToResponsesInput({
577
+ messages: model.invocations[1],
578
+ model: 'gpt-5.6',
579
+ zdrEnabled: false,
580
+ });
581
+ const serializedProviderInput = JSON.stringify(providerInput);
582
+ expect(serializedProviderInput).toContain('server result');
583
+ expect(serializedProviderInput).toContain('ephemeral-chart.png');
584
+ expect(serializedProviderInput).not.toContain('ci_interrupted');
585
+ expect(serializedProviderInput).not.toContain('code_interpreter_call');
586
+ expect(serializedProviderInput).not.toContain('function_call_output');
587
+ }
588
+ );
589
+
590
+ it('preserves dropped raw Responses results through a real model resume', async () => {
591
+ const run = await createSealRun({
592
+ runId: 'seal-openai-responses-raw-result',
593
+ hook: async () => ({
594
+ injectedMessages: [
595
+ { role: 'user' as const, content: 'Go on.', source: 'steer' },
596
+ ],
597
+ }),
598
+ responses: [],
599
+ });
600
+ const model = new ChatOpenAI({
601
+ model: 'gpt-5.6',
602
+ apiKey: 'test-key',
603
+ useResponsesApi: true,
604
+ });
605
+ const responses = (
606
+ model as unknown as { responses: StreamingResponsesDelegate }
607
+ ).responses;
608
+ const requests: OpenAIClient.Responses.ResponseCreateParamsStreaming[] = [];
609
+ responses.completionWithRetry = async (request) => {
610
+ requests.push(request);
611
+ const invocation = requests.length;
612
+ return (async function* () {
613
+ if (invocation === 1) {
614
+ yield {
615
+ type: 'response.output_item.done',
616
+ sequence_number: 0,
617
+ output_index: 0,
618
+ item: {
619
+ id: 'local_output_item',
620
+ type: 'local_shell_call_output',
621
+ status: 'completed',
622
+ output: 'local shell result',
623
+ },
624
+ } as OpenAIClient.Responses.ResponseStreamEvent;
625
+ yield {
626
+ type: 'response.output_item.added',
627
+ sequence_number: 1,
628
+ output_index: 1,
629
+ item: {
630
+ id: 'rs_interrupted',
631
+ type: 'reasoning',
632
+ status: 'in_progress',
633
+ summary: [],
634
+ },
635
+ } as OpenAIClient.Responses.ResponseStreamEvent;
636
+ yield {
637
+ type: 'response.output_text.delta',
638
+ sequence_number: 2,
639
+ output_index: 2,
640
+ content_index: 0,
641
+ item_id: 'msg_interrupted',
642
+ delta: 'Partial answer.',
643
+ logprobs: [],
644
+ } as OpenAIClient.Responses.ResponseStreamEvent;
645
+ return;
646
+ }
647
+ yield {
648
+ type: 'response.output_text.delta',
649
+ sequence_number: 0,
650
+ output_index: 0,
651
+ content_index: 0,
652
+ item_id: 'msg_resumed',
653
+ delta: RESUMED_RESPONSE,
654
+ logprobs: [],
655
+ } as OpenAIClient.Responses.ResponseStreamEvent;
656
+ })();
657
+ };
658
+ run.Graph!.overrideModel = model;
659
+
660
+ await run.processStream(
661
+ { messages: [new HumanMessage('hello there')] },
662
+ streamConfig
663
+ );
664
+
665
+ expect(requests).toHaveLength(2);
666
+ const resumedInput = JSON.stringify(requests[1].input);
667
+ expect(resumedInput).toContain('Partial answer.');
668
+ expect(resumedInput).toContain('local shell result');
669
+ expect(resumedInput).toContain('serverToolResult');
670
+ expect(resumedInput.indexOf('local shell result')).toBeLessThan(
671
+ resumedInput.indexOf('Partial answer.')
672
+ );
673
+ expect(resumedInput).not.toContain('local_output_item');
674
+ expect(resumedInput).not.toContain('local_shell_call_output');
675
+ expect(resumedInput).not.toContain('rs_interrupted');
676
+ expect(run.getHaltReason()).toBeUndefined();
677
+ });
309
678
  });
package/src/stream.ts CHANGED
@@ -39,9 +39,21 @@ import {
39
39
  calculateMaxToolResultChars,
40
40
  truncateToolResultContent,
41
41
  } from '@/utils/truncation';
42
+ import type { RunBreakerScope } from '@/llm/streamLimits';
43
+ import {
44
+ claimStreamLimitCharge,
45
+ combineCompleteToolCalls,
46
+ enforceCompleteToolCallArgLimit,
47
+ enforceStreamedToolCallArgLimit,
48
+ enforceStreamDeltaEventLimit,
49
+ requiresStreamLimitAccounting,
50
+ StreamLimitExceededError,
51
+ STREAM_LIMIT_EPOCH_KEY,
52
+ } from '@/llm/streamLimits';
42
53
  import { resolveToolOutcome, outcomeFieldsFromResult } from '@/tools/intentArg';
43
54
  import { TOOL_OUTPUT_REF_PATTERN } from '@/tools/toolOutputReferences';
44
55
  import { safeDispatchCustomEvent } from '@/utils/events';
56
+ import { composeAbortSignals } from '@/utils/misc';
45
57
  import { isGoogleLike } from '@/utils/llm';
46
58
  import { getMessageId } from '@/messages';
47
59
 
@@ -166,7 +178,10 @@ function isEagerExecutionExcludedTool(
166
178
  // the final request ("changed after eager execution started"), stop
167
179
  // prestarting it so the model's retry executes normally instead of
168
180
  // re-diverging in a loop (LibreChat#14371).
169
- if (graph.eagerEventToolSuppressions?.has(name) === true) {
181
+ if (
182
+ (graph.eagerEventToolSuppressions as Set<string> | undefined)?.has(name) ===
183
+ true
184
+ ) {
170
185
  return true;
171
186
  }
172
187
  // A code-session participant writes to the shared sandbox, so it is
@@ -778,6 +793,10 @@ function startEagerToolExecutions(args: {
778
793
  | Record<string, unknown>
779
794
  | undefined,
780
795
  metadata,
796
+ signal: composeAbortSignals(
797
+ graph.config?.signal,
798
+ graph.breakerAbort.signal
799
+ ),
781
800
  resolve: (results): void => {
782
801
  resultSettled = true;
783
802
  settledResults = results;
@@ -1069,9 +1088,8 @@ function recordEagerToolCallChunks(args: {
1069
1088
  const sealCoversChunk =
1070
1089
  seal != null &&
1071
1090
  (seal.kind === 'all' ||
1072
- (seal.kind === 'single' &&
1073
- ((seal.id != null && seal.id === id) ||
1074
- (seal.index != null && seal.index === index))));
1091
+ (seal.id != null && seal.id === id) ||
1092
+ (seal.index != null && seal.index === index));
1075
1093
  const next = {
1076
1094
  id,
1077
1095
  name,
@@ -1512,10 +1530,119 @@ export class ChatModelStreamHandler implements t.EventHandler {
1512
1530
  return;
1513
1531
  }
1514
1532
 
1515
- const agentContext = graph.getAgentContext(metadata);
1516
-
1517
1533
  const chunk = data.chunk as Partial<AIMessageChunk>;
1518
1534
 
1535
+ /** Attempts stamp their breaker epoch into event metadata; a mismatch
1536
+ * marks a straggling chunk from a failed run that outlived
1537
+ * `resetValues()`. Dropped OUTRIGHT: content handling and the eager
1538
+ * paths below compose the LIVE controller, so acting on a dead run's
1539
+ * chunk could dispatch host tools into the run now using it. Events
1540
+ * without a stamp (direct handler callers, partial stubs) keep the
1541
+ * live-controller behavior. */
1542
+ const eventEpoch = metadata?.[STREAM_LIMIT_EPOCH_KEY];
1543
+ /** Runtime-honest widening: partial handler stubs carry neither an
1544
+ * epoch nor a run scope despite the field types. */
1545
+ const liveEpoch = graph.breakerEpoch as number | undefined;
1546
+ if (eventEpoch != null && liveEpoch != null && eventEpoch !== liveEpoch) {
1547
+ return;
1548
+ }
1549
+ const eventBreaker =
1550
+ graph.breakerAbort instanceof AbortController
1551
+ ? graph.breakerAbort
1552
+ : undefined;
1553
+ /** Immutable scope captured at handler entry. A reset while this
1554
+ * handler is suspended in an await replaces the object, so ONE
1555
+ * reference comparison proves the event still belongs to the live run
1556
+ * before anything composes `graph.breakerAbort` or `graph.config`. */
1557
+ const entryRunScope = graph.runScope as RunBreakerScope | undefined;
1558
+ const runScopeInvalidated = (): boolean =>
1559
+ entryRunScope != null && graph.runScope !== entryRunScope;
1560
+ const throwIfRunBreakerTripped = (): void => {
1561
+ if (
1562
+ eventBreaker != null &&
1563
+ eventBreaker.signal.aborted &&
1564
+ eventBreaker.signal.reason instanceof StreamLimitExceededError
1565
+ ) {
1566
+ throw eventBreaker.signal.reason;
1567
+ }
1568
+ };
1569
+
1570
+ /**
1571
+ * Enforced before every content-specific early return below
1572
+ * (server-tool results, deferred mixed reasoning, late OpenRouter
1573
+ * reasoning): a looping provider can flood through any of those paths,
1574
+ * a coalesced event can carry client `tool_call_chunks` alongside a
1575
+ * server-tool result, and the complete-call dispatch branch further
1576
+ * down can prestart a side-effecting tool from an arrival-sealed
1577
+ * oversized call. Charging is claim-based: the producer loop and this
1578
+ * decoupled echo can observe the same chunk object in either order, and
1579
+ * only the first claimer charges it. The argument guard is
1580
+ * deliberately NOT gated on numeric chunk indices, so id-only or
1581
+ * index-less runaway streams stay bounded, and complete parsed
1582
+ * `tool_calls` without a raw chunk representation are judged standalone.
1583
+ */
1584
+ if (
1585
+ requiresStreamLimitAccounting(graph, chunk) &&
1586
+ claimStreamLimitCharge(graph, data.chunk, 'consumer', metadata)
1587
+ ) {
1588
+ try {
1589
+ enforceStreamDeltaEventLimit({ graph, metadata });
1590
+ /** Combined first so raw-chunk name correlation sees invalid calls
1591
+ * too; an unnamed raw chunk twinned with a named invalid call must
1592
+ * select that tool's override, not the global cap. */
1593
+ const completeCalls = combineCompleteToolCalls(chunk);
1594
+ if (chunk.tool_call_chunks && chunk.tool_call_chunks.length > 0) {
1595
+ enforceStreamedToolCallArgLimit({
1596
+ graph,
1597
+ metadata,
1598
+ toolCallChunks: chunk.tool_call_chunks,
1599
+ responseMetadata: chunk.response_metadata as
1600
+ | Record<string, unknown>
1601
+ | undefined,
1602
+ parsedToolCalls: completeCalls,
1603
+ });
1604
+ }
1605
+ /** Judged whenever parsed calls are present, not only when raw
1606
+ * chunks are absent — an adapter can pair an empty or partial raw
1607
+ * chunk with a complete parsed call; the standalone check is
1608
+ * stateless, so the common both-present case is not double-tallied.
1609
+ * Invalid calls are included because ToolNode processes and
1610
+ * promotes them. */
1611
+ if (completeCalls != null) {
1612
+ enforceCompleteToolCallArgLimit({
1613
+ graph,
1614
+ metadata,
1615
+ toolCalls: completeCalls,
1616
+ });
1617
+ }
1618
+ } catch (error) {
1619
+ /** A breach detected on this consumer path must still stop parallel
1620
+ * fan-out work: the producer skips its own enforcement once this
1621
+ * side has claimed the emission, so createCallModel's breaker-abort
1622
+ * never fires for it. Trip the EVENT's run-bound breaker before the
1623
+ * throw rejects the run — never the live controller of a newer run. */
1624
+ if (error instanceof StreamLimitExceededError && eventBreaker != null) {
1625
+ eventBreaker.abort(error);
1626
+ }
1627
+ throw error;
1628
+ }
1629
+ }
1630
+
1631
+ /** A parallel producer can trip the shared breaker while this event was
1632
+ * already queued in `streamEvents`. Stop before content handling or the
1633
+ * eager-tool paths below — those can dispatch a side-effecting host
1634
+ * tool with an already-aborted signal the handler never inspects.
1635
+ * Rechecked again immediately before each eager dispatch: the awaits in
1636
+ * between (server-tool results, tool-call handling, content dispatch)
1637
+ * are windows for a sibling's trip — or for a full reset, after which
1638
+ * this event belongs to a dead run and is dropped. */
1639
+ if (runScopeInvalidated()) {
1640
+ return;
1641
+ }
1642
+ throwIfRunBreakerTripped();
1643
+
1644
+ const agentContext = graph.getAgentContext(metadata);
1645
+
1519
1646
  const content = getChunkContent({
1520
1647
  chunk,
1521
1648
  reasoningKey: agentContext.reasoningKey,
@@ -1576,6 +1703,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
1576
1703
  ) {
1577
1704
  hasToolCalls = true;
1578
1705
  await handleToolCalls(chunk.tool_calls, metadata, graph);
1706
+ if (runScopeInvalidated()) {
1707
+ return;
1708
+ }
1709
+ throwIfRunBreakerTripped();
1579
1710
  if (hasFinalToolCallSignal(chunk)) {
1580
1711
  startEagerToolExecutions({
1581
1712
  graph,
@@ -1655,6 +1786,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
1655
1786
  metadata,
1656
1787
  });
1657
1788
  if (canStreamEager) {
1789
+ if (runScopeInvalidated()) {
1790
+ return;
1791
+ }
1792
+ throwIfRunBreakerTripped();
1658
1793
  startReadyStreamedEagerToolExecutions({
1659
1794
  graph,
1660
1795
  metadata,