@librechat/agents 3.3.4 → 3.3.5

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 (159) hide show
  1. package/dist/cjs/common/constants.cjs +21 -0
  2. package/dist/cjs/common/constants.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +10 -1
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +456 -7
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  9. package/dist/cjs/hooks/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/langfuse.cjs +8 -0
  14. package/dist/cjs/langfuse.cjs.map +1 -1
  15. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  17. package/dist/cjs/llm/invoke.cjs +268 -3
  18. package/dist/cjs/llm/invoke.cjs.map +1 -1
  19. package/dist/cjs/llm/preempt.cjs +132 -0
  20. package/dist/cjs/llm/preempt.cjs.map +1 -0
  21. package/dist/cjs/main.cjs +17 -3
  22. package/dist/cjs/messages/alternation.cjs +92 -0
  23. package/dist/cjs/messages/alternation.cjs.map +1 -0
  24. package/dist/cjs/messages/format.cjs +72 -0
  25. package/dist/cjs/messages/format.cjs.map +1 -1
  26. package/dist/cjs/messages/handoffCue.cjs +63 -0
  27. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  28. package/dist/cjs/messages/index.cjs +3 -0
  29. package/dist/cjs/messages/injected.cjs +60 -0
  30. package/dist/cjs/messages/injected.cjs.map +1 -0
  31. package/dist/cjs/run.cjs +80 -7
  32. package/dist/cjs/run.cjs.map +1 -1
  33. package/dist/cjs/session/handlers.cjs +18 -9
  34. package/dist/cjs/session/handlers.cjs.map +1 -1
  35. package/dist/cjs/stream.cjs +28 -7
  36. package/dist/cjs/stream.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +3 -23
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  40. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  41. package/dist/cjs/tools/search/tool.cjs +9 -1
  42. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  43. package/dist/esm/common/constants.mjs +19 -1
  44. package/dist/esm/common/constants.mjs.map +1 -1
  45. package/dist/esm/events.mjs +10 -1
  46. package/dist/esm/events.mjs.map +1 -1
  47. package/dist/esm/graphs/Graph.mjs +455 -6
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  50. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  51. package/dist/esm/hooks/index.mjs +12 -1
  52. package/dist/esm/hooks/index.mjs.map +1 -1
  53. package/dist/esm/hooks/types.mjs +1 -0
  54. package/dist/esm/hooks/types.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +9 -1
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  58. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  59. package/dist/esm/llm/invoke.mjs +270 -6
  60. package/dist/esm/llm/invoke.mjs.map +1 -1
  61. package/dist/esm/llm/preempt.mjs +131 -0
  62. package/dist/esm/llm/preempt.mjs.map +1 -0
  63. package/dist/esm/main.mjs +8 -5
  64. package/dist/esm/messages/alternation.mjs +91 -0
  65. package/dist/esm/messages/alternation.mjs.map +1 -0
  66. package/dist/esm/messages/format.mjs +72 -0
  67. package/dist/esm/messages/format.mjs.map +1 -1
  68. package/dist/esm/messages/handoffCue.mjs +61 -0
  69. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  70. package/dist/esm/messages/index.mjs +3 -0
  71. package/dist/esm/messages/injected.mjs +60 -0
  72. package/dist/esm/messages/injected.mjs.map +1 -0
  73. package/dist/esm/run.mjs +80 -7
  74. package/dist/esm/run.mjs.map +1 -1
  75. package/dist/esm/session/handlers.mjs +19 -10
  76. package/dist/esm/session/handlers.mjs.map +1 -1
  77. package/dist/esm/stream.mjs +27 -8
  78. package/dist/esm/stream.mjs.map +1 -1
  79. package/dist/esm/tools/ToolNode.mjs +3 -23
  80. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  81. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  82. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  83. package/dist/esm/tools/search/tool.mjs +9 -1
  84. package/dist/esm/tools/search/tool.mjs.map +1 -1
  85. package/dist/types/common/constants.d.ts +18 -0
  86. package/dist/types/graphs/Graph.d.ts +138 -1
  87. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  88. package/dist/types/hooks/index.d.ts +12 -1
  89. package/dist/types/hooks/types.d.ts +45 -6
  90. package/dist/types/index.d.ts +1 -1
  91. package/dist/types/llm/invoke.d.ts +7 -0
  92. package/dist/types/llm/preempt.d.ts +40 -0
  93. package/dist/types/messages/alternation.d.ts +23 -0
  94. package/dist/types/messages/handoffCue.d.ts +40 -0
  95. package/dist/types/messages/index.d.ts +3 -0
  96. package/dist/types/messages/injected.d.ts +3 -0
  97. package/dist/types/run.d.ts +7 -0
  98. package/dist/types/stream.d.ts +14 -0
  99. package/dist/types/tools/ToolNode.d.ts +0 -7
  100. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  101. package/dist/types/tools/search/types.d.ts +31 -2
  102. package/dist/types/types/graph.d.ts +8 -1
  103. package/dist/types/types/run.d.ts +65 -0
  104. package/dist/types/types/stream.d.ts +0 -25
  105. package/package.json +1 -1
  106. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  107. package/src/common/constants.ts +21 -0
  108. package/src/events.ts +15 -1
  109. package/src/graphs/Graph.ts +568 -3
  110. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  111. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  112. package/src/hooks/HookRegistry.ts +40 -0
  113. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  114. package/src/hooks/index.ts +16 -2
  115. package/src/hooks/types.ts +47 -3
  116. package/src/index.ts +1 -1
  117. package/src/langfuse.ts +26 -1
  118. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  119. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  120. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  121. package/src/llm/invoke.alternation.test.ts +87 -0
  122. package/src/llm/invoke.handoffCue.test.ts +112 -0
  123. package/src/llm/invoke.ts +417 -8
  124. package/src/llm/preempt.test.ts +323 -0
  125. package/src/llm/preempt.ts +178 -0
  126. package/src/messages/alternation.test.ts +212 -0
  127. package/src/messages/alternation.ts +112 -0
  128. package/src/messages/format.ts +91 -0
  129. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  130. package/src/messages/handoffCue.test.ts +96 -0
  131. package/src/messages/handoffCue.ts +78 -0
  132. package/src/messages/index.ts +3 -0
  133. package/src/messages/injected.test.ts +90 -0
  134. package/src/messages/injected.ts +74 -0
  135. package/src/run.ts +91 -6
  136. package/src/scripts/preempt-probe.ts +330 -0
  137. package/src/scripts/preempt-scenarios.ts +388 -0
  138. package/src/session/handlers.ts +32 -12
  139. package/src/specs/handoffCue.test.ts +165 -0
  140. package/src/specs/langfuse-callbacks.test.ts +352 -2
  141. package/src/specs/preemptSeal.test.ts +309 -0
  142. package/src/stream.dispatch.test.ts +63 -0
  143. package/src/stream.ts +36 -8
  144. package/src/tools/ToolNode.ts +3 -31
  145. package/src/tools/search/keenable-scraper.test.ts +153 -0
  146. package/src/tools/search/keenable-scraper.ts +137 -0
  147. package/src/tools/search/tool.ts +13 -2
  148. package/src/tools/search/types.ts +50 -3
  149. package/src/types/graph.ts +12 -1
  150. package/src/types/run.ts +67 -0
  151. package/src/types/stream.ts +0 -41
  152. package/dist/cjs/splitStream.cjs +0 -151
  153. package/dist/cjs/splitStream.cjs.map +0 -1
  154. package/dist/esm/splitStream.mjs +0 -150
  155. package/dist/esm/splitStream.mjs.map +0 -1
  156. package/dist/types/mockStream.d.ts +0 -32
  157. package/dist/types/splitStream.d.ts +0 -37
  158. package/src/mockStream.ts +0 -99
  159. package/src/splitStream.ts +0 -234
