@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.
- package/dist/cjs/graphs/MultiAgentGraph.cjs +6 -6
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/run.cjs +37 -3
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +26 -5
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +7 -7
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/run.mjs +37 -3
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +26 -5
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/types/run.d.ts +23 -9
- package/dist/types/session/types.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +9 -2
- package/dist/types/types/run.d.ts +17 -0
- package/package.json +5 -2
- package/src/graphs/MultiAgentGraph.ts +7 -12
- package/src/run.ts +55 -25
- package/src/scripts/handoff-test.ts +5 -6
- package/src/session/types.ts +4 -1
- package/src/specs/durability-checkpoint.integration.test.ts +243 -0
- package/src/tools/ToolNode.ts +43 -5
- package/src/tools/__tests__/ToolNode.runtimeState.test.ts +120 -0
- package/src/tools/__tests__/hitl.test.ts +329 -0
- package/src/types/run.ts +19 -0
package/dist/types/run.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
@@ -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.
|
|
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.
|
|
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 {
|
|
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,
|
|
332
|
+
async (rawInput, runtime: ToolRuntime) => {
|
|
334
333
|
const input = rawInput as Record<string, unknown>;
|
|
335
|
-
const state =
|
|
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,
|
|
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 =
|
|
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:
|
|
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 (
|
|
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:
|
|
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:
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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({
|
|
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:
|
|
1167
|
-
|
|
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 (_,
|
|
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:
|
|
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) },
|
package/src/session/types.ts
CHANGED
|
@@ -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> & {
|
|
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
|
+
});
|