@librechat/agents 3.2.52 → 3.2.53

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.
@@ -0,0 +1,243 @@
1
+ import { MongoClient } from 'mongodb';
2
+ import { HumanMessage } from '@langchain/core/messages';
3
+ import { MongoMemoryServer } from 'mongodb-memory-server-core';
4
+ import { MongoDBSaver } from '@langchain/langgraph-checkpoint-mongodb';
5
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
6
+ import {
7
+ END,
8
+ START,
9
+ Command,
10
+ Annotation,
11
+ StateGraph,
12
+ interrupt,
13
+ } from '@langchain/langgraph';
14
+ import type * as t from '@/types';
15
+ import { Providers } from '@/common';
16
+ import { Run } from '@/run';
17
+
18
+ /**
19
+ * End-to-end proof against a real Mongo store that the durability default is
20
+ * the storage optimization it claims to be, not just a config flag. A real
21
+ * `Run` with a fake streaming model (no provider API hit) writes through the
22
+ * official MongoDBSaver, and a langgraph interrupt graph exercises the HITL
23
+ * pause/resume boundary. `exit` persists only at the exit/interrupt boundary
24
+ * with no per-superstep writes; `async` checkpoints every superstep.
25
+ */
26
+ describe('durability default — real Mongo checkpointer', () => {
27
+ let mongod: MongoMemoryServer;
28
+ let client: MongoClient;
29
+
30
+ beforeAll(async () => {
31
+ // First CI run downloads a mongod binary — allow time beyond the 60s default.
32
+ mongod = await MongoMemoryServer.create();
33
+ client = new MongoClient(mongod.getUri());
34
+ await client.connect();
35
+ }, 120000);
36
+
37
+ afterAll(async () => {
38
+ await client.close();
39
+ await mongod.stop();
40
+ });
41
+
42
+ const counts = async (
43
+ dbName: string
44
+ ): Promise<{ checkpoints: number; writes: number }> => {
45
+ const db = client.db(dbName);
46
+ const [checkpoints, writes] = await Promise.all([
47
+ db.collection('checkpoints').countDocuments({}),
48
+ db.collection('checkpoint_writes').countDocuments({}),
49
+ ]);
50
+ return { checkpoints, writes };
51
+ };
52
+
53
+ describe('normal run (real Run + fake model)', () => {
54
+ const runOnce = async (
55
+ dbName: string,
56
+ durability?: t.Durability
57
+ ): Promise<void> => {
58
+ const run = await Run.create<t.IState>({
59
+ runId: `durability-${dbName}`,
60
+ graphConfig: {
61
+ type: 'standard',
62
+ agents: [
63
+ {
64
+ agentId: 'a',
65
+ provider: Providers.OPENAI,
66
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
67
+ instructions: 'noop',
68
+ maxContextTokens: 8000,
69
+ },
70
+ ],
71
+ compileOptions: {
72
+ checkpointer: new MongoDBSaver({ client, dbName }),
73
+ },
74
+ },
75
+ });
76
+ // Fake streaming model: one assistant turn, no tool calls -> agent -> END.
77
+ run.Graph?.overrideTestModel(['done'], 1);
78
+ await run.processStream(
79
+ { messages: [new HumanMessage('hi')] },
80
+ {
81
+ version: 'v2',
82
+ ...(durability != null ? { durability } : {}),
83
+ configurable: { thread_id: dbName },
84
+ }
85
+ );
86
+ };
87
+
88
+ it('exit default writes one checkpoint and no per-superstep writes; async writes more of both', async () => {
89
+ await runOnce('normal_exit');
90
+ await runOnce('normal_async', 'async');
91
+
92
+ const exitCounts = await counts('normal_exit');
93
+ const asyncCounts = await counts('normal_async');
94
+
95
+ // exit: only the final checkpoint, zero per-superstep pending writes.
96
+ expect(exitCounts).toEqual({ checkpoints: 1, writes: 0 });
97
+ // async: a checkpoint plus pending writes for every superstep.
98
+ expect(asyncCounts.checkpoints).toBeGreaterThan(exitCounts.checkpoints);
99
+ expect(asyncCounts.writes).toBeGreaterThan(exitCounts.writes);
100
+ });
101
+ });
102
+
103
+ describe('HITL interrupt (langgraph graph + real saver)', () => {
104
+ const State = Annotation.Root({
105
+ steps: Annotation<string[]>({
106
+ reducer: (a, b) => a.concat(b),
107
+ default: () => [],
108
+ }),
109
+ });
110
+
111
+ const drain = async (stream: AsyncIterable<unknown>): Promise<void> => {
112
+ for await (const _event of stream) {
113
+ // Consume the event stream exactly like Run.processStream does.
114
+ }
115
+ };
116
+
117
+ const runInterruptGraph = async (
118
+ dbName: string,
119
+ durability: t.Durability
120
+ ): Promise<{
121
+ atPause: { checkpoints: number; writes: number };
122
+ done: { checkpoints: number; writes: number };
123
+ }> => {
124
+ const saver = new MongoDBSaver({ client, dbName });
125
+ const graph = new StateGraph(State)
126
+ .addNode('a', () => ({ steps: ['a'] }))
127
+ .addNode('b', () => {
128
+ interrupt({ ask: 'approve?' });
129
+ return { steps: ['b'] };
130
+ })
131
+ .addNode('c', () => ({ steps: ['c'] }))
132
+ .addEdge(START, 'a')
133
+ .addEdge('a', 'b')
134
+ .addEdge('b', 'c')
135
+ .addEdge('c', END)
136
+ .compile({ checkpointer: saver });
137
+
138
+ // durability rides in the 2nd-arg config, exactly as run.ts threads it.
139
+ const cfg = {
140
+ configurable: { thread_id: dbName },
141
+ durability,
142
+ version: 'v2' as const,
143
+ };
144
+
145
+ await drain(graph.streamEvents({ steps: [] }, cfg, { raiseError: true }));
146
+ const atPause = await counts(dbName);
147
+
148
+ await drain(
149
+ graph.streamEvents(new Command({ resume: 'ok' }), cfg, {
150
+ raiseError: true,
151
+ })
152
+ );
153
+ const done = await counts(dbName);
154
+
155
+ return { atPause, done };
156
+ };
157
+
158
+ it('exit checkpoints only at the interrupt boundary and final exit; async checkpoints every superstep', async () => {
159
+ const exitRun = await runInterruptGraph('hitl_exit', 'exit');
160
+ const asyncRun = await runInterruptGraph('hitl_async', 'async');
161
+
162
+ // exit: one checkpoint at the pause (the interrupt), two once resumed
163
+ // (interrupt boundary + final exit).
164
+ expect(exitRun.atPause.checkpoints).toBe(1);
165
+ expect(exitRun.done.checkpoints).toBe(2);
166
+
167
+ // async: a checkpoint for every superstep, strictly more at each stage.
168
+ expect(asyncRun.atPause.checkpoints).toBeGreaterThan(
169
+ exitRun.atPause.checkpoints
170
+ );
171
+ expect(asyncRun.done.checkpoints).toBeGreaterThan(
172
+ exitRun.done.checkpoints
173
+ );
174
+ expect(asyncRun.done.writes).toBeGreaterThan(exitRun.done.writes);
175
+ });
176
+ });
177
+
178
+ describe('multiple interrupts (sequential ask-user-questions)', () => {
179
+ const State = Annotation.Root({
180
+ qa: Annotation<string[]>({
181
+ reducer: (a, b) => a.concat(b),
182
+ default: () => [],
183
+ }),
184
+ });
185
+
186
+ const drain = async (stream: AsyncIterable<unknown>): Promise<void> => {
187
+ for await (const _event of stream) {
188
+ // Consume the event stream.
189
+ }
190
+ };
191
+
192
+ it('checkpoints each interrupt boundary, chains them, and discards none across resumes', async () => {
193
+ const dbName = 'multi_exit';
194
+ const saver = new MongoDBSaver({ client, dbName });
195
+ const graph = new StateGraph(State)
196
+ .addNode('ask1', () => ({ qa: [`Q1=${interrupt({ q: 'Q1' })}`] }))
197
+ .addNode('ask2', () => ({ qa: [`Q2=${interrupt({ q: 'Q2' })}`] }))
198
+ .addNode('done', () => ({ qa: ['done'] }))
199
+ .addEdge(START, 'ask1')
200
+ .addEdge('ask1', 'ask2')
201
+ .addEdge('ask2', 'done')
202
+ .addEdge('done', END)
203
+ .compile({ checkpointer: saver });
204
+
205
+ const cfg = {
206
+ configurable: { thread_id: dbName },
207
+ durability: 'exit' as const,
208
+ version: 'v2' as const,
209
+ };
210
+
211
+ // First question -> pauses at ask1, one checkpoint at that boundary.
212
+ await drain(graph.streamEvents({ qa: [] }, cfg, { raiseError: true }));
213
+ let state = await graph.getState(cfg);
214
+ expect(state.next).toEqual(['ask1']);
215
+ expect((await counts(dbName)).checkpoints).toBe(1);
216
+
217
+ // Answer 1 -> the run continues and the LLM asks a second question.
218
+ // The chain grows to 2: the first interrupt checkpoint is retained as
219
+ // the parent, not discarded or overwritten.
220
+ await drain(
221
+ graph.streamEvents(new Command({ resume: 'A1' }), cfg, {
222
+ raiseError: true,
223
+ })
224
+ );
225
+ state = await graph.getState(cfg);
226
+ expect(state.next).toEqual(['ask2']);
227
+ expect(state.values.qa).toEqual(['Q1=A1']);
228
+ expect((await counts(dbName)).checkpoints).toBe(2);
229
+
230
+ // Answer 2 -> run completes; both answers applied, three checkpoints
231
+ // total (two interrupt boundaries + final exit).
232
+ await drain(
233
+ graph.streamEvents(new Command({ resume: 'A2' }), cfg, {
234
+ raiseError: true,
235
+ })
236
+ );
237
+ state = await graph.getState(cfg);
238
+ expect(state.next).toEqual([]);
239
+ expect(state.values.qa).toEqual(['Q1=A1', 'Q2=A2', 'done']);
240
+ expect((await counts(dbName)).checkpoints).toBe(3);
241
+ });
242
+ });
243
+ });
@@ -761,6 +761,173 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
761
761
  expect(run.Graph?.compileOptions?.checkpointer).toBe(hostCheckpointer);