@@ -0,0 +1,309 @@
1
+ // src/specs/preemptSeal.test.ts
2
+ /**
3
+ * End-to-end cooperative seal flow through a real `Run`: fake model streams,
4
+ * the host requests a preempt, the stream seals at a safe chunk, the
5
+ * `PreemptBoundary` drain decides what happens next. Everything below runs
6
+ * the dispatch-synchronous loop in `attemptInvoke` (no registered
7
+ * CHAT_MODEL_STREAM handler), which is the only loop allowed to seal.
8
+ */
9
+ import { HumanMessage } from '@langchain/core/messages';
10
+ import type { BaseMessage } from '@langchain/core/messages';
11
+ import { RunnableBinding } from '@langchain/core/runnables';
12
+ import type * as t from '@/types';
13
+ import { Providers } from '@/common';
14
+ import { HookRegistry } from '@/hooks/HookRegistry';
15
+ import type { HookCallback } from '@/hooks/types';
16
+ import { FakeChatModel } from '@/llm/fake';
17
+ import { Run } from '@/run';
18
+
19
+ const FULL_RESPONSE = 'Alpha beta gamma delta epsilon zeta';
20
+ const RESUMED_RESPONSE = 'Continuing after the steer.';
21
+
22
+ const streamConfig = {
23
+ configurable: { thread_id: 'preempt-seal-e2e' },
24
+ streamMode: 'values' as const,
25
+ version: 'v2' as const,
26
+ };
27
+
28
+ async function createSealRun(options: {
29
+ runId: string;
30
+ hook: HookCallback<'PreemptBoundary'>;
31
+ responses: string[];
32
+ stopHook?: HookCallback<'Stop'>;
33
+ modelCallbacks?: FakeChatModel['callbacks'];
34
+ }): Promise<Run<t.IState>> {
35
+ const registry = new HookRegistry();
36
+ registry.register('PreemptBoundary', { hooks: [options.hook] });
37
+ if (options.stopHook) {
38
+ registry.register('Stop', { hooks: [options.stopHook] });
39
+ }
40
+ const run = await Run.create<t.IState>({
41
+ runId: options.runId,
42
+ graphConfig: {
43
+ type: 'standard',
44
+ llmConfig: {
45
+ provider: Providers.OPENAI,
46
+ model: 'gpt-4o-mini',
47
+ apiKey: 'test-key',
48
+ },
49
+ instructions: 'Answer plainly.',
50
+ },
51
+ hooks: registry,
52
+ preemption: { shouldPreempt: () => true, maxSeals: 1 },
53
+ returnContent: true,
54
+ skipCleanup: true,
55
+ });
56
+ if (!run.Graph) {
57
+ throw new Error('Expected graph to be initialized');
58
+ }
59
+ const model = new FakeChatModel({
60
+ responses: options.responses,
61
+ });
62
+ if (options.modelCallbacks != null) {
63
+ model.callbacks = options.modelCallbacks;
64
+ }
65
+ /**
66
+ * Wrapped, not bare, when the test watches model-level callbacks: with
67
+ * tools bound, production hands `attemptInvoke` a `RunnableBinding` (and a
68
+ * system runnable pipes a sequence on top), while `clientOptions.callbacks`
69
+ * lives on the chat model at the bottom. A bare override would let a
70
+ * naive `model.callbacks` property read pass the detector while missing
71
+ * every real tool-enabled run.
72
+ */
73
+ run.Graph.overrideModel = (
74
+ options.modelCallbacks != null
75
+ ? new RunnableBinding({ bound: model, kwargs: {}, config: {} })
76
+ : model
77
+ ) as typeof model;
78
+ return run;
79
+ }
80
+
81
+ class CountingChatModel extends FakeChatModel {
82
+ invocations = 0;
83
+
84
+ override async *_streamResponseChunks(
85
+ ...args: Parameters<FakeChatModel['_streamResponseChunks']>
86
+ ): ReturnType<FakeChatModel['_streamResponseChunks']> {
87
+ this.invocations += 1;
88
+ yield* super._streamResponseChunks(...args);
89
+ }
90
+ }
91
+
92
+ const aiContents = (messages: BaseMessage[]): string[] =>
93
+ messages
94
+ .filter((message) => message.getType() === 'ai')
95
+ .map((message) =>
96
+ typeof message.content === 'string'
97
+ ? message.content
98
+ : JSON.stringify(message.content)
99
+ );
100
+
101
+ describe('cooperative seal (end-to-end via Run)', () => {
102
+ jest.setTimeout(15000);
103
+
104
+ it('surfaces an empty boundary as preempt_incomplete instead of a natural finish', async () => {
105
+ const run = await createSealRun({
106
+ runId: 'seal-empty-boundary',
107
+ hook: async () => ({}),
108
+ responses: [FULL_RESPONSE],
109
+ });
110
+
111
+ await run.processStream(
112
+ { messages: [new HumanMessage('hello there')] },
113
+ streamConfig
114
+ );
115
+
116
+ /**
117
+ * The answer really was cut short: the host asked to preempt, the seal
118
+ * took the budget, and the drain had nothing to resume with. A terminal
119
+ * consumer reading only completion events would persist a truncated
120
+ * answer as finished — `getHaltReason()` is the channel that prevents
121
+ * that (AgentSession emits `run.halted` off it).
122
+ */
123
+ expect(run.getHaltReason()).toBe('preempt_incomplete');
124
+ expect(run.Graph?.preemptIncomplete).toBe(true);
125
+ expect(run.Graph?.preemptEmptyBoundaries).toBe(1);
126
+
127
+ const contents = aiContents(run.getRunMessages() ?? []);
128
+ expect(contents).toHaveLength(1);
129
+ expect(contents[0].length).toBeGreaterThan(0);
130
+ expect(contents[0].length).toBeLessThan(FULL_RESPONSE.length);
131
+ expect(FULL_RESPONSE.startsWith(contents[0])).toBe(true);
132
+ });
133
+
134
+ it('forwards a halting hook\'s own stopReason to Stop hooks and getHaltReason', async () => {
135
+ let stopReasonSeen: string | undefined;
136
+ const run = await createSealRun({
137
+ runId: 'seal-halt-reason',
138
+ hook: async () => ({
139
+ preventContinuation: true,
140
+ stopReason: 'host_policy_stop',
141
+ }),
142
+ responses: [FULL_RESPONSE],
143
+ stopHook: async (input) => {
144
+ stopReasonSeen = input.stopReason;
145
+ return {};
146
+ },
147
+ });
148
+
149
+ await run.processStream(
150
+ { messages: [new HumanMessage('hello there')] },
151
+ streamConfig
152
+ );
153
+
154
+ /**
155
+ * The hook-supplied reason must win end to end: a persistence/audit Stop
156
+ * hook records the actual cause, not the generic preempt_incomplete
157
+ * label, and getHaltReason() reports the same string afterward. A
158
+ * halting boundary that injected nothing also counts as an empty
159
+ * boundary in the truncated-seal telemetry.
160
+ */
161
+ expect(stopReasonSeen).toBe('host_policy_stop');
162
+ expect(run.getHaltReason()).toBe('host_policy_stop');
163
+ expect(run.Graph?.preemptIncomplete).toBe(true);
164
+ expect(run.Graph?.preemptEmptyBoundaries).toBe(1);
165
+ });
166
+
167
+ it('closes model-level callbacks for the sealed run, not just config-level ones', async () => {
168
+ let starts = 0;
169
+ let ends = 0;
170
+ const run = await createSealRun({
171
+ runId: 'seal-model-callbacks',
172
+ hook: async () => ({
173
+ injectedMessages: [
174
+ { role: 'user' as const, content: 'Shorter.', source: 'steer' },
175
+ ],
176
+ }),
177
+ responses: [FULL_RESPONSE, RESUMED_RESPONSE],
178
+ /**
179
+ * A handler supplied on the MODEL (clientOptions.callbacks) gets
180
+ * handleChatModelStart from the real run, so the sealed turn's
181
+ * synthetic close must reach it too — otherwise its span for the
182
+ * sealed run never closes. Two runs (sealed + resumed): both must
183
+ * balance.
184
+ */
185
+ modelCallbacks: [
186
+ {
187
+ handleChatModelStart: (): void => {
188
+ starts += 1;
189
+ },
190
+ handleLLMEnd: (): void => {
191
+ ends += 1;
192
+ },
193
+ },
194
+ ],
195
+ });
196
+
197
+ await run.processStream(
198
+ { messages: [new HumanMessage('hello there')] },
199
+ streamConfig
200
+ );
201
+
202
+ expect(run.Graph?.preemptSealCount).toBe(1);
203
+ expect(starts).toBe(2);
204
+ expect(ends).toBe(2);
205
+ });
206
+
207
+ it('a halting boundary stops multi-agent successors, not just the sealed subgraph', async () => {
208
+ const registry = new HookRegistry();
209
+ registry.register('PreemptBoundary', {
210
+ hooks: [
211
+ async () => ({
212
+ preventContinuation: true,
213
+ stopReason: 'stop_everything',
214
+ }),
215
+ ],
216
+ });
217
+ const run = await Run.create<t.IState>({
218
+ runId: 'seal-halt-multiagent',
219
+ graphConfig: {
220
+ type: 'multi-agent',
221
+ agents: [
222
+ {
223
+ agentId: 'agent_a',
224
+ provider: Providers.OPENAI,
225
+ clientOptions: { model: 'gpt-4o-mini', apiKey: 'test-key' },
226
+ instructions: 'You are agent A.',
227
+ },
228
+ {
229
+ agentId: 'agent_b',
230
+ provider: Providers.OPENAI,
231
+ clientOptions: { model: 'gpt-4o-mini', apiKey: 'test-key' },
232
+ instructions: 'You are agent B.',
233
+ },
234
+ ],
235
+ edges: [{ from: 'agent_a', to: 'agent_b', edgeType: 'direct' }],
236
+ },
237
+ hooks: registry,
238
+ preemption: { shouldPreempt: () => true, maxSeals: 1 },
239
+ returnContent: true,
240
+ skipCleanup: true,
241
+ });
242
+ if (!run.Graph) {
243
+ throw new Error('Expected graph to be initialized');
244
+ }
245
+ const model = new CountingChatModel({
246
+ responses: [FULL_RESPONSE, 'agent B should never say this'],
247
+ });
248
+ run.Graph.overrideModel = model;
249
+
250
+ await run.processStream(
251
+ { messages: [new HumanMessage('hello there')] },
252
+ streamConfig
253
+ );
254
+
255
+ /**
256
+ * The registry halt is cleared to protect the sealed commit, so nothing
257
+ * in processStream's poll stops the outer workflow — the createCallModel
258
+ * entry guard is what keeps the direct-edge successor from taking a
259
+ * model turn after the halting boundary.
260
+ */
261
+ expect(model.invocations).toBe(1);
262
+ expect(run.getHaltReason()).toBe('stop_everything');
263
+ expect(run.Graph.preemptIncomplete).toBe(true);
264
+ const contents = aiContents(run.getRunMessages() ?? []);
265
+ expect(contents.some((c) => c.includes('agent B should never'))).toBe(
266
+ false
267
+ );
268
+ });
269
+
270
+ it('resumes after an injecting boundary and completes without a halt reason', async () => {
271
+ const run = await createSealRun({
272
+ runId: 'seal-inject-resume',
273
+ hook: async () => ({
274
+ injectedMessages: [
275
+ { role: 'user' as const, content: 'Make it shorter.', source: 'steer' },
276
+ ],
277
+ }),
278
+ responses: [FULL_RESPONSE, RESUMED_RESPONSE],
279
+ });
280
+
281
+ await run.processStream(
282
+ { messages: [new HumanMessage('hello there')] },
283
+ streamConfig
284
+ );
285
+
286
+ expect(run.getHaltReason()).toBeUndefined();
287
+ expect(run.Graph?.preemptIncomplete).toBe(false);
288
+ expect(run.Graph?.preemptSealCount).toBe(1);
289
+
290
+ const messages = run.getRunMessages() ?? [];
291
+ const steer = messages.find(
292
+ (message) => message.additional_kwargs.source === 'steer'
293
+ );
294
+ expect(steer).toBeDefined();
295
+ expect(steer?.content).toBe('Make it shorter.');
296
+
297
+ /**
298
+ * Two assistant turns: the sealed partial and the post-steer
299
+ * continuation, which must have run to completion — the seal budget was
300
+ * spent, so the second stream cannot seal again even though
301
+ * `shouldPreempt` still answers true.
302
+ */
303
+ const contents = aiContents(messages);
304
+ expect(contents).toHaveLength(2);
305
+ expect(FULL_RESPONSE.startsWith(contents[0])).toBe(true);
306
+ expect(contents[0].length).toBeLessThan(FULL_RESPONSE.length);
307
+ expect(contents[1]).toBe(RESUMED_RESPONSE);
308
+ });
309
+ });
@@ -0,0 +1,63 @@
1
+ import type * as t from '@/types';
2
+ import {
3
+ ChatModelStreamHandler,
4
+ dispatchesChatModelStream,
5
+ SDK_STREAM_DISPATCH,
6
+ } from '@/stream';
7
+ import { composeEventHandlers } from '@/events';
8
+ import { GraphEvents } from '@/common';
9
+
10
+ const inert: t.EventHandler = {
11
+ handle: (): void => {
12
+ /* renders nothing */
13
+ },
14
+ };
15
+
16
+ describe('dispatchesChatModelStream', () => {
17
+ it('recognizes the dispatcher itself', () => {
18
+ expect(dispatchesChatModelStream(new ChatModelStreamHandler())).toBe(true);
19
+ });
20
+
21
+ it('is false for a handler that owns no content-part dispatch', () => {
22
+ expect(dispatchesChatModelStream(inert)).toBe(false);
23
+ });
24
+
25
+ it('is false for nothing registered', () => {
26
+ expect(dispatchesChatModelStream(undefined)).toBe(false);
27
+ });
28
+
29
+ /**
30
+ * The reason identity is not a usable test: hosts wrap handlers, and every
31
+ * wrapper fails `instanceof` while still driving the same dispatch. A run
32
+ * that seals on that basis would be sealing against its own contract.
33
+ */
34
+ it('sees through a composed wrapper in either position', () => {
35
+ const first = composeEventHandlers(
36
+ { [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler() },
37
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert }
38
+ )[GraphEvents.CHAT_MODEL_STREAM];
39
+ const second = composeEventHandlers(
40
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert },
41
+ { [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler() }
42
+ )[GraphEvents.CHAT_MODEL_STREAM];
43
+
44
+ expect(first).not.toBeInstanceOf(ChatModelStreamHandler);
45
+ expect(second).not.toBeInstanceOf(ChatModelStreamHandler);
46
+ expect(dispatchesChatModelStream(first)).toBe(true);
47
+ expect(dispatchesChatModelStream(second)).toBe(true);
48
+ });
49
+
50
+ it('does not brand a composition of inert handlers', () => {
51
+ const composed = composeEventHandlers(
52
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert },
53
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert }
54
+ )[GraphEvents.CHAT_MODEL_STREAM];
55
+ expect(dispatchesChatModelStream(composed)).toBe(false);
56
+ });
57
+
58
+ it('exposes the brand as a cross-realm-safe registered symbol', () => {
59
+ expect(SDK_STREAM_DISPATCH).toBe(
60
+ Symbol.for('@librechat/agents:chatModelStreamDispatch')
61
+ );
62
+ });
63
+ });
package/src/stream.ts CHANGED
@@ -1400,7 +1400,34 @@ function shouldSkipLateOpenRouterReasoningChunk({
1400
1400
  );
1401
1401
  }
