@librechat/agents 3.2.52 → 3.2.54

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.
package/src/run.ts CHANGED
@@ -135,6 +135,13 @@ export class Run<_T extends t.BaseGraphState> {
135
135
  Graph: StandardGraph | MultiAgentGraph | undefined;
136
136
  returnContent: boolean = false;
137
137
  private skipCleanup: boolean = false;
138
+ /**
139
+ * Whether the compiled graph was built with a checkpointer (host-supplied
140
+ * or the HITL `MemorySaver` fallback). Captured at graph creation because
141
+ * the constructor can later overwrite `Graph.compileOptions` with the raw
142
+ * caller options, dropping the fallback checkpointer from that metadata.
143
+ */
144
+ private hasCheckpointer: boolean = false;
138
145
  private _streamResult: t.MessageContentComplex[] | undefined;
139
146
  /**
140
147
  * Captured interrupt payload typed as `unknown` because the SDK
@@ -256,6 +263,7 @@ export class Run<_T extends t.BaseGraphState> {
256
263
  standardGraph.compileOptions = this.applyHITLCheckpointerFallback(
257
264
  config.compileOptions
258
265
  );
266
+ this.hasCheckpointer = standardGraph.compileOptions?.checkpointer != null;
259
267
  standardGraph.hookRegistry = this.hookRegistry;
260
268
  standardGraph.humanInTheLoop = this.humanInTheLoop;
261
269
  standardGraph.toolOutputReferences = this.toolOutputReferences;
@@ -283,6 +291,7 @@ export class Run<_T extends t.BaseGraphState> {
283
291
 
284
292
  multiAgentGraph.compileOptions =
285
293
  this.applyHITLCheckpointerFallback(compileOptions);
294
+ this.hasCheckpointer = multiAgentGraph.compileOptions?.checkpointer != null;
286
295
 
287
296
  multiAgentGraph.hookRegistry = this.hookRegistry;
288
297
  multiAgentGraph.humanInTheLoop = this.humanInTheLoop;
@@ -647,10 +656,7 @@ export class Run<_T extends t.BaseGraphState> {
647
656
 
648
657
  async processStream(
649
658
  inputs: t.IState | Command,
650
- callerConfig: Partial<RunnableConfig> & {
651
- version: 'v1' | 'v2';
652
- run_id?: string;
653
- },
659
+ callerConfig: t.RunStreamConfig,
654
660
  streamOptions?: t.EventStreamOptions
655
661
  ): Promise<MessageContentComplex[] | undefined> {
656
662
  if (this.graphRunnable == null) {
@@ -676,10 +682,7 @@ export class Run<_T extends t.BaseGraphState> {
676
682
  const isResume = inputs instanceof Command;
677
683
  const stateInputs = isResume ? undefined : (inputs as t.IState);
678
684
 
679
- const config: Partial<RunnableConfig> & {
680
- version: 'v1' | 'v2';
681
- run_id?: string;
682
- } = {
685
+ const config: t.RunStreamConfig = {
683
686
  recursionLimit: 50,
684
687
  ...callerConfig,
685
688
  configurable: { ...callerConfig.configurable },
@@ -763,6 +766,16 @@ export class Run<_T extends t.BaseGraphState> {
763
766
  run_id: this.id,
764
767
  });
765
768
 
769
+ /**
770
+ * Default `durability: 'exit'` whenever a checkpointer is active so
771
+ * runs skip per-superstep checkpoint writes and persist only at the
772
+ * exit/interrupt boundary (all HITL/resume needs). An explicit caller
773
+ * value wins; no checkpointer leaves it unset (langgraph default).
774
+ */
775
+ if (config.durability == null && this.hasCheckpointer) {
776
+ config.durability = 'exit';
777
+ }
778
+
766
779
  const threadId = config.configurable.thread_id as string | undefined;
767
780
 
768
781
  if (this.hookRegistry != null && stateInputs != null) {
@@ -1155,10 +1168,7 @@ export class Run<_T extends t.BaseGraphState> {
1155
1168
  */
1156
1169
  async resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(
1157
1170
  resumeValue: TResume,
1158
- callerConfig: Partial<RunnableConfig> & {
1159
- version: 'v1' | 'v2';
1160
- run_id?: string;
1161
- },
1171
+ callerConfig: t.RunStreamConfig,
1162
1172
  streamOptions?: t.EventStreamOptions,
1163
1173
  commandOptions?: Pick<
1164
1174
  ConstructorParameters<typeof Command>[0],
@@ -1191,16 +1201,8 @@ export class Run<_T extends t.BaseGraphState> {
1191
1201
  }
1192
1202
 
1193
1203
  private async resolveInterruptResumeConfig(
1194
- callerConfig: Partial<RunnableConfig> & {
1195
- version: 'v1' | 'v2';
1196
- run_id?: string;
1197
- }
1198
- ): Promise<
1199
- Partial<RunnableConfig> & {
1200
- version: 'v1' | 'v2';
1201
- run_id?: string;
1202
- }
1203
- > {
1204
+ callerConfig: t.RunStreamConfig
1205
+ ): Promise<t.RunStreamConfig> {
1204
1206
  const interrupt = this._interrupt;
1205
1207
  const interruptId = interrupt?.interruptId;
1206
1208
  const workflow = this.graphRunnable as
@@ -209,7 +209,10 @@ export type AgentSessionInput = string | BaseMessage | BaseMessage[] | t.IState;
209
209
  export interface AgentSessionRunOptions {
210
210
  runId?: string;
211
211
  threadId?: string;
212
- config?: Partial<RunnableConfig> & { version?: 'v1' | 'v2' };
212
+ config?: Partial<RunnableConfig> & {
213
+ version?: 'v1' | 'v2';
214
+ durability?: t.Durability;
215
+ };
213
216
  streamOptions?: t.EventStreamOptions;
214
217
  }
215
218
 
@@ -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
+ };