@librechat/agents 3.2.51 → 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.
@@ -1,6 +1,5 @@
1
1
  import { Command } from '@langchain/langgraph';
2
2
  import type { MessageContentComplex, BaseMessage } from '@langchain/core/messages';
3
- import type { RunnableConfig } from '@langchain/core/runnables';
4
3
  import type * as t from '@/types';
5
4
  import { MultiAgentGraph } from '@/graphs/MultiAgentGraph';
6
5
  import { StandardGraph } from '@/graphs/Graph';
@@ -22,6 +21,13 @@ export declare class Run<_T extends t.BaseGraphState> {
22
21
  Graph: StandardGraph | MultiAgentGraph | undefined;
23
22
  returnContent: boolean;
24
23
  private skipCleanup;
24
+ /**
25
+ * Whether the compiled graph was built with a checkpointer (host-supplied
26
+ * or the HITL `MemorySaver` fallback). Captured at graph creation because
27
+ * the constructor can later overwrite `Graph.compileOptions` with the raw
28
+ * caller options, dropping the fallback checkpointer from that metadata.
29
+ */
30
+ private hasCheckpointer;
25
31
  private _streamResult;
26
32
  /**
27
33
  * Captured interrupt payload typed as `unknown` because the SDK
@@ -107,10 +113,7 @@ export declare class Run<_T extends t.BaseGraphState> {
107
113
  private isAwaitingResume;
108
114
  private getStreamLangfuseConfig;
109
115
  private getStreamToolOutputTracingLangfuseConfig;
110
- processStream(inputs: t.IState | Command, callerConfig: Partial<RunnableConfig> & {
111
- version: 'v1' | 'v2';
112
- run_id?: string;
113
- }, streamOptions?: t.EventStreamOptions): Promise<MessageContentComplex[] | undefined>;
116
+ processStream(inputs: t.IState | Command, callerConfig: t.RunStreamConfig, streamOptions?: t.EventStreamOptions): Promise<MessageContentComplex[] | undefined>;
114
117
  /**
115
118
  * Returns the pending interrupt captured during the most recent
116
119
  * `processStream` (or `resume`) invocation. `undefined` when the run
@@ -180,10 +183,21 @@ export declare class Run<_T extends t.BaseGraphState> {
180
183
  * paths processed; returns 0 when checkpointing is disabled.
181
184
  */
182
185
  rewindFiles(): Promise<number>;
183
- resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(resumeValue: TResume, callerConfig: Partial<RunnableConfig> & {
184
- version: 'v1' | 'v2';
185
- run_id?: string;
186
- }, streamOptions?: t.EventStreamOptions): Promise<MessageContentComplex[] | undefined>;
186
+ /**
187
+ * Resume an interrupted run. `commandOptions` forwards langgraph 1.4.5
188
+ * `Command` fields applied together with `resume` in one superstep:
189
+ * - `update`: channel updates committed at the resume point. On a *rebuilt*
190
+ * Run (new instance + durable checkpointer), `update.messages` are the first
191
+ * write the fresh wrapper sees, so they seed the `getRunMessages()` /
192
+ * `returnContent` baseline and are excluded from them (still committed to the
193
+ * checkpoint). Hosts that rebuild + inject messages should persist them
194
+ * directly or read from `getState`. Unreachable without a durable checkpointer.
195
+ * - `goto`: a *dynamic* edge that does not cancel static `addEdge` routes. On
196
+ * the built-in standard graph the fixed `toolNode -> agentNode/END` edge still
197
+ * fires, so `goto` adds rather than replaces (e.g. `goto: END` will not stop a
198
+ * tool-node resume). Intended for custom, Command-routed graphs.
199
+ */
200
+ resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(resumeValue: TResume, callerConfig: t.RunStreamConfig, streamOptions?: t.EventStreamOptions, commandOptions?: Pick<ConstructorParameters<typeof Command>[0], 'update' | 'goto'>): Promise<MessageContentComplex[] | undefined>;
187
201
  private resolveInterruptResumeConfig;
188
202
  private createSystemCallback;
189
203
  getCallbacks(clientCallbacks: t.ClientCallbacks): t.SystemCallbacks;
@@ -144,6 +144,7 @@ export interface AgentSessionRunOptions {
144
144
  threadId?: string;
145
145
  config?: Partial<RunnableConfig> & {
146
146
  version?: 'v1' | 'v2';
147
+ durability?: t.Durability;
147
148
  };
148
149
  streamOptions?: t.EventStreamOptions;
149
150
  }
@@ -11,7 +11,7 @@ import { RunnableCallable } from '@/utils';
11
11
  * batch-scoped value the method needs so the signature stays at
12
12
  * three positional parameters even as new context fields are added.
13
13
  */
14
- type RunToolBatchContext = {
14
+ type RunToolBatchContext<T = unknown> = {
15
15
  /** Position of this call within the parent ToolNode batch. */
16
16
  batchIndex?: number;
17
17
  /** Batch turn shared across every call in the batch. */
@@ -50,6 +50,13 @@ type RunToolBatchContext = {
50
50
  * contract for hosts relying on it for policy / recovery guidance.
51
51
  */
52
52
  additionalContextsSink?: string[];
53
+ /**
54
+ * Graph state the ToolNode was invoked with, threaded from `run()`
55
+ * so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
56
+ * (the deprecation-free replacement for `getCurrentTaskInput()`,
57
+ * which relies on `node:async_hooks` and is browser-incompatible).
58
+ */
59
+ runInput?: T;
53
60
  };
54
61
  export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
55
62
  private toolMap;
@@ -203,7 +210,7 @@ export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
203
210
  * register the output for future `{{tool<idx>turn<turn>}}`
204
211
  * substitutions. Omit when no registration should occur.
205
212
  */
206
- protected runTool(call: ToolCall, config: RunnableConfig, batchContext?: RunToolBatchContext): Promise<BaseMessage | Command>;
213
+ protected runTool(call: ToolCall, config: RunnableConfig, batchContext?: RunToolBatchContext<T>): Promise<BaseMessage | Command>;
207
214
  /**
208
215
  * Runs a single in-process tool call with the same lifecycle hooks
209
216
  * the event-dispatch path fires (`PreToolUse`, `PermissionDenied`,
@@ -243,3 +243,20 @@ export type EventStreamOptions = {
243
243
  callbacks?: g.ClientCallbacks;
244
244
  keepContent?: boolean;
245
245
  };
246
+ /**
247
+ * When to persist checkpoints during a run. Mirrors langgraph's option:
248
+ * `'async'`/`'sync'` checkpoint every superstep; `'exit'` checkpoints only
249
+ * at the graph's exit/interrupt boundary. Kept as a local union to avoid
250
+ * coupling to langgraph internals (it is not exported from the root).
251
+ */
252
+ export type Durability = 'async' | 'sync' | 'exit';
253
+ /**
254
+ * Config accepted by `processStream`/`resume`. Extends `RunnableConfig` with
255
+ * the stream `version`, an optional `run_id`, and an optional `durability`
256
+ * override forwarded to langgraph's run options.
257
+ */
258
+ export type RunStreamConfig = Partial<RunnableConfig> & {
259
+ version: 'v1' | 'v2';
260
+ run_id?: string;
261
+ durability?: Durability;
262
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.2.51",
3
+ "version": "3.2.53",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -222,7 +222,7 @@
222
222
  "@langchain/google-gauth": "2.2.0",
223
223
  "@langchain/google-genai": "2.2.0",
224
224
  "@langchain/google-vertexai": "2.2.0",
225
- "@langchain/langgraph": "^1.4.5",
225
+ "@langchain/langgraph": "^1.4.6",
226
226
  "@langchain/mistralai": "^1.2.0",
227
227
  "@langchain/openai": "1.5.3",
228
228
  "@langchain/textsplitters": "^1.0.1",
@@ -262,6 +262,7 @@
262
262
  "@anthropic-ai/sandbox-runtime": "^0.0.54",
263
263
  "@anthropic-ai/vertex-sdk": "^0.12.0",
264
264
  "@eslint/compat": "^1.2.7",
265
+ "@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
265
266
  "@swc/core": "^1.6.13",
266
267
  "@types/jest": "^30.0.0",
267
268
  "@types/node": "^24.13.1",
@@ -276,6 +277,8 @@
276
277
  "jest": "^30.2.0",
277
278
  "jest-util": "^30.2.0",
278
279
  "lint-staged": "^16.4.0",
280
+ "mongodb": "^6.21.0",
281
+ "mongodb-memory-server-core": "^11.2.0",
279
282
  "prettier": "^3.6.2",
280
283
  "ts-jest": "^29.4.6",
281
284
  "ts-node": "^10.9.2",
@@ -12,12 +12,11 @@ import {
12
12
  Command,
13
13
  StateGraph,
14
14
  Annotation,
15
- getCurrentTaskInput,
16
15
  messagesStateReducer,
17
16
  } from '@langchain/langgraph';
18
17
  import type { BaseMessage, AIMessageChunk } from '@langchain/core/messages';
19
18
  import type { LangGraphRunnableConfig } from '@langchain/langgraph';
20
- import type { ToolRunnableConfig } from '@langchain/core/tools';
19
+ import type { ToolRuntime } from '@langchain/core/tools';
21
20
  import type * as t from '@/types';
22
21
  import { StandardGraph } from './Graph';
23
22
  import { Constants } from '@/common';
@@ -330,12 +329,10 @@ export class MultiAgentGraph extends StandardGraph {
330
329
 
331
330
  tools.push(
332
331
  tool(
333
- async (rawInput, config) => {
332
+ async (rawInput, runtime: ToolRuntime) => {
334
333
  const input = rawInput as Record<string, unknown>;
335
- const state = getCurrentTaskInput() as t.BaseGraphState;
336
- const toolCallId =
337
- (config as ToolRunnableConfig | undefined)?.toolCall?.id ??
338
- 'unknown';
334
+ const state = runtime.state as t.BaseGraphState;
335
+ const toolCallId = runtime.toolCall?.id ?? 'unknown';
339
336
 
340
337
  /** Evaluated condition */
341
338
  const result = edge.condition!(state);
@@ -414,11 +411,9 @@ export class MultiAgentGraph extends StandardGraph {
414
411
 
415
412
  tools.push(
416
413
  tool(
417
- async (rawInput, config) => {
414
+ async (rawInput, runtime: ToolRuntime) => {
418
415
  const input = rawInput as Record<string, unknown>;
419
- const toolCallId =
420
- (config as ToolRunnableConfig | undefined)?.toolCall?.id ??
421
- 'unknown';
416
+ const toolCallId = runtime.toolCall?.id ?? 'unknown';
422
417
 
423
418
  let content = `Successfully transferred to ${destination}`;
424
419
  if (
@@ -439,7 +434,7 @@ export class MultiAgentGraph extends StandardGraph {
439
434
  },
440
435
  });
441
436
 
442
- const state = getCurrentTaskInput() as t.BaseGraphState;
437
+ const state = runtime.state as t.BaseGraphState;
443
438
 
444
439
  /**
445
440
  * For parallel handoff support:
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) {
@@ -667,7 +673,7 @@ export class Run<_T extends t.BaseGraphState> {
667
673
  const graph = this.Graph;
668
674
 
669
675
  /**
670
- * `Command` inputs (currently only `Command({ resume })`) are
676
+ * `Command` inputs (`Command({ resume, update?, goto? })`) are
671
677
  * resume-mode invocations: LangGraph rebuilds graph state from the
672
678
  * checkpointer, so we skip RunStart / UserPromptSubmit hooks (no
673
679
  * new prompt to evaluate) and read run-state from the Graph wrapper
@@ -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) {
@@ -1139,13 +1152,28 @@ export class Run<_T extends t.BaseGraphState> {
1139
1152
  return cp == null ? 0 : cp.rewind();
1140
1153
  }
1141
1154
 
1155
+ /**
1156
+ * Resume an interrupted run. `commandOptions` forwards langgraph 1.4.5
1157
+ * `Command` fields applied together with `resume` in one superstep:
1158
+ * - `update`: channel updates committed at the resume point. On a *rebuilt*
1159
+ * Run (new instance + durable checkpointer), `update.messages` are the first
1160
+ * write the fresh wrapper sees, so they seed the `getRunMessages()` /
1161
+ * `returnContent` baseline and are excluded from them (still committed to the
1162
+ * checkpoint). Hosts that rebuild + inject messages should persist them
1163
+ * directly or read from `getState`. Unreachable without a durable checkpointer.
1164
+ * - `goto`: a *dynamic* edge that does not cancel static `addEdge` routes. On
1165
+ * the built-in standard graph the fixed `toolNode -> agentNode/END` edge still
1166
+ * fires, so `goto` adds rather than replaces (e.g. `goto: END` will not stop a
1167
+ * tool-node resume). Intended for custom, Command-routed graphs.
1168
+ */
1142
1169
  async resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(
1143
1170
  resumeValue: TResume,
1144
- callerConfig: Partial<RunnableConfig> & {
1145
- version: 'v1' | 'v2';
1146
- run_id?: string;
1147
- },
1148
- streamOptions?: t.EventStreamOptions
1171
+ callerConfig: t.RunStreamConfig,
1172
+ streamOptions?: t.EventStreamOptions,
1173
+ commandOptions?: Pick<
1174
+ ConstructorParameters<typeof Command>[0],
1175
+ 'update' | 'goto'
1176
+ >
1149
1177
  ): Promise<MessageContentComplex[] | undefined> {
1150
1178
  const interruptId = this._interrupt?.interruptId;
1151
1179
  const scopedResume =
@@ -1155,24 +1183,26 @@ export class Run<_T extends t.BaseGraphState> {
1155
1183
  ? { [interruptId]: resumeValue }
1156
1184
  : resumeValue;
1157
1185
  const resumeConfig = await this.resolveInterruptResumeConfig(callerConfig);
1186
+ // langgraph 1.4.5 applies resume + state update + reroute in one superstep
1187
+ // (single checkpoint). `update`/`goto` are omitted unless the caller sets them.
1158
1188
  return this.processStream(
1159
- new Command({ resume: scopedResume }),
1189
+ new Command({
1190
+ resume: scopedResume,
1191
+ ...(commandOptions?.update !== undefined
1192
+ ? { update: commandOptions.update }
1193
+ : {}),
1194
+ ...(commandOptions?.goto !== undefined
1195
+ ? { goto: commandOptions.goto }
1196
+ : {}),
1197
+ }),
1160
1198
  resumeConfig,
1161
1199
  streamOptions
1162
1200
  );
1163
1201
  }
1164
1202
 
1165
1203
  private async resolveInterruptResumeConfig(
1166
- callerConfig: Partial<RunnableConfig> & {
1167
- version: 'v1' | 'v2';
1168
- run_id?: string;
1169
- }
1170
- ): Promise<
1171
- Partial<RunnableConfig> & {
1172
- version: 'v1' | 'v2';
1173
- run_id?: string;
1174
- }
1175
- > {
1204
+ callerConfig: t.RunStreamConfig
1205
+ ): Promise<t.RunStreamConfig> {
1176
1206
  const interrupt = this._interrupt;
1177
1207
  const interruptId = interrupt?.interruptId;
1178
1208
  const workflow = this.graphRunnable as
@@ -10,10 +10,10 @@ import {
10
10
  MessagesAnnotation,
11
11
  Command,
12
12
  START,
13
- getCurrentTaskInput,
14
13
  END,
15
14
  } from '@langchain/langgraph';
16
15
  import { ToolMessage } from '@langchain/core/messages';
16
+ import type { ToolRuntime } from '@langchain/core/tools';
17
17
 
18
18
  interface CreateHandoffToolParams {
19
19
  agentName: string;
@@ -28,16 +28,15 @@ const createHandoffTool = ({
28
28
  const toolDescription = description || `Ask agent '${agentName}' for help`;
29
29
 
30
30
  const handoffTool = tool(
31
- async (_, config) => {
31
+ async (_, runtime: ToolRuntime) => {
32
32
  const toolMessage = new ToolMessage({
33
33
  content: `Successfully transferred to ${agentName}`,
34
34
  name: toolName,
35
- tool_call_id: config.toolCall.id,
35
+ tool_call_id: runtime.toolCallId,
36
36
  });
37
37
 
38
- // inject the current agent state
39
- const state =
40
- getCurrentTaskInput() as (typeof MessagesAnnotation)['State'];
38
+ // inject the current agent state via langgraph 1.4 `runtime.state`
39
+ const state = runtime.state as (typeof MessagesAnnotation)['State'];
41
40
  return new Command({
42
41
  goto: agentName,
43
42
  update: { messages: state.messages.concat(toolMessage) },
@@ -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
+ });