1402
1402
 
1403
+ /**
1404
+ * Brands a handler as one that dispatches content parts for the SDK — either
1405
+ * `ChatModelStreamHandler` itself or a wrapper forwarding to one.
1406
+ *
1407
+ * Identity alone is not a usable contract here. Hosts compose and wrap
1408
+ * handlers (`composeEventHandlers`, `createRunHandlers`), and every wrapper
1409
+ * fails `instanceof` while still driving the same dispatch. A brand survives
1410
+ * wrapping, so "does this handler own content-part dispatch" can be answered
1411
+ * about a value the SDK did not construct.
1412
+ */
1413
+ export const SDK_STREAM_DISPATCH = Symbol.for(
1414
+ '@librechat/agents:chatModelStreamDispatch'
1415
+ );
1416
+
1417
+ /** True when `handler` is, or forwards to, the SDK's stream dispatcher. */
1418
+ export function dispatchesChatModelStream(handler?: t.EventHandler): boolean {
1419
+ if (handler == null) {
1420
+ return false;
1421
+ }
1422
+ if (handler instanceof ChatModelStreamHandler) {
1423
+ return true;
1424
+ }
1425
+ return Reflect.get(handler, SDK_STREAM_DISPATCH) === true;
1426
+ }
1427
+
1403
1428
  export class ChatModelStreamHandler implements t.EventHandler {
1429
+ readonly [SDK_STREAM_DISPATCH] = true;
1430
+
1404
1431
  async handle(
1405
1432
  event: string,
1406
1433
  data: t.StreamEventData,
@@ -1846,13 +1873,16 @@ export function createContentAggregator(): t.ContentAggregatorResult {
1846
1873
  number,
1847
1874
  { agentId?: string; groupId?: number }
1848
1875
  >();
1849
- const getFirstContentPart = (
1876
+ /** A delta's content may carry several parts (e.g. Google server-side tool
1877
+ * chunks emit multiple reasoning entries at once); every entry must reach
1878
+ * the step's slot, in order, or streamed text is silently lost. */
1879
+ const getDeltaContentParts = (
1850
1880
  content?: t.MessageDelta['content'] | t.MessageContentComplex
1851
- ): t.MessageContentComplex | undefined => {
1881
+ ): t.MessageContentComplex[] => {
1852
1882
  if (content == null) {
1853
- return undefined;
1883
+ return [];
1854
1884
  }
1855
- return Array.isArray(content) ? content[0] : content;
1885
+ return Array.isArray(content) ? content : [content];
1856
1886
  };
1857
1887
  const indexContentPart = (
1858
1888
  index: number,
@@ -2284,8 +2314,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2284
2314
  return;
2285
2315
  }
2286
2316
 
2287
- const contentPart = getFirstContentPart(messageDelta.delta.content);
2288
- if (contentPart != null) {
2317
+ for (const contentPart of getDeltaContentParts(messageDelta.delta.content)) {
2289
2318
  updateContent(runStep.index, contentPart);
2290
2319
  }
2291
2320
  } else if (
@@ -2314,8 +2343,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2314
2343
  return;
2315
2344
  }
2316
2345
 
2317
- const contentPart = getFirstContentPart(reasoningDelta.delta.content);
2318
- if (contentPart != null) {
2346
+ for (const contentPart of getDeltaContentParts(reasoningDelta.delta.content)) {
2319
2347
  updateContent(runStep.index, contentPart);
2320
2348
  }
2321
2349
  } else if (event === GraphEvents.ON_RUN_STEP_DELTA) {
@@ -70,7 +70,7 @@ import {
70
70
  } from '@/tools/local';
71
71
  import { stripCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
72
72
  import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
73
- import { toLangChainContent } from '@/messages/langchain';
73
+ import { convertInjectedMessages } from '@/messages/injected';
74
74
  import { safeDispatchCustomEvent } from '@/utils/events';
75
75
  import { RunnableCallable } from '@/utils';
76
76
  import { executeHooks } from '@/hooks';
@@ -2934,7 +2934,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2934
2934
  if (result.injectedMessages && result.injectedMessages.length > 0) {
2935
2935
  try {
2936
2936
  injected.push(
2937
- ...this.convertInjectedMessages(result.injectedMessages)
2937
+ ...convertInjectedMessages(result.injectedMessages)
2938
2938
  );
2939
2939
  } catch (e) {
2940
2940
  // eslint-disable-next-line no-console
@@ -3344,7 +3344,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3344
3344
  */
3345
3345
  if (hookInjectedMessages.length > 0) {
3346
3346
  try {
3347
- injected.push(...this.convertInjectedMessages(hookInjectedMessages));
3347
+ injected.push(...convertInjectedMessages(hookInjectedMessages));
3348
3348
  } catch (e) {
3349
3349
  // eslint-disable-next-line no-console
3350
3350
  console.warn(
@@ -3426,34 +3426,6 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3426
3426
  );
3427
3427
  }
3428
3428
 
3429
- /**
3430
- * Converts InjectedMessage instances to LangChain HumanMessage objects.
3431
- * Both 'user' and 'system' roles become HumanMessage to avoid provider
3432
- * rejections (Anthropic/Google reject non-leading SystemMessages).
3433
- * The original role is preserved in additional_kwargs for downstream consumers.
3434
- */
3435
- private convertInjectedMessages(
3436
- messages: t.InjectedMessage[]
3437
- ): BaseMessage[] {
3438
- const converted: BaseMessage[] = [];
3439
- for (const msg of messages) {
3440
- const additional_kwargs: Record<string, unknown> = {
3441
- role: msg.role,
3442
- };
3443
- if (msg.isMeta != null) additional_kwargs.isMeta = msg.isMeta;
3444
- if (msg.source != null) additional_kwargs.source = msg.source;
3445
- if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
3446
-
3447
- converted.push(
3448
- new HumanMessage({
3449
- content: toLangChainContent(msg.content),
3450
- additional_kwargs,
3451
- })
3452
- );
3453
- }
3454
- return converted;
3455
- }
3456
-
3457
3429
  /**
3458
3430
  * Execute a group of direct (in-process) tool calls with interrupt-safe
3459
3431
  * ordering, returning outputs aligned 1:1 with `directCalls`.
@@ -0,0 +1,153 @@
1
+ import axios from 'axios';
2
+ import { createKeenableScraper } from './keenable-scraper';
3
+
4
+ jest.mock('axios');
5
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
6
+
7
+ const sampleResponse = {
8
+ data: {
9
+ url: 'https://example.com/',
10
+ title: 'Example Domain',
11
+ content:
12
+ '# Example Domain\n\nThis domain is for use in documentation examples.',
13
+ description: 'An example page.',
14
+ },
15
+ };
16
+
17
+ describe('Keenable scraper', () => {
18
+ beforeEach(() => {
19
+ jest.clearAllMocks();
20
+ delete process.env.KEENABLE_API_KEY;
21
+ delete process.env.KEENABLE_FETCH_URL;
22
+ });
23
+
24
+ it('returns an error for empty URLs without calling the API', async () => {
25
+ const scraper = createKeenableScraper();
26
+ const [url, response] = await scraper.scrapeUrl(' ');
27
+
28
+ expect(url).toBe(' ');
29
+ expect(response.success).toBe(false);
30
+ expect(mockedAxios.get).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('hits the public endpoint and omits the API key header when keyless', async () => {
34
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
35
+
36
+ const scraper = createKeenableScraper();
37
+ const [, response] = await scraper.scrapeUrl('https://example.com');
38
+
39
+ expect(mockedAxios.get).toHaveBeenCalledWith(
40
+ 'https://api.keenable.ai/v1/fetch/public',
41
+ expect.objectContaining({
42
+ params: { url: 'https://example.com' },
43
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'LibreChat' }),
44
+ })
45
+ );
46
+ const headers = mockedAxios.get.mock.calls[0][1]?.headers as Record<
47
+ string,
48
+ string
49
+ >;
50
+ expect(headers['X-API-Key']).toBeUndefined();
51
+ expect(response.success).toBe(true);
52
+ expect(response.data?.content).toContain('# Example Domain');
53
+ });
54
+
55
+ it('hits the keyed endpoint and sends the API key header when a key is set', async () => {
56
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
57
+
58
+ const scraper = createKeenableScraper({ apiKey: 'secret-key' });
59
+ await scraper.scrapeUrl('https://example.com');
60
+
61
+ expect(mockedAxios.get).toHaveBeenCalledWith(
62
+ 'https://api.keenable.ai/v1/fetch',
63
+ expect.objectContaining({
64
+ headers: expect.objectContaining({ 'X-API-Key': 'secret-key' }),
65
+ })
66
+ );
67
+ });
68
+
69
+ it('honors a custom attribution title and fetch URL override', async () => {
70
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
71
+
72
+ const scraper = createKeenableScraper({
73
+ apiUrl: 'https://keenable.internal/v1/fetch/public',
74
+ attributionTitle: 'MyApp',
75
+ });
76
+ await scraper.scrapeUrl('https://example.com');
77
+
78
+ expect(mockedAxios.get).toHaveBeenCalledWith(
79
+ 'https://keenable.internal/v1/fetch/public',
80
+ expect.objectContaining({
81
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'MyApp' }),
82
+ })
83
+ );
84
+ });
85
+
86
+ it('reports failure when the API returns no content', async () => {
87
+ mockedAxios.get.mockResolvedValueOnce({
88
+ data: { url: 'https://x.test', content: '' },
89
+ });
90
+
91
+ const scraper = createKeenableScraper();
92
+ const [, response] = await scraper.scrapeUrl('https://x.test');
93
+
94
+ expect(response.success).toBe(false);
95
+ expect(response.error).toMatch(/no content/i);
96
+ });
97
+
98
+ it('reports failure with a message when the request throws', async () => {
99
+ mockedAxios.get.mockRejectedValueOnce(new Error('network down'));
100
+
101
+ const scraper = createKeenableScraper();
102
+ const [, response] = await scraper.scrapeUrl('https://x.test');
103
+
104
+ expect(response.success).toBe(false);
105
+ expect(response.error).toContain('network down');
106
+ });
107
+
108
+ it('scrapes multiple URLs concurrently', async () => {
109
+ mockedAxios.get
110
+ .mockResolvedValueOnce({ data: { content: 'a', url: 'https://a.test' } })
111
+ .mockResolvedValueOnce({ data: { content: 'b', url: 'https://b.test' } });
112
+
113
+ const scraper = createKeenableScraper();
114
+ const results = await scraper.scrapeUrls([
115
+ 'https://a.test',
116
+ 'https://b.test',
117
+ ]);
118
+
119
+ expect(results).toHaveLength(2);
120
+ expect(results[0][1].data?.content).toBe('a');
121
+ expect(results[1][1].data?.content).toBe('b');
122
+ });
123
+
124
+ it('extractContent returns the markdown with no structured references', () => {
125
+ const scraper = createKeenableScraper();
126
+ const [content, references] = scraper.extractContent({
127
+ success: true,
128
+ data: { content: '# Title\n\nBody' },
129
+ });
130
+
131
+ expect(content).toBe('# Title\n\nBody');
132
+ expect(references).toBeUndefined();
133
+ });
134
+
135
+ it('extractMetadata surfaces title, description and url', () => {
136
+ const scraper = createKeenableScraper();
137
+ const metadata = scraper.extractMetadata({
138
+ success: true,
139
+ data: {
140
+ content: 'x',
141
+ title: 'Example Domain',
142
+ description: 'An example page.',
143
+ url: 'https://example.com/',
144
+ },
145
+ });
146
+
147
+ expect(metadata).toEqual({
148
+ title: 'Example Domain',
149
+ description: 'An example page.',
150
+ url: 'https://example.com/',
151
+ });
152
+ });
153
+ });