@librechat/agents 3.2.51 → 3.2.52
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 +21 -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 +21 -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 +15 -1
- package/dist/types/tools/ToolNode.d.ts +9 -2
- package/package.json +1 -1
- package/src/graphs/MultiAgentGraph.ts +7 -12
- package/src/run.ts +31 -3
- package/src/scripts/handoff-test.ts +5 -6
- package/src/tools/ToolNode.ts +43 -5
- package/src/tools/__tests__/ToolNode.runtimeState.test.ts +120 -0
- package/src/tools/__tests__/hitl.test.ts +162 -0
package/dist/types/run.d.ts
CHANGED
|
@@ -180,10 +180,24 @@ export declare class Run<_T extends t.BaseGraphState> {
|
|
|
180
180
|
* paths processed; returns 0 when checkpointing is disabled.
|
|
181
181
|
*/
|
|
182
182
|
rewindFiles(): Promise<number>;
|
|
183
|
+
/**
|
|
184
|
+
* Resume an interrupted run. `commandOptions` forwards langgraph 1.4.5
|
|
185
|
+
* `Command` fields applied together with `resume` in one superstep:
|
|
186
|
+
* - `update`: channel updates committed at the resume point. On a *rebuilt*
|
|
187
|
+
* Run (new instance + durable checkpointer), `update.messages` are the first
|
|
188
|
+
* write the fresh wrapper sees, so they seed the `getRunMessages()` /
|
|
189
|
+
* `returnContent` baseline and are excluded from them (still committed to the
|
|
190
|
+
* checkpoint). Hosts that rebuild + inject messages should persist them
|
|
191
|
+
* directly or read from `getState`. Unreachable without a durable checkpointer.
|
|
192
|
+
* - `goto`: a *dynamic* edge that does not cancel static `addEdge` routes. On
|
|
193
|
+
* the built-in standard graph the fixed `toolNode -> agentNode/END` edge still
|
|
194
|
+
* fires, so `goto` adds rather than replaces (e.g. `goto: END` will not stop a
|
|
195
|
+
* tool-node resume). Intended for custom, Command-routed graphs.
|
|
196
|
+
*/
|
|
183
197
|
resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(resumeValue: TResume, callerConfig: Partial<RunnableConfig> & {
|
|
184
198
|
version: 'v1' | 'v2';
|
|
185
199
|
run_id?: string;
|
|
186
|
-
}, streamOptions?: t.EventStreamOptions): Promise<MessageContentComplex[] | undefined>;
|
|
200
|
+
}, 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`,
|
package/package.json
CHANGED
|
@@ -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
|
@@ -667,7 +667,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
667
667
|
const graph = this.Graph;
|
|
668
668
|
|
|
669
669
|
/**
|
|
670
|
-
* `Command` inputs (
|
|
670
|
+
* `Command` inputs (`Command({ resume, update?, goto? })`) are
|
|
671
671
|
* resume-mode invocations: LangGraph rebuilds graph state from the
|
|
672
672
|
* checkpointer, so we skip RunStart / UserPromptSubmit hooks (no
|
|
673
673
|
* new prompt to evaluate) and read run-state from the Graph wrapper
|
|
@@ -1139,13 +1139,31 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
1139
1139
|
return cp == null ? 0 : cp.rewind();
|
|
1140
1140
|
}
|
|
1141
1141
|
|
|
1142
|
+
/**
|
|
1143
|
+
* Resume an interrupted run. `commandOptions` forwards langgraph 1.4.5
|
|
1144
|
+
* `Command` fields applied together with `resume` in one superstep:
|
|
1145
|
+
* - `update`: channel updates committed at the resume point. On a *rebuilt*
|
|
1146
|
+
* Run (new instance + durable checkpointer), `update.messages` are the first
|
|
1147
|
+
* write the fresh wrapper sees, so they seed the `getRunMessages()` /
|
|
1148
|
+
* `returnContent` baseline and are excluded from them (still committed to the
|
|
1149
|
+
* checkpoint). Hosts that rebuild + inject messages should persist them
|
|
1150
|
+
* directly or read from `getState`. Unreachable without a durable checkpointer.
|
|
1151
|
+
* - `goto`: a *dynamic* edge that does not cancel static `addEdge` routes. On
|
|
1152
|
+
* the built-in standard graph the fixed `toolNode -> agentNode/END` edge still
|
|
1153
|
+
* fires, so `goto` adds rather than replaces (e.g. `goto: END` will not stop a
|
|
1154
|
+
* tool-node resume). Intended for custom, Command-routed graphs.
|
|
1155
|
+
*/
|
|
1142
1156
|
async resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(
|
|
1143
1157
|
resumeValue: TResume,
|
|
1144
1158
|
callerConfig: Partial<RunnableConfig> & {
|
|
1145
1159
|
version: 'v1' | 'v2';
|
|
1146
1160
|
run_id?: string;
|
|
1147
1161
|
},
|
|
1148
|
-
streamOptions?: t.EventStreamOptions
|
|
1162
|
+
streamOptions?: t.EventStreamOptions,
|
|
1163
|
+
commandOptions?: Pick<
|
|
1164
|
+
ConstructorParameters<typeof Command>[0],
|
|
1165
|
+
'update' | 'goto'
|
|
1166
|
+
>
|
|
1149
1167
|
): Promise<MessageContentComplex[] | undefined> {
|
|
1150
1168
|
const interruptId = this._interrupt?.interruptId;
|
|
1151
1169
|
const scopedResume =
|
|
@@ -1155,8 +1173,18 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
1155
1173
|
? { [interruptId]: resumeValue }
|
|
1156
1174
|
: resumeValue;
|
|
1157
1175
|
const resumeConfig = await this.resolveInterruptResumeConfig(callerConfig);
|
|
1176
|
+
// langgraph 1.4.5 applies resume + state update + reroute in one superstep
|
|
1177
|
+
// (single checkpoint). `update`/`goto` are omitted unless the caller sets them.
|
|
1158
1178
|
return this.processStream(
|
|
1159
|
-
new Command({
|
|
1179
|
+
new Command({
|
|
1180
|
+
resume: scopedResume,
|
|
1181
|
+
...(commandOptions?.update !== undefined
|
|
1182
|
+
? { update: commandOptions.update }
|
|
1183
|
+
: {}),
|
|
1184
|
+
...(commandOptions?.goto !== undefined
|
|
1185
|
+
? { goto: commandOptions.goto }
|
|
1186
|
+
: {}),
|
|
1187
|
+
}),
|
|
1160
1188
|
resumeConfig,
|
|
1161
1189
|
streamOptions
|
|
1162
1190
|
);
|
|
@@ -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/tools/ToolNode.ts
CHANGED
|
@@ -19,8 +19,12 @@ import type {
|
|
|
19
19
|
RunnableConfig,
|
|
20
20
|
RunnableToolLike,
|
|
21
21
|
} from '@langchain/core/runnables';
|
|
22
|
+
import type {
|
|
23
|
+
ToolRuntime,
|
|
24
|
+
StructuredToolInterface,
|
|
25
|
+
} from '@langchain/core/tools';
|
|
22
26
|
import type { BaseMessage, AIMessage } from '@langchain/core/messages';
|
|
23
|
-
import type {
|
|
27
|
+
import type { LangGraphRunnableConfig } from '@langchain/langgraph';
|
|
24
28
|
import type {
|
|
25
29
|
ToolOutputResolveView,
|
|
26
30
|
PreResolvedArgsMap,
|
|
@@ -65,7 +69,7 @@ import { executeHooks } from '@/hooks';
|
|
|
65
69
|
* batch-scoped value the method needs so the signature stays at
|
|
66
70
|
* three positional parameters even as new context fields are added.
|
|
67
71
|
*/
|
|
68
|
-
type RunToolBatchContext = {
|
|
72
|
+
type RunToolBatchContext<T = unknown> = {
|
|
69
73
|
/** Position of this call within the parent ToolNode batch. */
|
|
70
74
|
batchIndex?: number;
|
|
71
75
|
/** Batch turn shared across every call in the batch. */
|
|
@@ -104,6 +108,13 @@ type RunToolBatchContext = {
|
|
|
104
108
|
* contract for hosts relying on it for policy / recovery guidance.
|
|
105
109
|
*/
|
|
106
110
|
additionalContextsSink?: string[];
|
|
111
|
+
/**
|
|
112
|
+
* Graph state the ToolNode was invoked with, threaded from `run()`
|
|
113
|
+
* so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
|
|
114
|
+
* (the deprecation-free replacement for `getCurrentTaskInput()`,
|
|
115
|
+
* which relies on `node:async_hooks` and is browser-incompatible).
|
|
116
|
+
*/
|
|
117
|
+
runInput?: T;
|
|
107
118
|
};
|
|
108
119
|
|
|
109
120
|
const TOOL_NODE_RUN_NAME = 'tool_batch';
|
|
@@ -798,7 +809,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
798
809
|
protected async runTool(
|
|
799
810
|
call: ToolCall,
|
|
800
811
|
config: RunnableConfig,
|
|
801
|
-
batchContext: RunToolBatchContext = {}
|
|
812
|
+
batchContext: RunToolBatchContext<T> = {}
|
|
802
813
|
): Promise<BaseMessage | Command> {
|
|
803
814
|
const {
|
|
804
815
|
batchIndex,
|
|
@@ -806,6 +817,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
806
817
|
batchScopeId,
|
|
807
818
|
resolvedArgsByCallId,
|
|
808
819
|
preBatchSnapshot,
|
|
820
|
+
runInput,
|
|
809
821
|
} = batchContext;
|
|
810
822
|
const tool = this.toolMap.get(call.name);
|
|
811
823
|
const registry = this.toolOutputRegistry;
|
|
@@ -980,7 +992,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
980
992
|
}
|
|
981
993
|
}
|
|
982
994
|
|
|
983
|
-
|
|
995
|
+
/**
|
|
996
|
+
* Forward the graph state as langgraph 1.4's `runtime.state` so
|
|
997
|
+
* tools can read it off their second argument instead of the
|
|
998
|
+
* deprecated `getCurrentTaskInput()` (which relies on
|
|
999
|
+
* `node:async_hooks` and is browser-incompatible). Shape mirrors
|
|
1000
|
+
* langgraph's prebuilt ToolNode runtime exactly.
|
|
1001
|
+
*/
|
|
1002
|
+
const lgConfig = config as LangGraphRunnableConfig;
|
|
1003
|
+
const runtime: ToolRuntime<T> = {
|
|
1004
|
+
...config,
|
|
1005
|
+
state: runInput as ToolRuntime<T>['state'],
|
|
1006
|
+
toolCallId: call.id ?? '',
|
|
1007
|
+
config,
|
|
1008
|
+
context: lgConfig.context as ToolRuntime<T>['context'],
|
|
1009
|
+
store: (lgConfig.store ?? null) as ToolRuntime<T>['store'],
|
|
1010
|
+
writer:
|
|
1011
|
+
lgConfig.writer ??
|
|
1012
|
+
(config.configurable?.writer as ToolRuntime<T>['writer']) ??
|
|
1013
|
+
null,
|
|
1014
|
+
};
|
|
1015
|
+
const output = await tool.invoke(invokeParams, runtime);
|
|
984
1016
|
if (isCommand(output)) {
|
|
985
1017
|
return output;
|
|
986
1018
|
}
|
|
@@ -1192,7 +1224,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1192
1224
|
private async runDirectToolWithLifecycleHooks(
|
|
1193
1225
|
call: ToolCall,
|
|
1194
1226
|
config: RunnableConfig,
|
|
1195
|
-
batchContext: RunToolBatchContext = {}
|
|
1227
|
+
batchContext: RunToolBatchContext<T> = {}
|
|
1196
1228
|
): Promise<BaseMessage | Command> {
|
|
1197
1229
|
const runId = (config.configurable?.run_id as string | undefined) ?? '';
|
|
1198
1230
|
const hookRegistry = this.hookRegistry;
|
|
@@ -3162,6 +3194,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3162
3194
|
// branch, so direct tools dispatched via Send (a supported
|
|
3163
3195
|
// input shape) still silently dropped hook context.
|
|
3164
3196
|
const directAdditionalContexts: string[] = [];
|
|
3197
|
+
// Mirror langgraph's prebuilt ToolNode: the Send-input state is
|
|
3198
|
+
// the input minus the `lg_tool_call` envelope key.
|
|
3199
|
+
const { lg_tool_call: _sendToolCall, ...sendState } = input;
|
|
3165
3200
|
const sendOutput = await this.runDirectToolWithLifecycleHooks(
|
|
3166
3201
|
input.lg_tool_call,
|
|
3167
3202
|
config,
|
|
@@ -3171,6 +3206,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3171
3206
|
batchScopeId,
|
|
3172
3207
|
resolvedArgsByCallId,
|
|
3173
3208
|
additionalContextsSink: directAdditionalContexts,
|
|
3209
|
+
runInput: sendState as T,
|
|
3174
3210
|
}
|
|
3175
3211
|
);
|
|
3176
3212
|
outputs =
|
|
@@ -3343,6 +3379,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3343
3379
|
resolvedArgsByCallId,
|
|
3344
3380
|
preBatchSnapshot,
|
|
3345
3381
|
additionalContextsSink: directAdditionalContexts,
|
|
3382
|
+
runInput: input as T,
|
|
3346
3383
|
})
|
|
3347
3384
|
)
|
|
3348
3385
|
)
|
|
@@ -3407,6 +3444,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3407
3444
|
resolvedArgsByCallId,
|
|
3408
3445
|
preBatchSnapshot,
|
|
3409
3446
|
additionalContextsSink: directAdditionalContexts,
|
|
3447
|
+
runInput: input as T,
|
|
3410
3448
|
})
|
|
3411
3449
|
)
|
|
3412
3450
|
);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { describe, it, expect } from '@jest/globals';
|
|
4
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
5
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
6
|
+
import type { ToolRuntime } from '@langchain/core/tools';
|
|
7
|
+
import { ToolNode } from '../ToolNode';
|
|
8
|
+
|
|
9
|
+
type CapturedRuntime = {
|
|
10
|
+
state: unknown;
|
|
11
|
+
toolCallId: string;
|
|
12
|
+
toolCallIdFromConfig: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tool that records the langgraph runtime it received as its second
|
|
17
|
+
* argument. Proves our forked ToolNode forwards `runtime.state` (the
|
|
18
|
+
* deprecation-free replacement for `getCurrentTaskInput()`).
|
|
19
|
+
*/
|
|
20
|
+
function createRuntimeProbeTool(captured: CapturedRuntime[]) {
|
|
21
|
+
return tool(
|
|
22
|
+
async (_input, runtime: ToolRuntime) => {
|
|
23
|
+
captured.push({
|
|
24
|
+
state: runtime.state,
|
|
25
|
+
toolCallId: runtime.toolCallId,
|
|
26
|
+
toolCallIdFromConfig: runtime.toolCall?.id,
|
|
27
|
+
});
|
|
28
|
+
return 'ok';
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'probe_runtime',
|
|
32
|
+
description: 'Records the runtime forwarded by the ToolNode',
|
|
33
|
+
schema: z.object({ value: z.string() }),
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function aiMessageWithProbeCall(callId: string): AIMessage {
|
|
39
|
+
return new AIMessage({
|
|
40
|
+
content: '',
|
|
41
|
+
tool_calls: [
|
|
42
|
+
{
|
|
43
|
+
id: callId,
|
|
44
|
+
name: 'probe_runtime',
|
|
45
|
+
args: { value: 'hello' },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('ToolNode runtime.state forwarding (langgraph 1.4)', () => {
|
|
52
|
+
it('forwards the message-state input as runtime.state', async () => {
|
|
53
|
+
const captured: CapturedRuntime[] = [];
|
|
54
|
+
const toolNode = new ToolNode({
|
|
55
|
+
tools: [createRuntimeProbeTool(captured)],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const messages: BaseMessage[] = [
|
|
59
|
+
new HumanMessage('start'),
|
|
60
|
+
aiMessageWithProbeCall('call_1'),
|
|
61
|
+
];
|
|
62
|
+
const input = { messages };
|
|
63
|
+
|
|
64
|
+
await toolNode.invoke(input);
|
|
65
|
+
|
|
66
|
+
expect(captured).toHaveLength(1);
|
|
67
|
+
/* state is the exact run input object the ToolNode was invoked with */
|
|
68
|
+
expect(captured[0].state).toBe(input);
|
|
69
|
+
expect((captured[0].state as { messages: BaseMessage[] }).messages).toBe(
|
|
70
|
+
messages
|
|
71
|
+
);
|
|
72
|
+
/* toolCallId and the langchain-extracted toolCall both resolve */
|
|
73
|
+
expect(captured[0].toolCallId).toBe('call_1');
|
|
74
|
+
expect(captured[0].toolCallIdFromConfig).toBe('call_1');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('forwards the array-shaped input as runtime.state', async () => {
|
|
78
|
+
const captured: CapturedRuntime[] = [];
|
|
79
|
+
const toolNode = new ToolNode({
|
|
80
|
+
tools: [createRuntimeProbeTool(captured)],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const input: BaseMessage[] = [
|
|
84
|
+
new HumanMessage('start'),
|
|
85
|
+
aiMessageWithProbeCall('call_2'),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
await toolNode.invoke(input);
|
|
89
|
+
|
|
90
|
+
expect(captured).toHaveLength(1);
|
|
91
|
+
expect(captured[0].state).toBe(input);
|
|
92
|
+
expect(captured[0].toolCallId).toBe('call_2');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('forwards the de-enveloped state for Send-shaped input', async () => {
|
|
96
|
+
const captured: CapturedRuntime[] = [];
|
|
97
|
+
const toolNode = new ToolNode({
|
|
98
|
+
tools: [createRuntimeProbeTool(captured)],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const sidecar = { tenant: 'acme' };
|
|
102
|
+
await toolNode.invoke({
|
|
103
|
+
lg_tool_call: {
|
|
104
|
+
id: 'call_3',
|
|
105
|
+
name: 'probe_runtime',
|
|
106
|
+
args: { value: 'hi' },
|
|
107
|
+
type: 'tool_call',
|
|
108
|
+
},
|
|
109
|
+
...sidecar,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(captured).toHaveLength(1);
|
|
113
|
+
/* The `lg_tool_call` envelope key is stripped, the rest is state */
|
|
114
|
+
expect(captured[0].state).toEqual(sidecar);
|
|
115
|
+
expect(
|
|
116
|
+
(captured[0].state as Record<string, unknown>).lg_tool_call
|
|
117
|
+
).toBeUndefined();
|
|
118
|
+
expect(captured[0].toolCallId).toBe('call_3');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -761,6 +761,54 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
|
|
|
761
761
|
expect(run.Graph?.compileOptions?.checkpointer).toBe(hostCheckpointer);
|
|
762
762
|
});
|
|
763
763
|
|
|
764
|
+
it('Run.resume forwards update + goto into the resume Command (langgraph 1.4.5)', async () => {
|
|
765
|
+
const { Run } = await import('@/run');
|
|
766
|
+
const { Providers } = await import('@/common');
|
|
767
|
+
|
|
768
|
+
const run = await Run.create<t.IState>({
|
|
769
|
+
runId: 'hitl-resume-update-goto',
|
|
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
|
+
});
|
|
783
|
+
|
|
784
|
+
const spy = jest.spyOn(run, 'processStream').mockResolvedValue(undefined);
|
|
785
|
+
|
|
786
|
+
const decision = [{ type: 'approve' as const }];
|
|
787
|
+
const update = { messages: [new AIMessage('host-edit')] };
|
|
788
|
+
await run.resume(
|
|
789
|
+
decision,
|
|
790
|
+
{ version: 'v1', configurable: { thread_id: 't' } },
|
|
791
|
+
undefined,
|
|
792
|
+
{ update, goto: 'agent' }
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const cmd = spy.mock.calls[0]?.[0] as Command;
|
|
796
|
+
expect(cmd).toBeInstanceOf(Command);
|
|
797
|
+
// No interrupt was captured, so the resume value passes through unscoped.
|
|
798
|
+
expect(cmd.resume).toEqual(decision);
|
|
799
|
+
expect(cmd.update).toEqual(update);
|
|
800
|
+
expect(cmd.goto).toEqual(['agent']); // langgraph normalizes goto to an array
|
|
801
|
+
|
|
802
|
+
// Backward-compat: omitting commandOptions leaves update unset, goto empty.
|
|
803
|
+
await run.resume(decision, {
|
|
804
|
+
version: 'v1',
|
|
805
|
+
configurable: { thread_id: 't' },
|
|
806
|
+
});
|
|
807
|
+
const cmd2 = spy.mock.calls[1]?.[0] as Command;
|
|
808
|
+
expect(cmd2.update).toBeUndefined();
|
|
809
|
+
expect(cmd2.goto).toEqual([]);
|
|
810
|
+
});
|
|
811
|
+
|
|
764
812
|
it('re-exports langgraph HITL primitives from the SDK barrel for host use', async () => {
|
|
765
813
|
const indexExports = await import('@/index');
|
|
766
814
|
expect(indexExports.MemorySaver).toBe(MemorySaver);
|
|
@@ -903,6 +951,120 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
|
|
|
903
951
|
expect(run.getHaltReason()).toBeUndefined();
|
|
904
952
|
});
|
|
905
953
|
|
|
954
|
+
it('Run.resume() forwards `update` so langgraph applies the channel edit through streamEvents', async () => {
|
|
955
|
+
/** Executing proof (not a spy): interrupt on a tool, resume with an
|
|
956
|
+
* injected message via `update`, then read the committed checkpoint.
|
|
957
|
+
* langgraph 1.4.5 maps an INPUT resume Command through `mapCommand`
|
|
958
|
+
* (pregel/io.js), which applies resume AND update AND goto, so the
|
|
959
|
+
* injected message must land in the messages channel. */
|
|
960
|
+
jest
|
|
961
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
962
|
+
.mockImplementation(async (event, data) => {
|
|
963
|
+
if (event !== 'on_tool_execute') {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const request = data as {
|
|
967
|
+
toolCalls: t.ToolCallRequest[];
|
|
968
|
+
resolve: (r: t.ToolExecuteResult[]) => void;
|
|
969
|
+
};
|
|
970
|
+
request.resolve(
|
|
971
|
+
request.toolCalls.map((c) => ({
|
|
972
|
+
toolCallId: c.id,
|
|
973
|
+
content: 'host-result',
|
|
974
|
+
status: 'success' as const,
|
|
975
|
+
}))
|
|
976
|
+
);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const registry = new HookRegistry();
|
|
980
|
+
registry.register('PreToolUse', {
|
|
981
|
+
hooks: [
|
|
982
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
983
|
+
decision: 'ask',
|
|
984
|
+
reason: 'review',
|
|
985
|
+
}),
|
|
986
|
+
],
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const hexToolCallId = '0123456789abcdef0123456789abcdef';
|
|
990
|
+
const node = new ToolNode({
|
|
991
|
+
tools: [createSchemaStub('echo')],
|
|
992
|
+
eventDrivenMode: true,
|
|
993
|
+
agentId: 'agent-x',
|
|
994
|
+
toolCallStepIds: new Map([[hexToolCallId, 'step_1']]),
|
|
995
|
+
hookRegistry: registry,
|
|
996
|
+
humanInTheLoop: { enabled: true },
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const builder = new StateGraph(MessagesAnnotation)
|
|
1000
|
+
.addNode(
|
|
1001
|
+
'agent',
|
|
1002
|
+
(): MessagesUpdate => ({
|
|
1003
|
+
messages: [
|
|
1004
|
+
new AIMessage({
|
|
1005
|
+
content: '',
|
|
1006
|
+
tool_calls: [
|
|
1007
|
+
{ id: hexToolCallId, name: 'echo', args: { command: 'x' } },
|
|
1008
|
+
],
|
|
1009
|
+
}),
|
|
1010
|
+
],
|
|
1011
|
+
})
|
|
1012
|
+
)
|
|
1013
|
+
.addNode('tools', node)
|
|
1014
|
+
.addEdge(START, 'agent')
|
|
1015
|
+
.addEdge('agent', 'tools')
|
|
1016
|
+
.addEdge('tools', END);
|
|
1017
|
+
const graph = builder.compile({ checkpointer: new MemorySaver() });
|
|
1018
|
+
|
|
1019
|
+
const { Run } = await import('@/run');
|
|
1020
|
+
const { HumanMessage } = await import('@langchain/core/messages');
|
|
1021
|
+
const run = await Run.create<t.IState>({
|
|
1022
|
+
runId: 'run-resume-update',
|
|
1023
|
+
graphConfig: {
|
|
1024
|
+
type: 'standard',
|
|
1025
|
+
agents: [
|
|
1026
|
+
{
|
|
1027
|
+
agentId: 'a',
|
|
1028
|
+
provider: providers.OPENAI,
|
|
1029
|
+
clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
|
|
1030
|
+
instructions: 'noop',
|
|
1031
|
+
maxContextTokens: 8000,
|
|
1032
|
+
},
|
|
1033
|
+
],
|
|
1034
|
+
},
|
|
1035
|
+
hooks: registry,
|
|
1036
|
+
humanInTheLoop: { enabled: true },
|
|
1037
|
+
});
|
|
1038
|
+
run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
|
|
1039
|
+
|
|
1040
|
+
const callerConfig = {
|
|
1041
|
+
configurable: { thread_id: 'run-resume-update-thread' },
|
|
1042
|
+
version: 'v2' as const,
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
await run.processStream({ messages: [] }, callerConfig);
|
|
1046
|
+
expect(run.getInterrupt()).toBeDefined();
|
|
1047
|
+
|
|
1048
|
+
const injected = new HumanMessage({ content: 'human-injected-on-resume' });
|
|
1049
|
+
await run.resume(
|
|
1050
|
+
{ [hexToolCallId]: { type: 'approve' } },
|
|
1051
|
+
callerConfig,
|
|
1052
|
+
undefined,
|
|
1053
|
+
{ update: { messages: [injected] } }
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
expect(run.getInterrupt()).toBeUndefined();
|
|
1057
|
+
|
|
1058
|
+
const state = await graph.getState(callerConfig);
|
|
1059
|
+
const contents = (state.values.messages as BaseMessage[]).map(
|
|
1060
|
+
(m) => m.content
|
|
1061
|
+
);
|
|
1062
|
+
/** Proves langgraph honored `update` on the INPUT resume Command. */
|
|
1063
|
+
expect(contents).toContain('human-injected-on-resume');
|
|
1064
|
+
/** Resume itself still completed: the approved tool produced its result. */
|
|
1065
|
+
expect(contents).toContain('host-result');
|
|
1066
|
+
});
|
|
1067
|
+
|
|
906
1068
|
it('Run.getHaltReason() reports prompt_denied when UserPromptSubmit denies the prompt', async () => {
|
|
907
1069
|
const registry = new HookRegistry();
|
|
908
1070
|
registry.register('UserPromptSubmit', {
|