@librechat/agents 3.3.13 → 3.4.1
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/Graph.cjs +121 -27
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +83 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +60 -14
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +2 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/index.cjs +35 -206
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +121 -241
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/google/index.cjs +19 -12
- package/dist/cjs/llm/google/index.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +2 -2
- package/dist/cjs/llm/mistral/index.cjs +26 -0
- package/dist/cjs/llm/mistral/index.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +82 -80
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/providers.cjs +19 -19
- package/dist/cjs/llm/providers.cjs.map +1 -1
- package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
- package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
- package/dist/cjs/llm/stream/smoother.cjs +369 -0
- package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
- package/dist/cjs/llm/vertexai/index.cjs +13 -1
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +25 -16
- package/dist/cjs/messages/format.cjs +40 -23
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/run.cjs +82 -15
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +37 -2
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/stream.cjs +1 -1
- package/dist/cjs/summarization/node.cjs +6 -3
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
- package/dist/cjs/tools/ToolNode.cjs +316 -94
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +664 -80
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
- package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/cjs/utils/tokens.cjs +76 -29
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +121 -27
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +83 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/executeHooks.mjs +60 -14
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +2 -1
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +34 -205
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +120 -240
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/google/index.mjs +19 -12
- package/dist/esm/llm/google/index.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +1 -1
- package/dist/esm/llm/invoke.mjs +2 -2
- package/dist/esm/llm/mistral/index.mjs +26 -0
- package/dist/esm/llm/mistral/index.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +82 -80
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/providers.mjs +3 -3
- package/dist/esm/llm/providers.mjs.map +1 -1
- package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
- package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
- package/dist/esm/llm/stream/smoother.mjs +365 -0
- package/dist/esm/llm/stream/smoother.mjs.map +1 -0
- package/dist/esm/llm/vertexai/index.mjs +13 -1
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +13 -11
- package/dist/esm/messages/format.mjs +40 -23
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/run.mjs +83 -16
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +37 -2
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/stream.mjs +1 -1
- package/dist/esm/summarization/node.mjs +6 -3
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
- package/dist/esm/tools/ToolNode.mjs +317 -95
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +665 -81
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
- package/dist/esm/tools/toolOutputReferences.mjs +20 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/esm/utils/tokens.mjs +76 -30
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +19 -1
- package/dist/types/hooks/HookRegistry.d.ts +10 -1
- package/dist/types/hooks/executeHooks.d.ts +5 -1
- package/dist/types/hooks/index.d.ts +2 -2
- package/dist/types/hooks/types.d.ts +12 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/llm/google/index.d.ts +2 -0
- package/dist/types/llm/mistral/index.d.ts +11 -0
- package/dist/types/llm/openai/index.d.ts +20 -4
- package/dist/types/llm/openrouter/index.d.ts +4 -1
- package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
- package/dist/types/llm/stream/smoother.d.ts +95 -0
- package/dist/types/llm/vertexai/index.d.ts +2 -0
- package/dist/types/run.d.ts +4 -1
- package/dist/types/session/AgentSession.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +23 -15
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +49 -7
- package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
- package/dist/types/tools/toolOutputReferences.d.ts +12 -0
- package/dist/types/types/graph.d.ts +5 -5
- package/dist/types/types/hitl.d.ts +15 -0
- package/dist/types/types/llm.d.ts +21 -14
- package/dist/types/utils/tokens.d.ts +10 -0
- package/package.json +6 -2
- package/src/graphs/Graph.ts +257 -52
- package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
- package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
- package/src/hooks/HookRegistry.ts +151 -1
- package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
- package/src/hooks/__tests__/executeHooks.test.ts +160 -0
- package/src/hooks/executeHooks.ts +133 -15
- package/src/hooks/index.ts +3 -1
- package/src/hooks/types.ts +16 -0
- package/src/index.ts +8 -0
- package/src/llm/anthropic/index.ts +85 -354
- package/src/llm/bedrock/index.ts +174 -390
- package/src/llm/bedrock/llm.spec.ts +2 -0
- package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
- package/src/llm/custom-chat-models.smoke.test.ts +16 -1
- package/src/llm/google/index.ts +17 -18
- package/src/llm/google/streamSmoothing.test.ts +121 -0
- package/src/llm/mistral/index.ts +33 -0
- package/src/llm/mistral/streamSmoothing.test.ts +97 -0
- package/src/llm/openai/deepseek.test.ts +56 -0
- package/src/llm/openai/index.ts +119 -126
- package/src/llm/openrouter/index.ts +4 -1
- package/src/llm/providers.ts +3 -3
- package/src/llm/stream/chunkAdapters.test.ts +202 -0
- package/src/llm/stream/chunkAdapters.ts +317 -0
- package/src/llm/stream/reassembly.test.ts +241 -0
- package/src/llm/stream/smoother.bench.test.ts +155 -0
- package/src/llm/stream/smoother.test.ts +519 -0
- package/src/llm/stream/smoother.ts +574 -0
- package/src/llm/vertexai/index.ts +17 -1
- package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
- package/src/messages/format.ts +54 -26
- package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
- package/src/messages/formatAgentMessages.steer.test.ts +16 -11
- package/src/messages/formatAgentMessages.test.ts +7 -2
- package/src/run.ts +164 -18
- package/src/session/AgentSession.ts +52 -2
- package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
- package/src/specs/subagent.test.ts +44 -0
- package/src/specs/tokens.test.ts +159 -14
- package/src/summarization/__tests__/node.test.ts +32 -14
- package/src/summarization/node.ts +15 -11
- package/src/tools/ToolNode.ts +631 -157
- package/src/tools/__tests__/SubagentExecutor.test.ts +590 -7
- package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
- package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
- package/src/tools/__tests__/hitl.test.ts +234 -5
- package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
- package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
- package/src/tools/subagent/SubagentExecutor.ts +1438 -136
- package/src/tools/subagent/SubagentReplay.ts +575 -0
- package/src/tools/toolOutputReferences.ts +40 -1
- package/src/types/graph.ts +5 -5
- package/src/types/hitl.ts +16 -0
- package/src/types/llm.ts +53 -36
- package/src/utils/tokens.ts +115 -30
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
1
3
|
import { HumanMessage } from '@langchain/core/messages';
|
|
4
|
+
import { MemorySaver, interrupt } from '@langchain/langgraph';
|
|
2
5
|
import { FakeListChatModel } from '@langchain/core/utils/testing';
|
|
3
6
|
import type { ToolCall } from '@langchain/core/messages/tool';
|
|
4
7
|
import type {
|
|
@@ -26,6 +29,7 @@ import { FakeChatModel } from '@/llm/fake';
|
|
|
26
29
|
import { Run } from '@/run';
|
|
27
30
|
|
|
28
31
|
const CHILD_RESPONSE = 'Hook test child response.';
|
|
32
|
+
const PRIMITIVE_INTERRUPT_TOOL_NAME = 'confirm_child';
|
|
29
33
|
|
|
30
34
|
const calculatorDef: t.LCTool = {
|
|
31
35
|
name: 'calculator',
|
|
@@ -39,6 +43,23 @@ const calculatorDef: t.LCTool = {
|
|
|
39
43
|
},
|
|
40
44
|
};
|
|
41
45
|
|
|
46
|
+
const referenceToolDefs: t.LCTool[] = [
|
|
47
|
+
{
|
|
48
|
+
name: 'produce_value',
|
|
49
|
+
description: 'Produce a value for a later tool.',
|
|
50
|
+
parameters: { type: 'object', properties: {} },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'consume_value',
|
|
54
|
+
description: 'Consume a value from an earlier tool.',
|
|
55
|
+
parameters: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: { value: { type: 'string' } },
|
|
58
|
+
required: ['value'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
42
63
|
const callerConfig = {
|
|
43
64
|
configurable: { thread_id: 'hook-test-thread' },
|
|
44
65
|
streamMode: 'values' as const,
|
|
@@ -47,14 +68,17 @@ const callerConfig = {
|
|
|
47
68
|
|
|
48
69
|
const originalGetChatModelClass = providers.getChatModelClass;
|
|
49
70
|
|
|
50
|
-
function makeSubagentToolCall(
|
|
71
|
+
function makeSubagentToolCall(
|
|
72
|
+
id = `call_sub_${Date.now()}`,
|
|
73
|
+
description = 'Test task for hook verification'
|
|
74
|
+
): ToolCall {
|
|
51
75
|
return {
|
|
52
76
|
name: Constants.SUBAGENT,
|
|
53
77
|
args: {
|
|
54
|
-
description
|
|
78
|
+
description,
|
|
55
79
|
subagent_type: 'researcher',
|
|
56
80
|
},
|
|
57
|
-
id
|
|
81
|
+
id,
|
|
58
82
|
type: 'tool_call',
|
|
59
83
|
};
|
|
60
84
|
}
|
|
@@ -108,6 +132,98 @@ function createParentAgentWithChildTool(): t.AgentInputs {
|
|
|
108
132
|
};
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
function createParentAgentWithPrimitiveInterruptTool(
|
|
136
|
+
primitiveInterruptTool: t.GenericTool
|
|
137
|
+
): t.AgentInputs {
|
|
138
|
+
const parent = createParentAgent();
|
|
139
|
+
const child = parent.subagentConfigs?.[0];
|
|
140
|
+
const childAgent = child?.agentInputs;
|
|
141
|
+
if (child == null || childAgent == null) {
|
|
142
|
+
throw new Error('Expected a child agent configuration.');
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...parent,
|
|
146
|
+
subagentConfigs: [
|
|
147
|
+
{
|
|
148
|
+
...child,
|
|
149
|
+
agentInputs: {
|
|
150
|
+
...childAgent,
|
|
151
|
+
instructions: 'Request confirmation, then answer concisely.',
|
|
152
|
+
graphTools: [primitiveInterruptTool],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createParentAgentWithNestedChildTool(): t.AgentInputs {
|
|
160
|
+
return {
|
|
161
|
+
agentId: 'nested-hook-parent',
|
|
162
|
+
provider: Providers.OPENAI,
|
|
163
|
+
clientOptions: { modelName: 'parent-model', apiKey: 'test-key' },
|
|
164
|
+
instructions: 'Delegate nested calculations.',
|
|
165
|
+
maxContextTokens: 8000,
|
|
166
|
+
maxSubagentDepth: 2,
|
|
167
|
+
subagentConfigs: [
|
|
168
|
+
{
|
|
169
|
+
type: 'researcher',
|
|
170
|
+
name: 'Researcher',
|
|
171
|
+
description: 'Delegates calculations',
|
|
172
|
+
allowNested: true,
|
|
173
|
+
agentInputs: {
|
|
174
|
+
agentId: 'nested-researcher-child',
|
|
175
|
+
provider: Providers.OPENAI,
|
|
176
|
+
clientOptions: {
|
|
177
|
+
modelName: 'nested-child-model',
|
|
178
|
+
apiKey: 'test-key',
|
|
179
|
+
},
|
|
180
|
+
instructions: 'Delegate arithmetic to the calculator worker.',
|
|
181
|
+
maxContextTokens: 8000,
|
|
182
|
+
subagentConfigs: [
|
|
183
|
+
{
|
|
184
|
+
type: 'calculator-worker',
|
|
185
|
+
name: 'Calculator Worker',
|
|
186
|
+
description: 'Runs calculator tools',
|
|
187
|
+
agentInputs: {
|
|
188
|
+
agentId: 'calculator-grandchild',
|
|
189
|
+
provider: Providers.OPENAI,
|
|
190
|
+
clientOptions: {
|
|
191
|
+
modelName: 'grandchild-model',
|
|
192
|
+
apiKey: 'test-key',
|
|
193
|
+
},
|
|
194
|
+
instructions: 'Use calculator, then answer.',
|
|
195
|
+
maxContextTokens: 8000,
|
|
196
|
+
toolDefinitions: [calculatorDef],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createParentAgentWithReferenceTools(): t.AgentInputs {
|
|
207
|
+
const parent = createParentAgentWithChildTool();
|
|
208
|
+
const child = parent.subagentConfigs?.[0];
|
|
209
|
+
const childAgent = child?.agentInputs;
|
|
210
|
+
if (child == null || childAgent == null) {
|
|
211
|
+
throw new Error('Expected a child agent configuration.');
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
...parent,
|
|
215
|
+
subagentConfigs: [
|
|
216
|
+
{
|
|
217
|
+
...child,
|
|
218
|
+
agentInputs: {
|
|
219
|
+
...childAgent,
|
|
220
|
+
toolDefinitions: referenceToolDefs,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
111
227
|
function createCalculatorToolCall(): ToolCall {
|
|
112
228
|
return {
|
|
113
229
|
name: 'calculator',
|
|
@@ -117,6 +233,163 @@ function createCalculatorToolCall(): ToolCall {
|
|
|
117
233
|
};
|
|
118
234
|
}
|
|
119
235
|
|
|
236
|
+
class HitlChildFakeChatModel extends FakeChatModel {
|
|
237
|
+
constructor(_options: object) {
|
|
238
|
+
super({ responses: [CHILD_RESPONSE], sleep: 1 });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_streamResponseChunks(
|
|
242
|
+
messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
|
|
243
|
+
options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
|
|
244
|
+
runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
|
|
245
|
+
): ReturnType<FakeChatModel['_streamResponseChunks']> {
|
|
246
|
+
const hasToolResult = messages.some(
|
|
247
|
+
(message) => message._getType() === 'tool'
|
|
248
|
+
);
|
|
249
|
+
return new FakeChatModel({
|
|
250
|
+
responses: [hasToolResult ? CHILD_RESPONSE : 'Using calculator.'],
|
|
251
|
+
sleep: 1,
|
|
252
|
+
toolCalls: hasToolResult ? [] : [createCalculatorToolCall()],
|
|
253
|
+
})._streamResponseChunks(messages, options, runManager);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
|
|
257
|
+
const config = {
|
|
258
|
+
tools,
|
|
259
|
+
} as Parameters<FakeChatModel['withConfig']>[0];
|
|
260
|
+
return this.withConfig(config);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
class PrimitiveInterruptFakeChatModel extends FakeChatModel {
|
|
265
|
+
constructor(_options: object) {
|
|
266
|
+
super({ responses: [CHILD_RESPONSE], sleep: 1 });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_streamResponseChunks(
|
|
270
|
+
messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
|
|
271
|
+
options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
|
|
272
|
+
runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
|
|
273
|
+
): ReturnType<FakeChatModel['_streamResponseChunks']> {
|
|
274
|
+
const hasToolResult = messages.some(
|
|
275
|
+
(message) => message._getType() === 'tool'
|
|
276
|
+
);
|
|
277
|
+
return new FakeChatModel({
|
|
278
|
+
responses: [hasToolResult ? CHILD_RESPONSE : 'Requesting confirmation.'],
|
|
279
|
+
sleep: 1,
|
|
280
|
+
toolCalls: hasToolResult
|
|
281
|
+
? []
|
|
282
|
+
: [
|
|
283
|
+
{
|
|
284
|
+
name: PRIMITIVE_INTERRUPT_TOOL_NAME,
|
|
285
|
+
args: {},
|
|
286
|
+
id: 'call_child_primitive_interrupt',
|
|
287
|
+
type: 'tool_call',
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
})._streamResponseChunks(messages, options, runManager);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
|
|
294
|
+
return this.withConfig({ tools } as Parameters<
|
|
295
|
+
FakeChatModel['withConfig']
|
|
296
|
+
>[0]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
class NestedHitlFakeChatModel extends FakeChatModel {
|
|
301
|
+
private readonly nestedChild: boolean;
|
|
302
|
+
|
|
303
|
+
constructor(options: { modelName?: string }) {
|
|
304
|
+
super({ responses: [CHILD_RESPONSE], sleep: 1 });
|
|
305
|
+
this.nestedChild = options.modelName === 'nested-child-model';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_streamResponseChunks(
|
|
309
|
+
messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
|
|
310
|
+
options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
|
|
311
|
+
runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
|
|
312
|
+
): ReturnType<FakeChatModel['_streamResponseChunks']> {
|
|
313
|
+
const hasToolResult = messages.some(
|
|
314
|
+
(message) => message._getType() === 'tool'
|
|
315
|
+
);
|
|
316
|
+
let toolCalls: ToolCall[] = [];
|
|
317
|
+
if (!hasToolResult && this.nestedChild) {
|
|
318
|
+
toolCalls = [
|
|
319
|
+
{
|
|
320
|
+
name: Constants.SUBAGENT,
|
|
321
|
+
args: {
|
|
322
|
+
description: 'Calculate the result',
|
|
323
|
+
subagent_type: 'calculator-worker',
|
|
324
|
+
},
|
|
325
|
+
id: 'call_nested_calculator_worker',
|
|
326
|
+
type: 'tool_call',
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
} else if (!hasToolResult) {
|
|
330
|
+
toolCalls = [createCalculatorToolCall()];
|
|
331
|
+
}
|
|
332
|
+
return new FakeChatModel({
|
|
333
|
+
responses: [hasToolResult ? CHILD_RESPONSE : 'Delegating calculation.'],
|
|
334
|
+
sleep: 1,
|
|
335
|
+
toolCalls,
|
|
336
|
+
})._streamResponseChunks(messages, options, runManager);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
|
|
340
|
+
return this.withConfig({ tools } as Parameters<
|
|
341
|
+
FakeChatModel['withConfig']
|
|
342
|
+
>[0]);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
class ReferenceHitlFakeChatModel extends FakeChatModel {
|
|
347
|
+
constructor(_options: object) {
|
|
348
|
+
super({ responses: [CHILD_RESPONSE], sleep: 1 });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_streamResponseChunks(
|
|
352
|
+
messages: Parameters<FakeChatModel['_streamResponseChunks']>[0],
|
|
353
|
+
options: Parameters<FakeChatModel['_streamResponseChunks']>[1],
|
|
354
|
+
runManager?: Parameters<FakeChatModel['_streamResponseChunks']>[2]
|
|
355
|
+
): ReturnType<FakeChatModel['_streamResponseChunks']> {
|
|
356
|
+
const toolResultCount = messages.filter(
|
|
357
|
+
(message) => message._getType() === 'tool'
|
|
358
|
+
).length;
|
|
359
|
+
let toolCalls: ToolCall[] = [];
|
|
360
|
+
if (toolResultCount === 0) {
|
|
361
|
+
toolCalls = [
|
|
362
|
+
{
|
|
363
|
+
name: 'produce_value',
|
|
364
|
+
args: {},
|
|
365
|
+
id: 'call_produce_value',
|
|
366
|
+
type: 'tool_call',
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
} else if (toolResultCount === 1) {
|
|
370
|
+
toolCalls = [
|
|
371
|
+
{
|
|
372
|
+
name: 'consume_value',
|
|
373
|
+
args: { value: '{{tool0turn0}}' },
|
|
374
|
+
id: 'call_consume_value',
|
|
375
|
+
type: 'tool_call',
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
return new FakeChatModel({
|
|
380
|
+
responses: [toolCalls.length > 0 ? 'Using a tool.' : CHILD_RESPONSE],
|
|
381
|
+
sleep: 1,
|
|
382
|
+
toolCalls,
|
|
383
|
+
})._streamResponseChunks(messages, options, runManager);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
|
|
387
|
+
return this.withConfig({ tools } as Parameters<
|
|
388
|
+
FakeChatModel['withConfig']
|
|
389
|
+
>[0]);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
120
393
|
async function createSubagentRun(
|
|
121
394
|
hooks: HookRegistry,
|
|
122
395
|
runId = `subagent-hook-${Date.now()}`
|
|
@@ -489,54 +762,425 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
|
|
|
489
762
|
expect(dispatchAgentIds).toEqual(['hook-parent']);
|
|
490
763
|
});
|
|
491
764
|
|
|
492
|
-
it(
|
|
765
|
+
it.each([
|
|
766
|
+
{
|
|
767
|
+
label: 'approve',
|
|
768
|
+
resumeDecision: { type: 'approve' } as const,
|
|
769
|
+
shouldExecute: true,
|
|
770
|
+
deniedReason: undefined,
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
label: 'reject',
|
|
774
|
+
resumeDecision: {
|
|
775
|
+
type: 'reject',
|
|
776
|
+
reason: 'host rejected child tool',
|
|
777
|
+
} as const,
|
|
778
|
+
shouldExecute: false,
|
|
779
|
+
deniedReason: 'host rejected child tool',
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
label: 'deny',
|
|
783
|
+
resumeDecision: undefined,
|
|
784
|
+
shouldExecute: false,
|
|
785
|
+
deniedReason: 'policy denied child tool',
|
|
786
|
+
},
|
|
787
|
+
])(
|
|
788
|
+
'handles a child subagent tool $label through the parent Run',
|
|
789
|
+
async ({ resumeDecision, shouldExecute, deniedReason }) => {
|
|
790
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
791
|
+
if (provider === Providers.OPENAI) {
|
|
792
|
+
return HitlChildFakeChatModel;
|
|
793
|
+
}
|
|
794
|
+
return originalGetChatModelClass(provider);
|
|
795
|
+
}) as typeof providers.getChatModelClass);
|
|
796
|
+
|
|
797
|
+
const registry = new HookRegistry();
|
|
798
|
+
const deniedTools: string[] = [];
|
|
799
|
+
const executedTools: string[] = [];
|
|
800
|
+
let calculatorPreToolCalls = 0;
|
|
801
|
+
let calculatorPostToolCalls = 0;
|
|
802
|
+
|
|
803
|
+
const preHook: HookCallback<'PreToolUse'> = async (
|
|
804
|
+
input
|
|
805
|
+
): Promise<PreToolUseHookOutput> => {
|
|
806
|
+
if (input.toolName === 'calculator') {
|
|
807
|
+
calculatorPreToolCalls += 1;
|
|
808
|
+
if (resumeDecision == null) {
|
|
809
|
+
return {
|
|
810
|
+
decision: 'deny',
|
|
811
|
+
reason: 'policy denied child tool',
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
return { decision: 'ask', reason: 'review calculator' };
|
|
815
|
+
}
|
|
816
|
+
return { decision: 'allow' };
|
|
817
|
+
};
|
|
818
|
+
registry.register('PreToolUse', { hooks: [preHook] });
|
|
819
|
+
registry.register('PostToolUse', {
|
|
820
|
+
hooks: [
|
|
821
|
+
async (input): Promise<PostToolUseHookOutput> => {
|
|
822
|
+
if (input.toolName === 'calculator') {
|
|
823
|
+
calculatorPostToolCalls += 1;
|
|
824
|
+
}
|
|
825
|
+
return {};
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const deniedHook: HookCallback<'PermissionDenied'> = async (
|
|
831
|
+
input
|
|
832
|
+
): Promise<PermissionDeniedHookOutput> => {
|
|
833
|
+
deniedTools.push(
|
|
834
|
+
`${input.agentId ?? '-'}:${input.toolName}:${input.reason}`
|
|
835
|
+
);
|
|
836
|
+
return {};
|
|
837
|
+
};
|
|
838
|
+
registry.register('PermissionDenied', { hooks: [deniedHook] });
|
|
839
|
+
|
|
840
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
841
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
842
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
843
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
844
|
+
handle: (_event, rawData): void => {
|
|
845
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
846
|
+
executedTools.push(...request.toolCalls.map((call) => call.name));
|
|
847
|
+
const results: t.ToolExecuteResult[] = request.toolCalls.map(
|
|
848
|
+
(call) => ({
|
|
849
|
+
toolCallId: call.id,
|
|
850
|
+
status: 'success',
|
|
851
|
+
content: '42',
|
|
852
|
+
})
|
|
853
|
+
);
|
|
854
|
+
request.resolve(results);
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const run = await Run.create<t.IState>({
|
|
860
|
+
runId: `subagent-tool-ask-${Date.now()}`,
|
|
861
|
+
graphConfig: {
|
|
862
|
+
type: 'standard',
|
|
863
|
+
agents: [createParentAgentWithChildTool()],
|
|
864
|
+
},
|
|
865
|
+
returnContent: true,
|
|
866
|
+
skipCleanup: true,
|
|
867
|
+
customHandlers,
|
|
868
|
+
hooks: registry,
|
|
869
|
+
humanInTheLoop: { enabled: true },
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
const tc = makeSubagentToolCall();
|
|
873
|
+
run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
|
|
874
|
+
|
|
875
|
+
await run.processStream(
|
|
876
|
+
{ messages: [new HumanMessage('calculate something')] },
|
|
877
|
+
callerConfig
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
if (resumeDecision == null) {
|
|
881
|
+
expect(run.getInterrupt()).toBeUndefined();
|
|
882
|
+
expect(calculatorPreToolCalls).toBe(1);
|
|
883
|
+
expect(executedTools).not.toContain('calculator');
|
|
884
|
+
expect(deniedTools).toEqual([
|
|
885
|
+
`researcher-child:calculator:${deniedReason}`,
|
|
886
|
+
]);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const pending = run.getInterrupt();
|
|
891
|
+
expect(pending?.payload).toMatchObject({
|
|
892
|
+
type: 'tool_approval',
|
|
893
|
+
subagent: {
|
|
894
|
+
agent_id: 'researcher-child',
|
|
895
|
+
parent_tool_call_id: tc.id,
|
|
896
|
+
subagent_type: 'researcher',
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
expect(executedTools).not.toContain('calculator');
|
|
900
|
+
|
|
901
|
+
await run.resume([resumeDecision], callerConfig);
|
|
902
|
+
|
|
903
|
+
expect(run.getInterrupt()).toBeUndefined();
|
|
904
|
+
expect(calculatorPreToolCalls).toBe(2);
|
|
905
|
+
expect(
|
|
906
|
+
executedTools.filter((name) => name === 'calculator')
|
|
907
|
+
).toHaveLength(shouldExecute ? 1 : 0);
|
|
908
|
+
expect(calculatorPostToolCalls).toBe(shouldExecute ? 1 : 0);
|
|
909
|
+
expect(deniedTools).toEqual(
|
|
910
|
+
shouldExecute ? [] : [`researcher-child:calculator:${deniedReason}`]
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
it('resumes approvals across multiple subagents and keeps updates sanitized', async () => {
|
|
493
916
|
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
494
917
|
if (provider === Providers.OPENAI) {
|
|
495
|
-
return
|
|
496
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
497
|
-
constructor(_options: any) {
|
|
498
|
-
super({
|
|
499
|
-
responses: ['Using calculator.', CHILD_RESPONSE],
|
|
500
|
-
sleep: 1,
|
|
501
|
-
toolCalls: [createCalculatorToolCall()],
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
|
|
505
|
-
const config = {
|
|
506
|
-
tools,
|
|
507
|
-
} as Parameters<FakeChatModel['withConfig']>[0];
|
|
508
|
-
return this.withConfig(config);
|
|
509
|
-
}
|
|
510
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
511
|
-
} as any;
|
|
918
|
+
return HitlChildFakeChatModel;
|
|
512
919
|
}
|
|
513
920
|
return originalGetChatModelClass(provider);
|
|
514
921
|
}) as typeof providers.getChatModelClass);
|
|
515
922
|
|
|
516
923
|
const registry = new HookRegistry();
|
|
517
|
-
const
|
|
518
|
-
const
|
|
924
|
+
const deniedToolIds: string[] = [];
|
|
925
|
+
const executedToolIds: string[] = [];
|
|
926
|
+
const updates: t.SubagentUpdateEvent[] = [];
|
|
927
|
+
const completedSubagentCalls: string[] = [];
|
|
519
928
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
929
|
+
registry.register('PreToolUse', {
|
|
930
|
+
hooks: [
|
|
931
|
+
async (input): Promise<PreToolUseHookOutput> =>
|
|
932
|
+
input.toolName === 'calculator'
|
|
933
|
+
? { decision: 'ask', reason: 'review child calculator' }
|
|
934
|
+
: { decision: 'allow' },
|
|
935
|
+
],
|
|
936
|
+
});
|
|
937
|
+
registry.register('PostToolUse', {
|
|
938
|
+
hooks: [
|
|
939
|
+
async (input): Promise<PostToolUseHookOutput> => {
|
|
940
|
+
if (input.toolName === Constants.SUBAGENT) {
|
|
941
|
+
completedSubagentCalls.push(input.toolUseId);
|
|
942
|
+
return { additionalContext: `context:${input.toolUseId}` };
|
|
943
|
+
}
|
|
944
|
+
return {};
|
|
945
|
+
},
|
|
946
|
+
],
|
|
947
|
+
});
|
|
948
|
+
registry.register('PermissionDenied', {
|
|
949
|
+
hooks: [
|
|
950
|
+
async (input): Promise<PermissionDeniedHookOutput> => {
|
|
951
|
+
deniedToolIds.push(input.toolUseId);
|
|
952
|
+
return {};
|
|
953
|
+
},
|
|
954
|
+
],
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
958
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
959
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
960
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
961
|
+
handle: (_event, rawData): void => {
|
|
962
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
963
|
+
executedToolIds.push(...request.toolCalls.map((call) => call.id));
|
|
964
|
+
request.resolve(
|
|
965
|
+
request.toolCalls.map((call) => ({
|
|
966
|
+
toolCallId: call.id,
|
|
967
|
+
status: 'success' as const,
|
|
968
|
+
content: '42',
|
|
969
|
+
}))
|
|
970
|
+
);
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
[GraphEvents.ON_SUBAGENT_UPDATE]: {
|
|
974
|
+
handle: (_event, data): void => {
|
|
975
|
+
updates.push(data as t.SubagentUpdateEvent);
|
|
976
|
+
},
|
|
977
|
+
},
|
|
527
978
|
};
|
|
528
|
-
registry.register('PreToolUse', { hooks: [preHook] });
|
|
529
979
|
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
): Promise<
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
980
|
+
const checkpointer = new MemorySaver();
|
|
981
|
+
const baseRunId = `subagent-multi-hitl-${Date.now()}`;
|
|
982
|
+
const createRun = (runId: string): Promise<Run<t.IState>> =>
|
|
983
|
+
Run.create<t.IState>({
|
|
984
|
+
runId,
|
|
985
|
+
graphConfig: {
|
|
986
|
+
type: 'standard',
|
|
987
|
+
agents: [createParentAgentWithChildTool()],
|
|
988
|
+
compileOptions: { checkpointer, interruptBefore: [] },
|
|
989
|
+
},
|
|
990
|
+
returnContent: true,
|
|
991
|
+
skipCleanup: true,
|
|
992
|
+
customHandlers,
|
|
993
|
+
hooks: registry,
|
|
994
|
+
humanInTheLoop: { enabled: true },
|
|
995
|
+
});
|
|
996
|
+
const run = await createRun(`${baseRunId}-initial`);
|
|
997
|
+
const first = makeSubagentToolCall(
|
|
998
|
+
'call_sub_first',
|
|
999
|
+
'Run the first calculation'
|
|
1000
|
+
);
|
|
1001
|
+
const second = makeSubagentToolCall(
|
|
1002
|
+
'call_sub_second',
|
|
1003
|
+
'Run the second calculation'
|
|
1004
|
+
);
|
|
1005
|
+
run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1006
|
+
first,
|
|
1007
|
+
second,
|
|
1008
|
+
]);
|
|
1009
|
+
const multiCallerConfig = {
|
|
1010
|
+
...callerConfig,
|
|
1011
|
+
configurable: {
|
|
1012
|
+
thread_id: 'multi-subagent-hitl-thread',
|
|
1013
|
+
access_token: 'must-not-leak',
|
|
1014
|
+
requestBody: { currentTaskInput: 'must-not-leak-task' },
|
|
1015
|
+
userMCPAuthMap: { private: { token: 'must-not-leak-mcp' } },
|
|
1016
|
+
},
|
|
537
1017
|
};
|
|
538
|
-
registry.register('PermissionDenied', { hooks: [deniedHook] });
|
|
539
1018
|
|
|
1019
|
+
await run.processStream(
|
|
1020
|
+
{ messages: [new HumanMessage('calculate twice')] },
|
|
1021
|
+
multiCallerConfig
|
|
1022
|
+
);
|
|
1023
|
+
expect(run.getInterrupt()?.payload).toMatchObject({
|
|
1024
|
+
type: 'tool_approval',
|
|
1025
|
+
subagent: { parent_tool_call_id: first.id },
|
|
1026
|
+
});
|
|
1027
|
+
expect(updates.some((event) => event.parentToolCallId === second.id)).toBe(
|
|
1028
|
+
true
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
await run.resume([{ type: 'approve' }], multiCallerConfig);
|
|
1032
|
+
expect(run.getInterrupt()?.payload).toMatchObject({
|
|
1033
|
+
type: 'tool_approval',
|
|
1034
|
+
subagent: { parent_tool_call_id: second.id },
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
|
|
1038
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1039
|
+
await rebuiltRun.resume(
|
|
1040
|
+
[{ type: 'reject', reason: 'reject second child' }],
|
|
1041
|
+
multiCallerConfig
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1045
|
+
expect(executedToolIds).toHaveLength(1);
|
|
1046
|
+
expect(deniedToolIds).toHaveLength(1);
|
|
1047
|
+
expect(completedSubagentCalls).toEqual([first.id, second.id]);
|
|
1048
|
+
const firstPhases = updates
|
|
1049
|
+
.filter((event) => event.parentToolCallId === first.id)
|
|
1050
|
+
.map((event) => event.phase);
|
|
1051
|
+
const secondPhases = updates
|
|
1052
|
+
.filter((event) => event.parentToolCallId === second.id)
|
|
1053
|
+
.map((event) => event.phase);
|
|
1054
|
+
expect(firstPhases[0]).toBe('start');
|
|
1055
|
+
expect(firstPhases[firstPhases.length - 1]).toBe('stop');
|
|
1056
|
+
expect(secondPhases[0]).toBe('start');
|
|
1057
|
+
expect(secondPhases[secondPhases.length - 1]).toBe('stop');
|
|
1058
|
+
const serializedUpdates = JSON.stringify(updates);
|
|
1059
|
+
expect(serializedUpdates).not.toContain('must-not-leak');
|
|
1060
|
+
expect(serializedUpdates).not.toContain('currentTaskInput');
|
|
1061
|
+
expect(serializedUpdates).not.toContain('userMCPAuthMap');
|
|
1062
|
+
expect(serializedUpdates).not.toContain('checkpoint_');
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('resumes a primitive child interrupt through a rebuilt Run', async () => {
|
|
1066
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1067
|
+
if (provider === Providers.OPENAI) {
|
|
1068
|
+
return PrimitiveInterruptFakeChatModel;
|
|
1069
|
+
}
|
|
1070
|
+
return originalGetChatModelClass(provider);
|
|
1071
|
+
}) as typeof providers.getChatModelClass);
|
|
1072
|
+
|
|
1073
|
+
const checkpointer = new MemorySaver();
|
|
1074
|
+
const resumedValues: string[] = [];
|
|
1075
|
+
const primitiveInterruptTool = tool(
|
|
1076
|
+
async () => {
|
|
1077
|
+
const resumeValue = interrupt<string, string>('confirm child');
|
|
1078
|
+
resumedValues.push(resumeValue);
|
|
1079
|
+
return `confirmed:${resumeValue}`;
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
name: PRIMITIVE_INTERRUPT_TOOL_NAME,
|
|
1083
|
+
description: 'Pause for primitive confirmation.',
|
|
1084
|
+
schema: z.object({}),
|
|
1085
|
+
}
|
|
1086
|
+
);
|
|
1087
|
+
const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
|
|
1088
|
+
Run.create<t.IState>({
|
|
1089
|
+
runId: currentRunId,
|
|
1090
|
+
graphConfig: {
|
|
1091
|
+
type: 'standard',
|
|
1092
|
+
agents: [
|
|
1093
|
+
createParentAgentWithPrimitiveInterruptTool(primitiveInterruptTool),
|
|
1094
|
+
],
|
|
1095
|
+
compileOptions: { checkpointer },
|
|
1096
|
+
},
|
|
1097
|
+
returnContent: true,
|
|
1098
|
+
skipCleanup: true,
|
|
1099
|
+
customHandlers: {
|
|
1100
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
1101
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
1102
|
+
},
|
|
1103
|
+
humanInTheLoop: { enabled: true },
|
|
1104
|
+
});
|
|
1105
|
+
const runId = `primitive-subagent-rebuild-${Date.now()}`;
|
|
1106
|
+
const parentCall = makeSubagentToolCall('call_primitive_rebuild');
|
|
1107
|
+
const initialRun = await createRun(`${runId}-initial`);
|
|
1108
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1109
|
+
parentCall,
|
|
1110
|
+
]);
|
|
1111
|
+
|
|
1112
|
+
await initialRun.processStream(
|
|
1113
|
+
{ messages: [new HumanMessage('confirm inside the child')] },
|
|
1114
|
+
callerConfig
|
|
1115
|
+
);
|
|
1116
|
+
const paused = initialRun.getInterrupt<string>();
|
|
1117
|
+
expect(paused?.payload).toBe('confirm child');
|
|
1118
|
+
expect(JSON.stringify(paused)).not.toContain(
|
|
1119
|
+
'__librechat_subagent_resume_manifest'
|
|
1120
|
+
);
|
|
1121
|
+
if (paused?.checkpointId == null) {
|
|
1122
|
+
throw new Error('Expected a persisted parent checkpoint.');
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const rebuiltRun = await createRun(`${runId}-rebuilt`);
|
|
1126
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1127
|
+
const injected = new HumanMessage('host edit on resume');
|
|
1128
|
+
const rebuiltContent = await rebuiltRun.resume<string>(
|
|
1129
|
+
'approved after restart',
|
|
1130
|
+
{
|
|
1131
|
+
...callerConfig,
|
|
1132
|
+
configurable: {
|
|
1133
|
+
...callerConfig.configurable,
|
|
1134
|
+
checkpoint_id: paused.checkpointId,
|
|
1135
|
+
checkpoint_ns: paused.checkpointNs ?? '',
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
1138
|
+
undefined,
|
|
1139
|
+
{ update: { messages: [injected] } }
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1143
|
+
expect(resumedValues).toEqual(['approved after restart']);
|
|
1144
|
+
expect(JSON.stringify(rebuiltContent)).toContain('Final answer.');
|
|
1145
|
+
expect(JSON.stringify(rebuiltContent)).not.toContain('host edit on resume');
|
|
1146
|
+
expect(
|
|
1147
|
+
rebuiltRun
|
|
1148
|
+
.getRunMessages()
|
|
1149
|
+
?.some(
|
|
1150
|
+
(message) =>
|
|
1151
|
+
message._getType() === 'ai' &&
|
|
1152
|
+
JSON.stringify(message.content).includes('Final answer.')
|
|
1153
|
+
)
|
|
1154
|
+
).toBe(true);
|
|
1155
|
+
expect(
|
|
1156
|
+
rebuiltRun.getRunMessages()?.some((message) => message.id === injected.id)
|
|
1157
|
+
).toBe(false);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('forks every resume from the paused child snapshot', async () => {
|
|
1161
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1162
|
+
if (provider === Providers.OPENAI) {
|
|
1163
|
+
return HitlChildFakeChatModel;
|
|
1164
|
+
}
|
|
1165
|
+
return originalGetChatModelClass(provider);
|
|
1166
|
+
}) as typeof providers.getChatModelClass);
|
|
1167
|
+
|
|
1168
|
+
const checkpointer = new MemorySaver();
|
|
1169
|
+
const registry = new HookRegistry();
|
|
1170
|
+
const executedTools: string[] = [];
|
|
1171
|
+
const updates: t.SubagentUpdateEvent[] = [];
|
|
1172
|
+
let approvalHookCalls = 0;
|
|
1173
|
+
const runId = `subagent-rebuild-hitl-${Date.now()}`;
|
|
1174
|
+
registry.registerSession(`${runId}-initial`, 'PreToolUse', {
|
|
1175
|
+
once: true,
|
|
1176
|
+
pattern: '^calculator$',
|
|
1177
|
+
hooks: [
|
|
1178
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
1179
|
+
approvalHookCalls += 1;
|
|
1180
|
+
return { decision: 'ask', reason: 'review calculator' };
|
|
1181
|
+
},
|
|
1182
|
+
],
|
|
1183
|
+
});
|
|
540
1184
|
const customHandlers: Record<string, t.EventHandler> = {
|
|
541
1185
|
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
542
1186
|
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
@@ -544,43 +1188,377 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
|
|
|
544
1188
|
handle: (_event, rawData): void => {
|
|
545
1189
|
const request = rawData as t.ToolExecuteBatchRequest;
|
|
546
1190
|
executedTools.push(...request.toolCalls.map((call) => call.name));
|
|
547
|
-
|
|
548
|
-
(call) => ({
|
|
1191
|
+
request.resolve(
|
|
1192
|
+
request.toolCalls.map((call) => ({
|
|
549
1193
|
toolCallId: call.id,
|
|
550
|
-
status: 'success',
|
|
1194
|
+
status: 'success' as const,
|
|
551
1195
|
content: '42',
|
|
552
|
-
})
|
|
1196
|
+
}))
|
|
553
1197
|
);
|
|
554
|
-
|
|
1198
|
+
},
|
|
1199
|
+
},
|
|
1200
|
+
[GraphEvents.ON_SUBAGENT_UPDATE]: {
|
|
1201
|
+
handle: (_event, data): void => {
|
|
1202
|
+
updates.push(data as t.SubagentUpdateEvent);
|
|
555
1203
|
},
|
|
556
1204
|
},
|
|
557
1205
|
};
|
|
1206
|
+
const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
|
|
1207
|
+
Run.create<t.IState>({
|
|
1208
|
+
runId: currentRunId,
|
|
1209
|
+
graphConfig: {
|
|
1210
|
+
type: 'standard',
|
|
1211
|
+
agents: [createParentAgentWithChildTool()],
|
|
1212
|
+
compileOptions: { checkpointer },
|
|
1213
|
+
},
|
|
1214
|
+
returnContent: true,
|
|
1215
|
+
skipCleanup: true,
|
|
1216
|
+
customHandlers,
|
|
1217
|
+
hooks: registry,
|
|
1218
|
+
humanInTheLoop: { enabled: true },
|
|
1219
|
+
});
|
|
1220
|
+
const tc = makeSubagentToolCall('call_sub_rebuild');
|
|
1221
|
+
const initialRun = await createRun(`${runId}-initial`);
|
|
1222
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1223
|
+
tc,
|
|
1224
|
+
]);
|
|
558
1225
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1226
|
+
await initialRun.processStream(
|
|
1227
|
+
{ messages: [new HumanMessage('calculate after restart')] },
|
|
1228
|
+
callerConfig
|
|
1229
|
+
);
|
|
1230
|
+
const persistedInterrupt = initialRun.getInterrupt();
|
|
1231
|
+
expect(persistedInterrupt?.payload).toMatchObject({
|
|
1232
|
+
type: 'tool_approval',
|
|
1233
|
+
subagent: { parent_tool_call_id: tc.id },
|
|
1234
|
+
});
|
|
1235
|
+
expect(JSON.stringify(persistedInterrupt)).not.toContain(
|
|
1236
|
+
'__librechat_subagent_resume_manifest'
|
|
1237
|
+
);
|
|
1238
|
+
expect(approvalHookCalls).toBe(1);
|
|
1239
|
+
const sourceChildRunId =
|
|
1240
|
+
persistedInterrupt?.payload.type === 'tool_approval'
|
|
1241
|
+
? persistedInterrupt.payload.subagent?.run_id
|
|
1242
|
+
: undefined;
|
|
1243
|
+
expect(sourceChildRunId).toBeDefined();
|
|
1244
|
+
const branchConfig = {
|
|
1245
|
+
...callerConfig,
|
|
1246
|
+
configurable: {
|
|
1247
|
+
...callerConfig.configurable,
|
|
1248
|
+
checkpoint_id: persistedInterrupt?.checkpointId,
|
|
1249
|
+
checkpoint_ns: persistedInterrupt?.checkpointNs ?? '',
|
|
564
1250
|
},
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1251
|
+
};
|
|
1252
|
+
const earlyApprovedRun = await createRun(`${runId}-early-approved`);
|
|
1253
|
+
const sharedRebuiltRunId = `${runId}-same-rebuilt-id`;
|
|
1254
|
+
const approvedRun = await createRun(sharedRebuiltRunId);
|
|
1255
|
+
const rejectedRun = await createRun(sharedRebuiltRunId);
|
|
1256
|
+
let armRestoredCheckpointBarrier = false;
|
|
1257
|
+
let blockNextRestoredCheckpointRead = false;
|
|
1258
|
+
const originalRestorePendingToolApprovals =
|
|
1259
|
+
registry.restorePendingToolApprovals.bind(registry);
|
|
1260
|
+
const restoreApprovalSpy = jest
|
|
1261
|
+
.spyOn(registry, 'restorePendingToolApprovals')
|
|
1262
|
+
.mockImplementation((sessionId, targetExecutionScope, snapshots) => {
|
|
1263
|
+
originalRestorePendingToolApprovals(
|
|
1264
|
+
sessionId,
|
|
1265
|
+
targetExecutionScope,
|
|
1266
|
+
snapshots
|
|
1267
|
+
);
|
|
1268
|
+
if (armRestoredCheckpointBarrier) {
|
|
1269
|
+
armRestoredCheckpointBarrier = false;
|
|
1270
|
+
blockNextRestoredCheckpointRead = true;
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
let markCheckpointReadBlocked = (): void => undefined;
|
|
1274
|
+
const checkpointReadBlocked = new Promise<void>((resolve) => {
|
|
1275
|
+
markCheckpointReadBlocked = resolve;
|
|
1276
|
+
});
|
|
1277
|
+
let releaseBlockedCheckpointRead = (): void => undefined;
|
|
1278
|
+
const blockedCheckpointReadRelease = new Promise<void>((resolve) => {
|
|
1279
|
+
releaseBlockedCheckpointRead = resolve;
|
|
570
1280
|
});
|
|
1281
|
+
const originalGetTuple = checkpointer.getTuple.bind(checkpointer);
|
|
1282
|
+
const checkpointReadSpy = jest
|
|
1283
|
+
.spyOn(checkpointer, 'getTuple')
|
|
1284
|
+
.mockImplementation(async (config) => {
|
|
1285
|
+
const tuple = await originalGetTuple(config);
|
|
1286
|
+
const configurable = config.configurable;
|
|
1287
|
+
const threadId = configurable?.thread_id;
|
|
1288
|
+
if (
|
|
1289
|
+
blockNextRestoredCheckpointRead &&
|
|
1290
|
+
typeof threadId === 'string' &&
|
|
1291
|
+
threadId.startsWith('subagent:') &&
|
|
1292
|
+
configurable?.checkpoint_id == null
|
|
1293
|
+
) {
|
|
1294
|
+
blockNextRestoredCheckpointRead = false;
|
|
1295
|
+
markCheckpointReadBlocked();
|
|
1296
|
+
await blockedCheckpointReadRelease;
|
|
1297
|
+
}
|
|
1298
|
+
return tuple;
|
|
1299
|
+
});
|
|
1300
|
+
earlyApprovedRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1301
|
+
approvedRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1302
|
+
rejectedRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1303
|
+
const warningSpy = jest
|
|
1304
|
+
.spyOn(console, 'warn')
|
|
1305
|
+
.mockImplementation((): void => undefined);
|
|
1306
|
+
let rejectedResume: Promise<t.MessageContentComplex[] | undefined> | null =
|
|
1307
|
+
null;
|
|
1308
|
+
try {
|
|
1309
|
+
await earlyApprovedRun.resume([{ type: 'approve' }], branchConfig);
|
|
1310
|
+
await initialRun.resume([{ type: 'approve' }], callerConfig);
|
|
1311
|
+
armRestoredCheckpointBarrier = true;
|
|
1312
|
+
rejectedResume = rejectedRun.resume(
|
|
1313
|
+
[{ type: 'reject', reason: 'deny after restart' }],
|
|
1314
|
+
branchConfig
|
|
1315
|
+
);
|
|
1316
|
+
await checkpointReadBlocked;
|
|
1317
|
+
await approvedRun.resume([{ type: 'approve' }], branchConfig);
|
|
1318
|
+
releaseBlockedCheckpointRead();
|
|
1319
|
+
await rejectedResume;
|
|
1320
|
+
expect(warningSpy).not.toHaveBeenCalledWith(
|
|
1321
|
+
expect.stringContaining('toolCallStepIds missing entry')
|
|
1322
|
+
);
|
|
1323
|
+
} finally {
|
|
1324
|
+
releaseBlockedCheckpointRead();
|
|
1325
|
+
await rejectedResume?.catch(() => undefined);
|
|
1326
|
+
checkpointReadSpy.mockRestore();
|
|
1327
|
+
warningSpy.mockRestore();
|
|
1328
|
+
}
|
|
571
1329
|
|
|
572
|
-
|
|
573
|
-
|
|
1330
|
+
expect(earlyApprovedRun.getInterrupt()).toBeUndefined();
|
|
1331
|
+
expect(initialRun.getInterrupt()).toBeUndefined();
|
|
1332
|
+
expect(approvedRun.getInterrupt()).toBeUndefined();
|
|
1333
|
+
expect(rejectedRun.getInterrupt()).toBeUndefined();
|
|
1334
|
+
expect(executedTools).toEqual(['calculator', 'calculator', 'calculator']);
|
|
1335
|
+
expect(approvalHookCalls).toBe(1);
|
|
1336
|
+
const restoredApprovalScopes = new Set(
|
|
1337
|
+
restoreApprovalSpy.mock.calls.map(([, executionScope]) => executionScope)
|
|
1338
|
+
);
|
|
1339
|
+
expect(restoredApprovalScopes.size).toBeGreaterThanOrEqual(4);
|
|
1340
|
+
const initialChildThreads = initialRun.getChildCheckpointThreadIds();
|
|
1341
|
+
const earlyApprovedChildThreads =
|
|
1342
|
+
earlyApprovedRun.getChildCheckpointThreadIds();
|
|
1343
|
+
const approvedChildThreads = approvedRun.getChildCheckpointThreadIds();
|
|
1344
|
+
const rejectedChildThreads = rejectedRun.getChildCheckpointThreadIds();
|
|
1345
|
+
expect(initialChildThreads).toHaveLength(2);
|
|
1346
|
+
expect(earlyApprovedChildThreads).toHaveLength(1);
|
|
1347
|
+
expect(approvedChildThreads).toHaveLength(1);
|
|
1348
|
+
expect(rejectedChildThreads).toHaveLength(1);
|
|
1349
|
+
expect(approvedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
|
|
1350
|
+
expect(earlyApprovedChildThreads[0]).not.toBe(approvedChildThreads[0]);
|
|
1351
|
+
expect(earlyApprovedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
|
|
1352
|
+
expect(initialChildThreads).not.toContain(approvedChildThreads[0]);
|
|
1353
|
+
expect(initialChildThreads).not.toContain(rejectedChildThreads[0]);
|
|
1354
|
+
expect(
|
|
1355
|
+
updates
|
|
1356
|
+
.filter((event) => event.parentToolCallId === tc.id)
|
|
1357
|
+
.map((event) => event.subagentRunId)
|
|
1358
|
+
).toEqual(expect.arrayContaining([sourceChildRunId]));
|
|
1359
|
+
expect(
|
|
1360
|
+
updates
|
|
1361
|
+
.filter((event) => event.parentToolCallId === tc.id)
|
|
1362
|
+
.every((event) => event.subagentRunId === sourceChildRunId)
|
|
1363
|
+
).toBe(true);
|
|
1364
|
+
});
|
|
574
1365
|
|
|
575
|
-
|
|
576
|
-
|
|
1366
|
+
it('forks nested resumes from each checkpoint in the manifest chain', async () => {
|
|
1367
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1368
|
+
if (provider === Providers.OPENAI) {
|
|
1369
|
+
return NestedHitlFakeChatModel;
|
|
1370
|
+
}
|
|
1371
|
+
return originalGetChatModelClass(provider);
|
|
1372
|
+
}) as typeof providers.getChatModelClass);
|
|
1373
|
+
|
|
1374
|
+
const checkpointer = new MemorySaver();
|
|
1375
|
+
const registry = new HookRegistry();
|
|
1376
|
+
const executedTools: string[] = [];
|
|
1377
|
+
const baseRunId = `nested-subagent-rebuild-${Date.now()}`;
|
|
1378
|
+
registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
|
|
1379
|
+
once: true,
|
|
1380
|
+
pattern: '^calculator$',
|
|
1381
|
+
hooks: [
|
|
1382
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
1383
|
+
decision: 'ask',
|
|
1384
|
+
reason: 'review nested calculator',
|
|
1385
|
+
}),
|
|
1386
|
+
],
|
|
1387
|
+
});
|
|
1388
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
1389
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
1390
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
1391
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
1392
|
+
handle: (_event, rawData): void => {
|
|
1393
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
1394
|
+
executedTools.push(...request.toolCalls.map((call) => call.name));
|
|
1395
|
+
request.resolve(
|
|
1396
|
+
request.toolCalls.map((call) => ({
|
|
1397
|
+
toolCallId: call.id,
|
|
1398
|
+
status: 'success' as const,
|
|
1399
|
+
content: '42',
|
|
1400
|
+
}))
|
|
1401
|
+
);
|
|
1402
|
+
},
|
|
1403
|
+
},
|
|
1404
|
+
};
|
|
1405
|
+
const createRun = (runId: string): Promise<Run<t.IState>> =>
|
|
1406
|
+
Run.create<t.IState>({
|
|
1407
|
+
runId,
|
|
1408
|
+
graphConfig: {
|
|
1409
|
+
type: 'standard',
|
|
1410
|
+
agents: [createParentAgentWithNestedChildTool()],
|
|
1411
|
+
compileOptions: { checkpointer },
|
|
1412
|
+
},
|
|
1413
|
+
returnContent: true,
|
|
1414
|
+
skipCleanup: true,
|
|
1415
|
+
customHandlers,
|
|
1416
|
+
hooks: registry,
|
|
1417
|
+
humanInTheLoop: { enabled: true },
|
|
1418
|
+
});
|
|
1419
|
+
const parentCall = makeSubagentToolCall(
|
|
1420
|
+
'call_nested_researcher',
|
|
1421
|
+
'Delegate a nested calculation'
|
|
1422
|
+
);
|
|
1423
|
+
const initialRun = await createRun(`${baseRunId}-initial`);
|
|
1424
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1425
|
+
parentCall,
|
|
1426
|
+
]);
|
|
1427
|
+
|
|
1428
|
+
await initialRun.processStream(
|
|
1429
|
+
{ messages: [new HumanMessage('calculate through two agents')] },
|
|
577
1430
|
callerConfig
|
|
578
1431
|
);
|
|
1432
|
+
const paused = initialRun.getInterrupt();
|
|
1433
|
+
expect(paused?.payload).toMatchObject({
|
|
1434
|
+
type: 'tool_approval',
|
|
1435
|
+
subagent: {
|
|
1436
|
+
agent_id: 'calculator-grandchild',
|
|
1437
|
+
parent_tool_call_id: 'call_nested_calculator_worker',
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
const oldParentCheckpoint = {
|
|
1441
|
+
...callerConfig,
|
|
1442
|
+
configurable: {
|
|
1443
|
+
...callerConfig.configurable,
|
|
1444
|
+
checkpoint_id: paused?.checkpointId,
|
|
1445
|
+
checkpoint_ns: paused?.checkpointNs ?? '',
|
|
1446
|
+
},
|
|
1447
|
+
};
|
|
1448
|
+
const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
|
|
1449
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1450
|
+
const warningSpy = jest
|
|
1451
|
+
.spyOn(console, 'warn')
|
|
1452
|
+
.mockImplementation((): void => undefined);
|
|
1453
|
+
try {
|
|
1454
|
+
await initialRun.resume([{ type: 'approve' }], callerConfig);
|
|
1455
|
+
await rebuiltRun.resume(
|
|
1456
|
+
[{ type: 'reject', reason: 'reject stale nested branch' }],
|
|
1457
|
+
oldParentCheckpoint
|
|
1458
|
+
);
|
|
1459
|
+
expect(warningSpy).not.toHaveBeenCalledWith(
|
|
1460
|
+
expect.stringContaining('toolCallStepIds missing entry')
|
|
1461
|
+
);
|
|
1462
|
+
} finally {
|
|
1463
|
+
warningSpy.mockRestore();
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
expect(initialRun.getInterrupt()).toBeUndefined();
|
|
1467
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1468
|
+
expect(executedTools).toEqual(['calculator']);
|
|
1469
|
+
expect(initialRun.getChildCheckpointThreadIds().length).toBeGreaterThan(2);
|
|
1470
|
+
expect(rebuiltRun.getChildCheckpointThreadIds().length).toBeGreaterThan(1);
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
it('restores child tool-output references into rebuilt branches', async () => {
|
|
1474
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1475
|
+
if (provider === Providers.OPENAI) {
|
|
1476
|
+
return ReferenceHitlFakeChatModel;
|
|
1477
|
+
}
|
|
1478
|
+
return originalGetChatModelClass(provider);
|
|
1479
|
+
}) as typeof providers.getChatModelClass);
|
|
1480
|
+
|
|
1481
|
+
const referenceValue = 'checkpoint-scoped producer output';
|
|
1482
|
+
const consumedValues: unknown[] = [];
|
|
1483
|
+
const checkpointer = new MemorySaver();
|
|
1484
|
+
const registry = new HookRegistry();
|
|
1485
|
+
const baseRunId = `subagent-reference-rebuild-${Date.now()}`;
|
|
1486
|
+
registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
|
|
1487
|
+
once: true,
|
|
1488
|
+
pattern: '^consume_value$',
|
|
1489
|
+
hooks: [
|
|
1490
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
1491
|
+
decision: 'ask',
|
|
1492
|
+
reason: 'review referenced input',
|
|
1493
|
+
}),
|
|
1494
|
+
],
|
|
1495
|
+
});
|
|
1496
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
1497
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
1498
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
1499
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
1500
|
+
handle: (_event, rawData): void => {
|
|
1501
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
1502
|
+
request.resolve(
|
|
1503
|
+
request.toolCalls.map((call) => {
|
|
1504
|
+
if (call.name === 'consume_value') {
|
|
1505
|
+
consumedValues.push(call.args.value);
|
|
1506
|
+
}
|
|
1507
|
+
return {
|
|
1508
|
+
toolCallId: call.id,
|
|
1509
|
+
status: 'success' as const,
|
|
1510
|
+
content:
|
|
1511
|
+
call.name === 'produce_value' ? referenceValue : 'consumed',
|
|
1512
|
+
};
|
|
1513
|
+
})
|
|
1514
|
+
);
|
|
1515
|
+
},
|
|
1516
|
+
},
|
|
1517
|
+
};
|
|
1518
|
+
const createRun = (runId: string): Promise<Run<t.IState>> =>
|
|
1519
|
+
Run.create<t.IState>({
|
|
1520
|
+
runId,
|
|
1521
|
+
graphConfig: {
|
|
1522
|
+
type: 'standard',
|
|
1523
|
+
agents: [createParentAgentWithReferenceTools()],
|
|
1524
|
+
compileOptions: { checkpointer },
|
|
1525
|
+
},
|
|
1526
|
+
returnContent: true,
|
|
1527
|
+
skipCleanup: true,
|
|
1528
|
+
customHandlers,
|
|
1529
|
+
hooks: registry,
|
|
1530
|
+
humanInTheLoop: { enabled: true },
|
|
1531
|
+
toolOutputReferences: { enabled: true },
|
|
1532
|
+
});
|
|
1533
|
+
const parentCall = makeSubagentToolCall('call_reference_researcher');
|
|
1534
|
+
const initialRun = await createRun(`${baseRunId}-initial`);
|
|
1535
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1536
|
+
parentCall,
|
|
1537
|
+
]);
|
|
579
1538
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1539
|
+
await initialRun.processStream(
|
|
1540
|
+
{ messages: [new HumanMessage('produce and consume a value')] },
|
|
1541
|
+
callerConfig
|
|
583
1542
|
);
|
|
584
|
-
|
|
1543
|
+
const paused = initialRun.getInterrupt();
|
|
1544
|
+
expect(paused?.payload).toMatchObject({
|
|
1545
|
+
type: 'tool_approval',
|
|
1546
|
+
action_requests: [
|
|
1547
|
+
{ tool_call_id: 'call_consume_value', name: 'consume_value' },
|
|
1548
|
+
],
|
|
1549
|
+
});
|
|
1550
|
+
const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
|
|
1551
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1552
|
+
await rebuiltRun.resume([{ type: 'approve' }], {
|
|
1553
|
+
...callerConfig,
|
|
1554
|
+
configurable: {
|
|
1555
|
+
...callerConfig.configurable,
|
|
1556
|
+
checkpoint_id: paused?.checkpointId,
|
|
1557
|
+
checkpoint_ns: paused?.checkpointNs ?? '',
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1562
|
+
expect(consumedValues).toEqual([referenceValue]);
|
|
585
1563
|
});
|
|
586
1564
|
});
|