@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/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,221 @@ 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
|
+
|
|
931
|
+
it('Run.resume forwards update + goto into the resume Command (langgraph 1.4.5)', async () => {
|
|
932
|
+
const { Run } = await import('@/run');
|
|
933
|
+
const { Providers } = await import('@/common');
|
|
934
|
+
|
|
935
|
+
const run = await Run.create<t.IState>({
|
|
936
|
+
runId: 'hitl-resume-update-goto',
|
|
937
|
+
graphConfig: {
|
|
938
|
+
type: 'standard',
|
|
939
|
+
agents: [
|
|
940
|
+
{
|
|
941
|
+
agentId: 'a',
|
|
942
|
+
provider: Providers.OPENAI,
|
|
943
|
+
clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
|
|
944
|
+
instructions: 'noop',
|
|
945
|
+
maxContextTokens: 8000,
|
|
946
|
+
},
|
|
947
|
+
],
|
|
948
|
+
},
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
const spy = jest.spyOn(run, 'processStream').mockResolvedValue(undefined);
|
|
952
|
+
|
|
953
|
+
const decision = [{ type: 'approve' as const }];
|
|
954
|
+
const update = { messages: [new AIMessage('host-edit')] };
|
|
955
|
+
await run.resume(
|
|
956
|
+
decision,
|
|
957
|
+
{ version: 'v1', configurable: { thread_id: 't' } },
|
|
958
|
+
undefined,
|
|
959
|
+
{ update, goto: 'agent' }
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
const cmd = spy.mock.calls[0]?.[0] as Command;
|
|
963
|
+
expect(cmd).toBeInstanceOf(Command);
|
|
964
|
+
// No interrupt was captured, so the resume value passes through unscoped.
|
|
965
|
+
expect(cmd.resume).toEqual(decision);
|
|
966
|
+
expect(cmd.update).toEqual(update);
|
|
967
|
+
expect(cmd.goto).toEqual(['agent']); // langgraph normalizes goto to an array
|
|
968
|
+
|
|
969
|
+
// Backward-compat: omitting commandOptions leaves update unset, goto empty.
|
|
970
|
+
await run.resume(decision, {
|
|
971
|
+
version: 'v1',
|
|
972
|
+
configurable: { thread_id: 't' },
|
|
973
|
+
});
|
|
974
|
+
const cmd2 = spy.mock.calls[1]?.[0] as Command;
|
|
975
|
+
expect(cmd2.update).toBeUndefined();
|
|
976
|
+
expect(cmd2.goto).toEqual([]);
|
|
977
|
+
});
|
|
978
|
+
|
|
764
979
|
it('re-exports langgraph HITL primitives from the SDK barrel for host use', async () => {
|
|
765
980
|
const indexExports = await import('@/index');
|
|
766
981
|
expect(indexExports.MemorySaver).toBe(MemorySaver);
|
|
@@ -903,6 +1118,120 @@ describe('Run integration — HITL fallback checkpointer + resume', () => {
|
|
|
903
1118
|
expect(run.getHaltReason()).toBeUndefined();
|
|
904
1119
|
});
|
|
905
1120
|
|
|
1121
|
+
it('Run.resume() forwards `update` so langgraph applies the channel edit through streamEvents', async () => {
|
|
1122
|
+
/** Executing proof (not a spy): interrupt on a tool, resume with an
|
|
1123
|
+
* injected message via `update`, then read the committed checkpoint.
|
|
1124
|
+
* langgraph 1.4.5 maps an INPUT resume Command through `mapCommand`
|
|
1125
|
+
* (pregel/io.js), which applies resume AND update AND goto, so the
|
|
1126
|
+
* injected message must land in the messages channel. */
|
|
1127
|
+
jest
|
|
1128
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
1129
|
+
.mockImplementation(async (event, data) => {
|
|
1130
|
+
if (event !== 'on_tool_execute') {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const request = data as {
|
|
1134
|
+
toolCalls: t.ToolCallRequest[];
|
|
1135
|
+
resolve: (r: t.ToolExecuteResult[]) => void;
|
|
1136
|
+
};
|
|
1137
|
+
request.resolve(
|
|
1138
|
+
request.toolCalls.map((c) => ({
|
|
1139
|
+
toolCallId: c.id,
|
|
1140
|
+
content: 'host-result',
|
|
1141
|
+
status: 'success' as const,
|
|
1142
|
+
}))
|
|
1143
|
+
);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
const registry = new HookRegistry();
|
|
1147
|
+
registry.register('PreToolUse', {
|
|
1148
|
+
hooks: [
|
|
1149
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
1150
|
+
decision: 'ask',
|
|
1151
|
+
reason: 'review',
|
|
1152
|
+
}),
|
|
1153
|
+
],
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
const hexToolCallId = '0123456789abcdef0123456789abcdef';
|
|
1157
|
+
const node = new ToolNode({
|
|
1158
|
+
tools: [createSchemaStub('echo')],
|
|
1159
|
+
eventDrivenMode: true,
|
|
1160
|
+
agentId: 'agent-x',
|
|
1161
|
+
toolCallStepIds: new Map([[hexToolCallId, 'step_1']]),
|
|
1162
|
+
hookRegistry: registry,
|
|
1163
|
+
humanInTheLoop: { enabled: true },
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const builder = new StateGraph(MessagesAnnotation)
|
|
1167
|
+
.addNode(
|
|
1168
|
+
'agent',
|
|
1169
|
+
(): MessagesUpdate => ({
|
|
1170
|
+
messages: [
|
|
1171
|
+
new AIMessage({
|
|
1172
|
+
content: '',
|
|
1173
|
+
tool_calls: [
|
|
1174
|
+
{ id: hexToolCallId, name: 'echo', args: { command: 'x' } },
|
|
1175
|
+
],
|
|
1176
|
+
}),
|
|
1177
|
+
],
|
|
1178
|
+
})
|
|
1179
|
+
)
|
|
1180
|
+
.addNode('tools', node)
|
|
1181
|
+
.addEdge(START, 'agent')
|
|
1182
|
+
.addEdge('agent', 'tools')
|
|
1183
|
+
.addEdge('tools', END);
|
|
1184
|
+
const graph = builder.compile({ checkpointer: new MemorySaver() });
|
|
1185
|
+
|
|
1186
|
+
const { Run } = await import('@/run');
|
|
1187
|
+
const { HumanMessage } = await import('@langchain/core/messages');
|
|
1188
|
+
const run = await Run.create<t.IState>({
|
|
1189
|
+
runId: 'run-resume-update',
|
|
1190
|
+
graphConfig: {
|
|
1191
|
+
type: 'standard',
|
|
1192
|
+
agents: [
|
|
1193
|
+
{
|
|
1194
|
+
agentId: 'a',
|
|
1195
|
+
provider: providers.OPENAI,
|
|
1196
|
+
clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
|
|
1197
|
+
instructions: 'noop',
|
|
1198
|
+
maxContextTokens: 8000,
|
|
1199
|
+
},
|
|
1200
|
+
],
|
|
1201
|
+
},
|
|
1202
|
+
hooks: registry,
|
|
1203
|
+
humanInTheLoop: { enabled: true },
|
|
1204
|
+
});
|
|
1205
|
+
run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
|
|
1206
|
+
|
|
1207
|
+
const callerConfig = {
|
|
1208
|
+
configurable: { thread_id: 'run-resume-update-thread' },
|
|
1209
|
+
version: 'v2' as const,
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
await run.processStream({ messages: [] }, callerConfig);
|
|
1213
|
+
expect(run.getInterrupt()).toBeDefined();
|
|
1214
|
+
|
|
1215
|
+
const injected = new HumanMessage({ content: 'human-injected-on-resume' });
|
|
1216
|
+
await run.resume(
|
|
1217
|
+
{ [hexToolCallId]: { type: 'approve' } },
|
|
1218
|
+
callerConfig,
|
|
1219
|
+
undefined,
|
|
1220
|
+
{ update: { messages: [injected] } }
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
expect(run.getInterrupt()).toBeUndefined();
|
|
1224
|
+
|
|
1225
|
+
const state = await graph.getState(callerConfig);
|
|
1226
|
+
const contents = (state.values.messages as BaseMessage[]).map(
|
|
1227
|
+
(m) => m.content
|
|
1228
|
+
);
|
|
1229
|
+
/** Proves langgraph honored `update` on the INPUT resume Command. */
|
|
1230
|
+
expect(contents).toContain('human-injected-on-resume');
|
|
1231
|
+
/** Resume itself still completed: the approved tool produced its result. */
|
|
1232
|
+
expect(contents).toContain('host-result');
|
|
1233
|
+
});
|
|
1234
|
+
|
|
906
1235
|
it('Run.getHaltReason() reports prompt_denied when UserPromptSubmit denies the prompt', async () => {
|
|
907
1236
|
const registry = new HookRegistry();
|
|
908
1237
|
registry.register('UserPromptSubmit', {
|
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
|
+
};
|