@librechat/agents 3.3.9 → 3.3.11

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 (61) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +4 -0
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +21 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/llm/init.cjs +1 -1
  8. package/dist/cjs/llm/invoke.cjs +14 -7
  9. package/dist/cjs/llm/invoke.cjs.map +1 -1
  10. package/dist/cjs/llm/openai/index.cjs +188 -11
  11. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  12. package/dist/cjs/main.cjs +4 -3
  13. package/dist/cjs/messages/core.cjs +592 -27
  14. package/dist/cjs/messages/core.cjs.map +1 -1
  15. package/dist/cjs/run.cjs +11 -1
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/stream.cjs +2 -2
  18. package/dist/cjs/tools/ToolNode.cjs +1 -1
  19. package/dist/cjs/tools/search/tool.cjs +1 -1
  20. package/dist/cjs/utils/index.cjs +1 -1
  21. package/dist/esm/agents/AgentContext.mjs +4 -0
  22. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  23. package/dist/esm/graphs/Graph.mjs +21 -2
  24. package/dist/esm/graphs/Graph.mjs.map +1 -1
  25. package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
  26. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  27. package/dist/esm/llm/init.mjs +1 -1
  28. package/dist/esm/llm/invoke.mjs +14 -7
  29. package/dist/esm/llm/invoke.mjs.map +1 -1
  30. package/dist/esm/llm/openai/index.mjs +190 -13
  31. package/dist/esm/llm/openai/index.mjs.map +1 -1
  32. package/dist/esm/main.mjs +5 -5
  33. package/dist/esm/messages/core.mjs +592 -28
  34. package/dist/esm/messages/core.mjs.map +1 -1
  35. package/dist/esm/run.mjs +11 -1
  36. package/dist/esm/run.mjs.map +1 -1
  37. package/dist/esm/stream.mjs +2 -2
  38. package/dist/esm/tools/ToolNode.mjs +1 -1
  39. package/dist/esm/tools/search/tool.mjs +1 -1
  40. package/dist/esm/utils/index.mjs +1 -1
  41. package/dist/types/agents/AgentContext.d.ts +2 -0
  42. package/dist/types/graphs/Graph.d.ts +5 -0
  43. package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
  44. package/dist/types/llm/invoke.d.ts +1 -1
  45. package/dist/types/messages/core.d.ts +11 -6
  46. package/dist/types/run.d.ts +7 -0
  47. package/package.json +1 -1
  48. package/src/agents/AgentContext.ts +5 -0
  49. package/src/graphs/Graph.ts +39 -0
  50. package/src/langfuseToolOutputTracing.ts +410 -14
  51. package/src/llm/custom-chat-models.smoke.test.ts +747 -0
  52. package/src/llm/invoke.test.ts +98 -0
  53. package/src/llm/invoke.ts +34 -23
  54. package/src/llm/openai/index.ts +334 -25
  55. package/src/llm/openai/llm.spec.ts +107 -6
  56. package/src/messages/core.ts +1290 -42
  57. package/src/messages/formatAgentMessages.test.ts +2623 -0
  58. package/src/run.ts +15 -0
  59. package/src/specs/discovered-tools.test.ts +217 -0
  60. package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
  61. package/src/specs/preemptSeal.test.ts +374 -5
@@ -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
  });