762
762
  });
763
763
 
764
+ it('processStream defaults durability to "exit" when a checkpointer is active', async () => {
765
+ const { Run } = await import('@/run');
766
+ const { Providers } = await import('@/common');
767
+
768
+ const run = await Run.create<t.IState>({
769
+ runId: 'durability-exit-default',
770
+ graphConfig: {
771
+ type: 'standard',
772
+ agents: [
773
+ {
774
+ agentId: 'a',
775
+ provider: Providers.OPENAI,
776
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
777
+ instructions: 'noop',
778
+ maxContextTokens: 8000,
779
+ },
780
+ ],
781
+ },
782
+ humanInTheLoop: { enabled: true },
783
+ });
784
+ expect(run.Graph?.compileOptions?.checkpointer).toBeInstanceOf(MemorySaver);
785
+
786
+ const graph = new StateGraph(MessagesAnnotation)
787
+ .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
788
+ .addEdge(START, 'noop')
789
+ .addEdge('noop', END)
790
+ .compile();
791
+ const spy = jest.spyOn(graph, 'streamEvents');
792
+ run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
793
+
794
+ await run.processStream(
795
+ { messages: [] },
796
+ { version: 'v2', configurable: { thread_id: 't' } }
797
+ );
798
+
799
+ const streamedConfig = spy.mock.calls[0]?.[1] as
800
+ | t.RunStreamConfig
801
+ | undefined;
802
+ expect(streamedConfig?.durability).toBe('exit');
803
+ });
804
+
805
+ it('processStream respects an explicit caller durability over the checkpointer default', async () => {
806
+ const { Run } = await import('@/run');
807
+ const { Providers } = await import('@/common');
808
+
809
+ const run = await Run.create<t.IState>({
810
+ runId: 'durability-explicit-override',
811
+ graphConfig: {
812
+ type: 'standard',
813
+ agents: [
814
+ {
815
+ agentId: 'a',
816
+ provider: Providers.OPENAI,
817
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
818
+ instructions: 'noop',
819
+ maxContextTokens: 8000,
820
+ },
821
+ ],
822
+ },
823
+ humanInTheLoop: { enabled: true },
824
+ });
825
+
826
+ const graph = new StateGraph(MessagesAnnotation)
827
+ .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
828
+ .addEdge(START, 'noop')
829
+ .addEdge('noop', END)
830
+ .compile();
831
+ const spy = jest.spyOn(graph, 'streamEvents');
832
+ run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
833
+
834
+ await run.processStream(
835
+ { messages: [] },
836
+ { version: 'v2', durability: 'sync', configurable: { thread_id: 't' } }
837
+ );
838
+
839
+ const streamedConfig = spy.mock.calls[0]?.[1] as
840
+ | t.RunStreamConfig
841
+ | undefined;
842
+ expect(streamedConfig?.durability).toBe('sync');
843
+ });
844
+
845
+ it('processStream leaves durability unset when no checkpointer is active', async () => {
846
+ const { Run } = await import('@/run');
847
+ const { Providers } = await import('@/common');
848
+
849
+ const run = await Run.create<t.IState>({
850
+ runId: 'durability-no-checkpointer',
851
+ graphConfig: {
852
+ type: 'standard',
853
+ agents: [
854
+ {
855
+ agentId: 'a',
856
+ provider: Providers.OPENAI,
857
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
858
+ instructions: 'noop',
859
+ maxContextTokens: 8000,
860
+ },
861
+ ],
862
+ },
863
+ // humanInTheLoop omitted — no checkpointer installed
864
+ });
865
+ expect(run.Graph?.compileOptions?.checkpointer).toBeUndefined();
866
+
867
+ const graph = new StateGraph(MessagesAnnotation)
868
+ .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
869
+ .addEdge(START, 'noop')
870
+ .addEdge('noop', END)
871
+ .compile();
872
+ const spy = jest.spyOn(graph, 'streamEvents');
873
+ run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
874
+
875
+ await run.processStream(
876
+ { messages: [] },
877
+ { version: 'v2', configurable: { thread_id: 't' } }
878
+ );
879
+
880
+ const streamedConfig = spy.mock.calls[0]?.[1] as
881
+ | t.RunStreamConfig
882
+ | undefined;
883
+ expect(streamedConfig?.durability).toBeUndefined();
884
+ });
885
+
886
+ it('defaults durability to "exit" when HITL installs the fallback checkpointer but caller compileOptions omit it', async () => {
887
+ const { Run } = await import('@/run');
888
+ const { Providers } = await import('@/common');
889
+
890
+ const run = await Run.create<t.IState>({
891
+ runId: 'durability-hitl-fallback-compileopts',
892
+ graphConfig: {
893
+ type: 'standard',
894
+ agents: [
895
+ {
896
+ agentId: 'a',
897
+ provider: Providers.OPENAI,
898
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
899
+ instructions: 'noop',
900
+ maxContextTokens: 8000,
901
+ },
902
+ ],
903
+ // Caller compileOptions without a checkpointer: HITL adds a MemorySaver
904
+ // fallback to the compiled graph, but the constructor restores this raw
905
+ // metadata (no checkpointer) onto Graph.compileOptions.
906
+ compileOptions: { interruptBefore: [] },
907
+ },
908
+ humanInTheLoop: { enabled: true },
909
+ });
910
+ expect(run.Graph?.compileOptions?.checkpointer).toBeUndefined();
911
+
912
+ const graph = new StateGraph(MessagesAnnotation)
913
+ .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
914
+ .addEdge(START, 'noop')
915
+ .addEdge('noop', END)
916
+ .compile();
917
+ const spy = jest.spyOn(graph, 'streamEvents');
918
+ run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
919
+
920
+ await run.processStream(
921
+ { messages: [] },
922
+ { version: 'v2', configurable: { thread_id: 't' } }
923
+ );
924
+
925
+ const streamedConfig = spy.mock.calls[0]?.[1] as
926
+ | t.RunStreamConfig
927
+ | undefined;
928
+ expect(streamedConfig?.durability).toBe('exit');
929
+ });
930
+
764
931
  it('Run.resume forwards update + goto into the resume Command (langgraph 1.4.5)', async () => {
765
932
  const { Run } = await import('@/run');
766
933
  const { Providers } = await import('@/common');
package/src/types/run.ts CHANGED
@@ -261,3 +261,22 @@ export type EventStreamOptions = {
261
261
  callbacks?: g.ClientCallbacks;
262
262
  keepContent?: boolean;
263
263
  };
264
+
265
+ /**
266
+ * When to persist checkpoints during a run. Mirrors langgraph's option:
267
+ * `'async'`/`'sync'` checkpoint every superstep; `'exit'` checkpoints only
268
+ * at the graph's exit/interrupt boundary. Kept as a local union to avoid
269
+ * coupling to langgraph internals (it is not exported from the root).
270
+ */
271
+ export type Durability = 'async' | 'sync' | 'exit';
272
+
273
+ /**
274
+ * Config accepted by `processStream`/`resume`. Extends `RunnableConfig` with
275
+ * the stream `version`, an optional `run_id`, and an optional `durability`
276
+ * override forwarded to langgraph's run options.
277
+ */
278
+ export type RunStreamConfig = Partial<RunnableConfig> & {
279
+ version: 'v1' | 'v2';
280
+ run_id?: string;
281
+ durability?: Durability;
282
+ };