@librechat/agents 3.4.0 → 3.4.2
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/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +165 -57
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
- package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
- package/dist/cjs/graphs/createGraph.cjs +15 -0
- package/dist/cjs/graphs/createGraph.cjs.map +1 -0
- package/dist/cjs/graphs/index.cjs +1 -0
- package/dist/cjs/hooks/HookRegistry.cjs +83 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +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/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +2 -2
- package/dist/cjs/main.cjs +34 -24
- package/dist/cjs/messages/format.cjs +40 -23
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/run.cjs +130 -53
- 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/session/JsonlSessionStore.cjs +2 -2
- 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/SubagentTool.cjs +4 -4
- package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +321 -95
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +1 -1
- package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
- package/dist/cjs/tools/search/rerankers.cjs +154 -1
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +4 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
- package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1114 -233
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +177 -0
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
- package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
- package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
- package/dist/cjs/tools/subagent/index.cjs +1 -0
- package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
- package/dist/cjs/tools/subagent/runtimeLimits.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/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +165 -57
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
- package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
- package/dist/esm/graphs/createGraph.mjs +15 -0
- package/dist/esm/graphs/createGraph.mjs.map +1 -0
- package/dist/esm/graphs/index.mjs +1 -0
- package/dist/esm/hooks/HookRegistry.mjs +83 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +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/init.mjs +1 -1
- package/dist/esm/llm/invoke.mjs +2 -2
- package/dist/esm/main.mjs +21 -19
- package/dist/esm/messages/format.mjs +40 -23
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/run.mjs +131 -54
- 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/session/JsonlSessionStore.mjs +2 -2
- 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/SubagentTool.mjs +4 -4
- package/dist/esm/tools/SubagentTool.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +322 -96
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +1 -1
- package/dist/esm/tools/eagerEventExecution.mjs +1 -1
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
- package/dist/esm/tools/search/rerankers.mjs +154 -1
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +4 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
- package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1114 -233
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +170 -0
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
- package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
- package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
- package/dist/esm/tools/subagent/index.mjs +1 -0
- package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
- package/dist/esm/tools/subagent/runtimeLimits.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/types/agents/AgentContext.d.ts +1 -1
- package/dist/types/graphs/Graph.d.ts +22 -2
- package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
- package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
- package/dist/types/graphs/createGraph.d.ts +13 -0
- package/dist/types/graphs/graphFactory.d.ts +13 -0
- package/dist/types/graphs/index.d.ts +2 -0
- 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/run.d.ts +6 -3
- package/dist/types/session/AgentSession.d.ts +1 -0
- package/dist/types/tools/SubagentTool.d.ts +1 -1
- package/dist/types/tools/ToolNode.d.ts +23 -15
- package/dist/types/tools/search/rerankers.d.ts +28 -0
- package/dist/types/tools/search/types.d.ts +38 -1
- package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +70 -36
- package/dist/types/tools/subagent/SubagentReplay.d.ts +85 -0
- package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
- package/dist/types/tools/subagent/index.d.ts +1 -1
- package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
- package/dist/types/tools/toolOutputReferences.d.ts +12 -0
- package/dist/types/types/graph.d.ts +189 -24
- package/dist/types/types/hitl.d.ts +15 -0
- package/package.json +8 -2
- package/src/agents/AgentContext.ts +1 -1
- package/src/graphs/Graph.ts +349 -104
- package/src/graphs/MultiAgentGraph.ts +149 -47
- 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/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
- package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
- package/src/graphs/__tests__/createGraph.test.ts +206 -0
- package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
- package/src/graphs/createGraph.ts +32 -0
- package/src/graphs/graphFactory.ts +12 -0
- package/src/graphs/index.ts +2 -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/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 +214 -56
- package/src/session/AgentSession.ts +52 -2
- package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
- package/src/specs/graph-subagent.live.test.ts +242 -0
- package/src/specs/graph-subagent.test.ts +322 -0
- package/src/specs/langfuse-routing.integration.test.ts +120 -1
- package/src/specs/subagent.test.ts +130 -4
- package/src/summarization/__tests__/node.test.ts +32 -14
- package/src/summarization/node.ts +15 -11
- package/src/tools/SubagentTool.ts +4 -4
- package/src/tools/ToolNode.ts +643 -157
- package/src/tools/__tests__/SubagentExecutor.test.ts +740 -15
- package/src/tools/__tests__/SubagentReplay.test.ts +329 -0
- package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +512 -2
- package/src/tools/__tests__/hitl.test.ts +234 -5
- package/src/tools/__tests__/subagentHooks.test.ts +1126 -61
- package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
- package/src/tools/search/rag-api-reranker.test.ts +891 -0
- package/src/tools/search/rerankers.ts +260 -0
- package/src/tools/search/tool.ts +6 -0
- package/src/tools/search/types.ts +45 -1
- package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
- package/src/tools/subagent/SubagentExecutor.ts +2232 -312
- package/src/tools/subagent/SubagentReplay.ts +584 -0
- package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
- package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
- package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
- package/src/tools/subagent/childGraphConfig.ts +694 -0
- package/src/tools/subagent/index.ts +6 -0
- package/src/tools/subagent/runtimeLimits.ts +7 -0
- package/src/tools/toolOutputReferences.ts +40 -1
- package/src/types/graph.ts +222 -24
- package/src/types/hitl.ts +16 -0
|
@@ -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()}`
|
|
@@ -223,6 +496,60 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
|
|
|
223
496
|
expect(captured!.messages.length).toBeGreaterThan(0);
|
|
224
497
|
});
|
|
225
498
|
|
|
499
|
+
it('propagates a caller-only Run signal into lazy subagent resolution', async () => {
|
|
500
|
+
const callerAbort = new AbortController();
|
|
501
|
+
let resolverSignal: AbortSignal | undefined;
|
|
502
|
+
let markResolverStarted = (): void => undefined;
|
|
503
|
+
const resolverStarted = new Promise<void>((resolve) => {
|
|
504
|
+
markResolverStarted = resolve;
|
|
505
|
+
});
|
|
506
|
+
let releaseResolver = (): void => undefined;
|
|
507
|
+
const resolverRelease = new Promise<void>((resolve) => {
|
|
508
|
+
releaseResolver = resolve;
|
|
509
|
+
});
|
|
510
|
+
const parent = createParentAgent();
|
|
511
|
+
const eagerChild = parent.subagentConfigs?.[0]?.agentInputs;
|
|
512
|
+
if (eagerChild == null) {
|
|
513
|
+
throw new Error('Expected eager child inputs.');
|
|
514
|
+
}
|
|
515
|
+
parent.subagentConfigs = [
|
|
516
|
+
{
|
|
517
|
+
type: 'researcher',
|
|
518
|
+
name: 'Researcher',
|
|
519
|
+
description: 'Researches topics',
|
|
520
|
+
configId: 'caller-signal-researcher@v1',
|
|
521
|
+
resolveAgentInputs: async (context) => {
|
|
522
|
+
resolverSignal = context.signal;
|
|
523
|
+
markResolverStarted();
|
|
524
|
+
await resolverRelease;
|
|
525
|
+
return eagerChild;
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
const run = await Run.create<t.IState>({
|
|
530
|
+
runId: `subagent-caller-signal-${Date.now()}`,
|
|
531
|
+
graphConfig: { type: 'standard', agents: [parent] },
|
|
532
|
+
returnContent: true,
|
|
533
|
+
skipCleanup: true,
|
|
534
|
+
});
|
|
535
|
+
run.Graph!.overrideTestModel(['Delegating...', 'Caller cancelled.'], 5, [
|
|
536
|
+
makeSubagentToolCall('call_caller_signal'),
|
|
537
|
+
]);
|
|
538
|
+
|
|
539
|
+
const processing = run.processStream(
|
|
540
|
+
{ messages: [new HumanMessage('cancel this delegation')] },
|
|
541
|
+
{ ...callerConfig, signal: callerAbort.signal }
|
|
542
|
+
);
|
|
543
|
+
await resolverStarted;
|
|
544
|
+
callerAbort.abort(new Error('caller cancelled'));
|
|
545
|
+
await Promise.resolve();
|
|
546
|
+
const resolverWasAborted = resolverSignal?.aborted === true;
|
|
547
|
+
releaseResolver();
|
|
548
|
+
await processing.catch(() => undefined);
|
|
549
|
+
|
|
550
|
+
expect(resolverWasAborted).toBe(true);
|
|
551
|
+
});
|
|
552
|
+
|
|
226
553
|
it('SubagentStart deny blocks subagent execution and returns blocked message', async () => {
|
|
227
554
|
const registry = new HookRegistry();
|
|
228
555
|
const denyHook: HookCallback<
|
|
@@ -489,54 +816,458 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
|
|
|
489
816
|
expect(dispatchAgentIds).toEqual(['hook-parent']);
|
|
490
817
|
});
|
|
491
818
|
|
|
492
|
-
it(
|
|
819
|
+
it.each([
|
|
820
|
+
{
|
|
821
|
+
label: 'approve',
|
|
822
|
+
resumeDecision: { type: 'approve' } as const,
|
|
823
|
+
shouldExecute: true,
|
|
824
|
+
deniedReason: undefined,
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
label: 'reject',
|
|
828
|
+
resumeDecision: {
|
|
829
|
+
type: 'reject',
|
|
830
|
+
reason: 'host rejected child tool',
|
|
831
|
+
} as const,
|
|
832
|
+
shouldExecute: false,
|
|
833
|
+
deniedReason: 'host rejected child tool',
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
label: 'deny',
|
|
837
|
+
resumeDecision: undefined,
|
|
838
|
+
shouldExecute: false,
|
|
839
|
+
deniedReason: 'policy denied child tool',
|
|
840
|
+
},
|
|
841
|
+
])(
|
|
842
|
+
'handles a child subagent tool $label through the parent Run',
|
|
843
|
+
async ({ resumeDecision, shouldExecute, deniedReason }) => {
|
|
844
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
845
|
+
if (provider === Providers.OPENAI) {
|
|
846
|
+
return HitlChildFakeChatModel;
|
|
847
|
+
}
|
|
848
|
+
return originalGetChatModelClass(provider);
|
|
849
|
+
}) as typeof providers.getChatModelClass);
|
|
850
|
+
|
|
851
|
+
const registry = new HookRegistry();
|
|
852
|
+
const deniedTools: string[] = [];
|
|
853
|
+
const executedTools: string[] = [];
|
|
854
|
+
let calculatorPreToolCalls = 0;
|
|
855
|
+
let calculatorPostToolCalls = 0;
|
|
856
|
+
|
|
857
|
+
const preHook: HookCallback<'PreToolUse'> = async (
|
|
858
|
+
input
|
|
859
|
+
): Promise<PreToolUseHookOutput> => {
|
|
860
|
+
if (input.toolName === 'calculator') {
|
|
861
|
+
calculatorPreToolCalls += 1;
|
|
862
|
+
if (resumeDecision == null) {
|
|
863
|
+
return {
|
|
864
|
+
decision: 'deny',
|
|
865
|
+
reason: 'policy denied child tool',
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
return { decision: 'ask', reason: 'review calculator' };
|
|
869
|
+
}
|
|
870
|
+
return { decision: 'allow' };
|
|
871
|
+
};
|
|
872
|
+
registry.register('PreToolUse', { hooks: [preHook] });
|
|
873
|
+
registry.register('PostToolUse', {
|
|
874
|
+
hooks: [
|
|
875
|
+
async (input): Promise<PostToolUseHookOutput> => {
|
|
876
|
+
if (input.toolName === 'calculator') {
|
|
877
|
+
calculatorPostToolCalls += 1;
|
|
878
|
+
}
|
|
879
|
+
return {};
|
|
880
|
+
},
|
|
881
|
+
],
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
const deniedHook: HookCallback<'PermissionDenied'> = async (
|
|
885
|
+
input
|
|
886
|
+
): Promise<PermissionDeniedHookOutput> => {
|
|
887
|
+
deniedTools.push(
|
|
888
|
+
`${input.agentId ?? '-'}:${input.toolName}:${input.reason}`
|
|
889
|
+
);
|
|
890
|
+
return {};
|
|
891
|
+
};
|
|
892
|
+
registry.register('PermissionDenied', { hooks: [deniedHook] });
|
|
893
|
+
|
|
894
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
895
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
896
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
897
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
898
|
+
handle: (_event, rawData): void => {
|
|
899
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
900
|
+
executedTools.push(...request.toolCalls.map((call) => call.name));
|
|
901
|
+
const results: t.ToolExecuteResult[] = request.toolCalls.map(
|
|
902
|
+
(call) => ({
|
|
903
|
+
toolCallId: call.id,
|
|
904
|
+
status: 'success',
|
|
905
|
+
content: '42',
|
|
906
|
+
})
|
|
907
|
+
);
|
|
908
|
+
request.resolve(results);
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const run = await Run.create<t.IState>({
|
|
914
|
+
runId: `subagent-tool-ask-${Date.now()}`,
|
|
915
|
+
graphConfig: {
|
|
916
|
+
type: 'standard',
|
|
917
|
+
agents: [createParentAgentWithChildTool()],
|
|
918
|
+
},
|
|
919
|
+
returnContent: true,
|
|
920
|
+
skipCleanup: true,
|
|
921
|
+
customHandlers,
|
|
922
|
+
hooks: registry,
|
|
923
|
+
humanInTheLoop: { enabled: true },
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
const tc = makeSubagentToolCall();
|
|
927
|
+
run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
|
|
928
|
+
|
|
929
|
+
await run.processStream(
|
|
930
|
+
{ messages: [new HumanMessage('calculate something')] },
|
|
931
|
+
callerConfig
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
if (resumeDecision == null) {
|
|
935
|
+
expect(run.getInterrupt()).toBeUndefined();
|
|
936
|
+
expect(calculatorPreToolCalls).toBe(1);
|
|
937
|
+
expect(executedTools).not.toContain('calculator');
|
|
938
|
+
expect(deniedTools).toEqual([
|
|
939
|
+
`researcher-child:calculator:${deniedReason}`,
|
|
940
|
+
]);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const pending = run.getInterrupt();
|
|
945
|
+
expect(pending?.payload).toMatchObject({
|
|
946
|
+
type: 'tool_approval',
|
|
947
|
+
subagent: {
|
|
948
|
+
agent_id: 'researcher-child',
|
|
949
|
+
parent_tool_call_id: tc.id,
|
|
950
|
+
subagent_type: 'researcher',
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
expect(executedTools).not.toContain('calculator');
|
|
954
|
+
|
|
955
|
+
await run.resume([resumeDecision], callerConfig);
|
|
956
|
+
|
|
957
|
+
expect(run.getInterrupt()).toBeUndefined();
|
|
958
|
+
expect(calculatorPreToolCalls).toBe(2);
|
|
959
|
+
expect(
|
|
960
|
+
executedTools.filter((name) => name === 'calculator')
|
|
961
|
+
).toHaveLength(shouldExecute ? 1 : 0);
|
|
962
|
+
expect(calculatorPostToolCalls).toBe(shouldExecute ? 1 : 0);
|
|
963
|
+
expect(deniedTools).toEqual(
|
|
964
|
+
shouldExecute ? [] : [`researcher-child:calculator:${deniedReason}`]
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
it('resumes approvals across multiple subagents and keeps updates sanitized', async () => {
|
|
493
970
|
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
494
971
|
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;
|
|
972
|
+
return HitlChildFakeChatModel;
|
|
512
973
|
}
|
|
513
974
|
return originalGetChatModelClass(provider);
|
|
514
975
|
}) as typeof providers.getChatModelClass);
|
|
515
976
|
|
|
516
977
|
const registry = new HookRegistry();
|
|
517
|
-
const
|
|
518
|
-
const
|
|
978
|
+
const deniedToolIds: string[] = [];
|
|
979
|
+
const executedToolIds: string[] = [];
|
|
980
|
+
const updates: t.SubagentUpdateEvent[] = [];
|
|
981
|
+
const completedSubagentCalls: string[] = [];
|
|
519
982
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
983
|
+
registry.register('PreToolUse', {
|
|
984
|
+
hooks: [
|
|
985
|
+
async (input): Promise<PreToolUseHookOutput> =>
|
|
986
|
+
input.toolName === 'calculator'
|
|
987
|
+
? { decision: 'ask', reason: 'review child calculator' }
|
|
988
|
+
: { decision: 'allow' },
|
|
989
|
+
],
|
|
990
|
+
});
|
|
991
|
+
registry.register('PostToolUse', {
|
|
992
|
+
hooks: [
|
|
993
|
+
async (input): Promise<PostToolUseHookOutput> => {
|
|
994
|
+
if (input.toolName === Constants.SUBAGENT) {
|
|
995
|
+
completedSubagentCalls.push(input.toolUseId);
|
|
996
|
+
return { additionalContext: `context:${input.toolUseId}` };
|
|
997
|
+
}
|
|
998
|
+
return {};
|
|
999
|
+
},
|
|
1000
|
+
],
|
|
1001
|
+
});
|
|
1002
|
+
registry.register('PermissionDenied', {
|
|
1003
|
+
hooks: [
|
|
1004
|
+
async (input): Promise<PermissionDeniedHookOutput> => {
|
|
1005
|
+
deniedToolIds.push(input.toolUseId);
|
|
1006
|
+
return {};
|
|
1007
|
+
},
|
|
1008
|
+
],
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
1012
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
1013
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
1014
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
1015
|
+
handle: (_event, rawData): void => {
|
|
1016
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
1017
|
+
executedToolIds.push(...request.toolCalls.map((call) => call.id));
|
|
1018
|
+
request.resolve(
|
|
1019
|
+
request.toolCalls.map((call) => ({
|
|
1020
|
+
toolCallId: call.id,
|
|
1021
|
+
status: 'success' as const,
|
|
1022
|
+
content: '42',
|
|
1023
|
+
}))
|
|
1024
|
+
);
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
[GraphEvents.ON_SUBAGENT_UPDATE]: {
|
|
1028
|
+
handle: (_event, data): void => {
|
|
1029
|
+
updates.push(data as t.SubagentUpdateEvent);
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
527
1032
|
};
|
|
528
|
-
registry.register('PreToolUse', { hooks: [preHook] });
|
|
529
1033
|
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
): Promise<
|
|
533
|
-
|
|
534
|
-
|
|
1034
|
+
const checkpointer = new MemorySaver();
|
|
1035
|
+
const baseRunId = `subagent-multi-hitl-${Date.now()}`;
|
|
1036
|
+
const createRun = (runId: string): Promise<Run<t.IState>> =>
|
|
1037
|
+
Run.create<t.IState>({
|
|
1038
|
+
runId,
|
|
1039
|
+
graphConfig: {
|
|
1040
|
+
type: 'standard',
|
|
1041
|
+
agents: [createParentAgentWithChildTool()],
|
|
1042
|
+
compileOptions: { checkpointer, interruptBefore: [] },
|
|
1043
|
+
},
|
|
1044
|
+
returnContent: true,
|
|
1045
|
+
skipCleanup: true,
|
|
1046
|
+
customHandlers,
|
|
1047
|
+
hooks: registry,
|
|
1048
|
+
humanInTheLoop: { enabled: true },
|
|
1049
|
+
});
|
|
1050
|
+
const run = await createRun(`${baseRunId}-initial`);
|
|
1051
|
+
const first = makeSubagentToolCall(
|
|
1052
|
+
'call_sub_first',
|
|
1053
|
+
'Run the first calculation'
|
|
1054
|
+
);
|
|
1055
|
+
const second = makeSubagentToolCall(
|
|
1056
|
+
'call_sub_second',
|
|
1057
|
+
'Run the second calculation'
|
|
1058
|
+
);
|
|
1059
|
+
run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1060
|
+
first,
|
|
1061
|
+
second,
|
|
1062
|
+
]);
|
|
1063
|
+
const multiCallerConfig = {
|
|
1064
|
+
...callerConfig,
|
|
1065
|
+
configurable: {
|
|
1066
|
+
thread_id: 'multi-subagent-hitl-thread',
|
|
1067
|
+
access_token: 'must-not-leak',
|
|
1068
|
+
requestBody: { currentTaskInput: 'must-not-leak-task' },
|
|
1069
|
+
userMCPAuthMap: { private: { token: 'must-not-leak-mcp' } },
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
await run.processStream(
|
|
1074
|
+
{ messages: [new HumanMessage('calculate twice')] },
|
|
1075
|
+
multiCallerConfig
|
|
1076
|
+
);
|
|
1077
|
+
expect(run.getInterrupt()?.payload).toMatchObject({
|
|
1078
|
+
type: 'tool_approval',
|
|
1079
|
+
subagent: { parent_tool_call_id: first.id },
|
|
1080
|
+
});
|
|
1081
|
+
expect(updates.some((event) => event.parentToolCallId === second.id)).toBe(
|
|
1082
|
+
true
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
await run.resume([{ type: 'approve' }], multiCallerConfig);
|
|
1086
|
+
expect(run.getInterrupt()?.payload).toMatchObject({
|
|
1087
|
+
type: 'tool_approval',
|
|
1088
|
+
subagent: { parent_tool_call_id: second.id },
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
|
|
1092
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1093
|
+
await rebuiltRun.resume(
|
|
1094
|
+
[{ type: 'reject', reason: 'reject second child' }],
|
|
1095
|
+
multiCallerConfig
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1099
|
+
expect(executedToolIds).toHaveLength(1);
|
|
1100
|
+
expect(deniedToolIds).toHaveLength(1);
|
|
1101
|
+
expect(completedSubagentCalls).toEqual([first.id, second.id]);
|
|
1102
|
+
const firstPhases = updates
|
|
1103
|
+
.filter((event) => event.parentToolCallId === first.id)
|
|
1104
|
+
.map((event) => event.phase);
|
|
1105
|
+
const secondPhases = updates
|
|
1106
|
+
.filter((event) => event.parentToolCallId === second.id)
|
|
1107
|
+
.map((event) => event.phase);
|
|
1108
|
+
expect(firstPhases[0]).toBe('start');
|
|
1109
|
+
expect(firstPhases[firstPhases.length - 1]).toBe('stop');
|
|
1110
|
+
expect(secondPhases[0]).toBe('start');
|
|
1111
|
+
expect(secondPhases[secondPhases.length - 1]).toBe('stop');
|
|
1112
|
+
const serializedUpdates = JSON.stringify(updates);
|
|
1113
|
+
expect(serializedUpdates).not.toContain('must-not-leak');
|
|
1114
|
+
expect(serializedUpdates).not.toContain('currentTaskInput');
|
|
1115
|
+
expect(serializedUpdates).not.toContain('userMCPAuthMap');
|
|
1116
|
+
expect(serializedUpdates).not.toContain('checkpoint_');
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it('resumes a primitive child interrupt through a rebuilt Run', async () => {
|
|
1120
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1121
|
+
if (provider === Providers.OPENAI) {
|
|
1122
|
+
return PrimitiveInterruptFakeChatModel;
|
|
1123
|
+
}
|
|
1124
|
+
return originalGetChatModelClass(provider);
|
|
1125
|
+
}) as typeof providers.getChatModelClass);
|
|
1126
|
+
|
|
1127
|
+
const checkpointer = new MemorySaver();
|
|
1128
|
+
const resumedValues: string[] = [];
|
|
1129
|
+
const primitiveInterruptTool = tool(
|
|
1130
|
+
async () => {
|
|
1131
|
+
const resumeValue = interrupt<string, string>('confirm child');
|
|
1132
|
+
resumedValues.push(resumeValue);
|
|
1133
|
+
return `confirmed:${resumeValue}`;
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
name: PRIMITIVE_INTERRUPT_TOOL_NAME,
|
|
1137
|
+
description: 'Pause for primitive confirmation.',
|
|
1138
|
+
schema: z.object({}),
|
|
1139
|
+
}
|
|
1140
|
+
);
|
|
1141
|
+
const resolutionContexts: t.SubagentResolveContext[] = [];
|
|
1142
|
+
const createLazyParent = (): t.AgentInputs => {
|
|
1143
|
+
const parent = createParentAgentWithPrimitiveInterruptTool(
|
|
1144
|
+
primitiveInterruptTool
|
|
535
1145
|
);
|
|
536
|
-
|
|
1146
|
+
const child = parent.subagentConfigs?.[0];
|
|
1147
|
+
const childInputs = child?.agentInputs;
|
|
1148
|
+
if (child == null || childInputs == null) {
|
|
1149
|
+
throw new Error('Expected a child agent configuration.');
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
...parent,
|
|
1153
|
+
subagentConfigs: [
|
|
1154
|
+
{
|
|
1155
|
+
...child,
|
|
1156
|
+
agentInputs: undefined,
|
|
1157
|
+
configId: 'primitive-interrupt-child@v1',
|
|
1158
|
+
resolveAgentInputs: async (context) => {
|
|
1159
|
+
resolutionContexts.push(context);
|
|
1160
|
+
return childInputs;
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
],
|
|
1164
|
+
};
|
|
537
1165
|
};
|
|
538
|
-
|
|
1166
|
+
const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
|
|
1167
|
+
Run.create<t.IState>({
|
|
1168
|
+
runId: currentRunId,
|
|
1169
|
+
graphConfig: {
|
|
1170
|
+
type: 'standard',
|
|
1171
|
+
agents: [createLazyParent()],
|
|
1172
|
+
compileOptions: { checkpointer },
|
|
1173
|
+
},
|
|
1174
|
+
returnContent: true,
|
|
1175
|
+
skipCleanup: true,
|
|
1176
|
+
customHandlers: {
|
|
1177
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
1178
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
1179
|
+
},
|
|
1180
|
+
humanInTheLoop: { enabled: true },
|
|
1181
|
+
});
|
|
1182
|
+
const runId = `primitive-subagent-rebuild-${Date.now()}`;
|
|
1183
|
+
const parentCall = makeSubagentToolCall('call_primitive_rebuild');
|
|
1184
|
+
const initialRun = await createRun(`${runId}-initial`);
|
|
1185
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1186
|
+
parentCall,
|
|
1187
|
+
]);
|
|
539
1188
|
|
|
1189
|
+
await initialRun.processStream(
|
|
1190
|
+
{ messages: [new HumanMessage('confirm inside the child')] },
|
|
1191
|
+
callerConfig
|
|
1192
|
+
);
|
|
1193
|
+
const paused = initialRun.getInterrupt<string>();
|
|
1194
|
+
expect(paused?.payload).toBe('confirm child');
|
|
1195
|
+
expect(JSON.stringify(paused)).not.toContain(
|
|
1196
|
+
'__librechat_subagent_resume_manifest'
|
|
1197
|
+
);
|
|
1198
|
+
if (paused?.checkpointId == null) {
|
|
1199
|
+
throw new Error('Expected a persisted parent checkpoint.');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const rebuiltRun = await createRun(`${runId}-rebuilt`);
|
|
1203
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1204
|
+
const injected = new HumanMessage('host edit on resume');
|
|
1205
|
+
const rebuiltContent = await rebuiltRun.resume<string>(
|
|
1206
|
+
'approved after restart',
|
|
1207
|
+
{
|
|
1208
|
+
...callerConfig,
|
|
1209
|
+
configurable: {
|
|
1210
|
+
...callerConfig.configurable,
|
|
1211
|
+
checkpoint_id: paused.checkpointId,
|
|
1212
|
+
checkpoint_ns: paused.checkpointNs ?? '',
|
|
1213
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
undefined,
|
|
1216
|
+
{ update: { messages: [injected] } }
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1220
|
+
expect(resolutionContexts).toHaveLength(2);
|
|
1221
|
+
expect(resolutionContexts[0].executionId).toBe(
|
|
1222
|
+
resolutionContexts[1].executionId
|
|
1223
|
+
);
|
|
1224
|
+
expect(resolutionContexts[0].descriptor.configId).toBe(
|
|
1225
|
+
'primitive-interrupt-child@v1'
|
|
1226
|
+
);
|
|
1227
|
+
expect(resolutionContexts[1].descriptor.configId).toBe(
|
|
1228
|
+
'primitive-interrupt-child@v1'
|
|
1229
|
+
);
|
|
1230
|
+
expect(resumedValues).toEqual(['approved after restart']);
|
|
1231
|
+
expect(JSON.stringify(rebuiltContent)).toContain('Final answer.');
|
|
1232
|
+
expect(JSON.stringify(rebuiltContent)).not.toContain('host edit on resume');
|
|
1233
|
+
expect(
|
|
1234
|
+
rebuiltRun
|
|
1235
|
+
.getRunMessages()
|
|
1236
|
+
?.some(
|
|
1237
|
+
(message) =>
|
|
1238
|
+
message._getType() === 'ai' &&
|
|
1239
|
+
JSON.stringify(message.content).includes('Final answer.')
|
|
1240
|
+
)
|
|
1241
|
+
).toBe(true);
|
|
1242
|
+
expect(
|
|
1243
|
+
rebuiltRun.getRunMessages()?.some((message) => message.id === injected.id)
|
|
1244
|
+
).toBe(false);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('forks every resume from the paused child snapshot', async () => {
|
|
1248
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1249
|
+
if (provider === Providers.OPENAI) {
|
|
1250
|
+
return HitlChildFakeChatModel;
|
|
1251
|
+
}
|
|
1252
|
+
return originalGetChatModelClass(provider);
|
|
1253
|
+
}) as typeof providers.getChatModelClass);
|
|
1254
|
+
|
|
1255
|
+
const checkpointer = new MemorySaver();
|
|
1256
|
+
const registry = new HookRegistry();
|
|
1257
|
+
const executedTools: string[] = [];
|
|
1258
|
+
const updates: t.SubagentUpdateEvent[] = [];
|
|
1259
|
+
let approvalHookCalls = 0;
|
|
1260
|
+
const runId = `subagent-rebuild-hitl-${Date.now()}`;
|
|
1261
|
+
registry.registerSession(`${runId}-initial`, 'PreToolUse', {
|
|
1262
|
+
once: true,
|
|
1263
|
+
pattern: '^calculator$',
|
|
1264
|
+
hooks: [
|
|
1265
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
1266
|
+
approvalHookCalls += 1;
|
|
1267
|
+
return { decision: 'ask', reason: 'review calculator' };
|
|
1268
|
+
},
|
|
1269
|
+
],
|
|
1270
|
+
});
|
|
540
1271
|
const customHandlers: Record<string, t.EventHandler> = {
|
|
541
1272
|
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
542
1273
|
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
@@ -544,43 +1275,377 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
|
|
|
544
1275
|
handle: (_event, rawData): void => {
|
|
545
1276
|
const request = rawData as t.ToolExecuteBatchRequest;
|
|
546
1277
|
executedTools.push(...request.toolCalls.map((call) => call.name));
|
|
547
|
-
|
|
548
|
-
(call) => ({
|
|
1278
|
+
request.resolve(
|
|
1279
|
+
request.toolCalls.map((call) => ({
|
|
549
1280
|
toolCallId: call.id,
|
|
550
|
-
status: 'success',
|
|
1281
|
+
status: 'success' as const,
|
|
551
1282
|
content: '42',
|
|
552
|
-
})
|
|
1283
|
+
}))
|
|
553
1284
|
);
|
|
554
|
-
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
[GraphEvents.ON_SUBAGENT_UPDATE]: {
|
|
1288
|
+
handle: (_event, data): void => {
|
|
1289
|
+
updates.push(data as t.SubagentUpdateEvent);
|
|
555
1290
|
},
|
|
556
1291
|
},
|
|
557
1292
|
};
|
|
1293
|
+
const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
|
|
1294
|
+
Run.create<t.IState>({
|
|
1295
|
+
runId: currentRunId,
|
|
1296
|
+
graphConfig: {
|
|
1297
|
+
type: 'standard',
|
|
1298
|
+
agents: [createParentAgentWithChildTool()],
|
|
1299
|
+
compileOptions: { checkpointer },
|
|
1300
|
+
},
|
|
1301
|
+
returnContent: true,
|
|
1302
|
+
skipCleanup: true,
|
|
1303
|
+
customHandlers,
|
|
1304
|
+
hooks: registry,
|
|
1305
|
+
humanInTheLoop: { enabled: true },
|
|
1306
|
+
});
|
|
1307
|
+
const tc = makeSubagentToolCall('call_sub_rebuild');
|
|
1308
|
+
const initialRun = await createRun(`${runId}-initial`);
|
|
1309
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1310
|
+
tc,
|
|
1311
|
+
]);
|
|
558
1312
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1313
|
+
await initialRun.processStream(
|
|
1314
|
+
{ messages: [new HumanMessage('calculate after restart')] },
|
|
1315
|
+
callerConfig
|
|
1316
|
+
);
|
|
1317
|
+
const persistedInterrupt = initialRun.getInterrupt();
|
|
1318
|
+
expect(persistedInterrupt?.payload).toMatchObject({
|
|
1319
|
+
type: 'tool_approval',
|
|
1320
|
+
subagent: { parent_tool_call_id: tc.id },
|
|
1321
|
+
});
|
|
1322
|
+
expect(JSON.stringify(persistedInterrupt)).not.toContain(
|
|
1323
|
+
'__librechat_subagent_resume_manifest'
|
|
1324
|
+
);
|
|
1325
|
+
expect(approvalHookCalls).toBe(1);
|
|
1326
|
+
const sourceChildRunId =
|
|
1327
|
+
persistedInterrupt?.payload.type === 'tool_approval'
|
|
1328
|
+
? persistedInterrupt.payload.subagent?.run_id
|
|
1329
|
+
: undefined;
|
|
1330
|
+
expect(sourceChildRunId).toBeDefined();
|
|
1331
|
+
const branchConfig = {
|
|
1332
|
+
...callerConfig,
|
|
1333
|
+
configurable: {
|
|
1334
|
+
...callerConfig.configurable,
|
|
1335
|
+
checkpoint_id: persistedInterrupt?.checkpointId,
|
|
1336
|
+
checkpoint_ns: persistedInterrupt?.checkpointNs ?? '',
|
|
564
1337
|
},
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1338
|
+
};
|
|
1339
|
+
const earlyApprovedRun = await createRun(`${runId}-early-approved`);
|
|
1340
|
+
const sharedRebuiltRunId = `${runId}-same-rebuilt-id`;
|
|
1341
|
+
const approvedRun = await createRun(sharedRebuiltRunId);
|
|
1342
|
+
const rejectedRun = await createRun(sharedRebuiltRunId);
|
|
1343
|
+
let armRestoredCheckpointBarrier = false;
|
|
1344
|
+
let blockNextRestoredCheckpointRead = false;
|
|
1345
|
+
const originalRestorePendingToolApprovals =
|
|
1346
|
+
registry.restorePendingToolApprovals.bind(registry);
|
|
1347
|
+
const restoreApprovalSpy = jest
|
|
1348
|
+
.spyOn(registry, 'restorePendingToolApprovals')
|
|
1349
|
+
.mockImplementation((sessionId, targetExecutionScope, snapshots) => {
|
|
1350
|
+
originalRestorePendingToolApprovals(
|
|
1351
|
+
sessionId,
|
|
1352
|
+
targetExecutionScope,
|
|
1353
|
+
snapshots
|
|
1354
|
+
);
|
|
1355
|
+
if (armRestoredCheckpointBarrier) {
|
|
1356
|
+
armRestoredCheckpointBarrier = false;
|
|
1357
|
+
blockNextRestoredCheckpointRead = true;
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
let markCheckpointReadBlocked = (): void => undefined;
|
|
1361
|
+
const checkpointReadBlocked = new Promise<void>((resolve) => {
|
|
1362
|
+
markCheckpointReadBlocked = resolve;
|
|
570
1363
|
});
|
|
1364
|
+
let releaseBlockedCheckpointRead = (): void => undefined;
|
|
1365
|
+
const blockedCheckpointReadRelease = new Promise<void>((resolve) => {
|
|
1366
|
+
releaseBlockedCheckpointRead = resolve;
|
|
1367
|
+
});
|
|
1368
|
+
const originalGetTuple = checkpointer.getTuple.bind(checkpointer);
|
|
1369
|
+
const checkpointReadSpy = jest
|
|
1370
|
+
.spyOn(checkpointer, 'getTuple')
|
|
1371
|
+
.mockImplementation(async (config) => {
|
|
1372
|
+
const tuple = await originalGetTuple(config);
|
|
1373
|
+
const configurable = config.configurable;
|
|
1374
|
+
const threadId = configurable?.thread_id;
|
|
1375
|
+
if (
|
|
1376
|
+
blockNextRestoredCheckpointRead &&
|
|
1377
|
+
typeof threadId === 'string' &&
|
|
1378
|
+
threadId.startsWith('subagent:') &&
|
|
1379
|
+
configurable?.checkpoint_id == null
|
|
1380
|
+
) {
|
|
1381
|
+
blockNextRestoredCheckpointRead = false;
|
|
1382
|
+
markCheckpointReadBlocked();
|
|
1383
|
+
await blockedCheckpointReadRelease;
|
|
1384
|
+
}
|
|
1385
|
+
return tuple;
|
|
1386
|
+
});
|
|
1387
|
+
earlyApprovedRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1388
|
+
approvedRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1389
|
+
rejectedRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1390
|
+
const warningSpy = jest
|
|
1391
|
+
.spyOn(console, 'warn')
|
|
1392
|
+
.mockImplementation((): void => undefined);
|
|
1393
|
+
let rejectedResume: Promise<t.MessageContentComplex[] | undefined> | null =
|
|
1394
|
+
null;
|
|
1395
|
+
try {
|
|
1396
|
+
await earlyApprovedRun.resume([{ type: 'approve' }], branchConfig);
|
|
1397
|
+
await initialRun.resume([{ type: 'approve' }], callerConfig);
|
|
1398
|
+
armRestoredCheckpointBarrier = true;
|
|
1399
|
+
rejectedResume = rejectedRun.resume(
|
|
1400
|
+
[{ type: 'reject', reason: 'deny after restart' }],
|
|
1401
|
+
branchConfig
|
|
1402
|
+
);
|
|
1403
|
+
await checkpointReadBlocked;
|
|
1404
|
+
await approvedRun.resume([{ type: 'approve' }], branchConfig);
|
|
1405
|
+
releaseBlockedCheckpointRead();
|
|
1406
|
+
await rejectedResume;
|
|
1407
|
+
expect(warningSpy).not.toHaveBeenCalledWith(
|
|
1408
|
+
expect.stringContaining('toolCallStepIds missing entry')
|
|
1409
|
+
);
|
|
1410
|
+
} finally {
|
|
1411
|
+
releaseBlockedCheckpointRead();
|
|
1412
|
+
await rejectedResume?.catch(() => undefined);
|
|
1413
|
+
checkpointReadSpy.mockRestore();
|
|
1414
|
+
warningSpy.mockRestore();
|
|
1415
|
+
}
|
|
571
1416
|
|
|
572
|
-
|
|
573
|
-
|
|
1417
|
+
expect(earlyApprovedRun.getInterrupt()).toBeUndefined();
|
|
1418
|
+
expect(initialRun.getInterrupt()).toBeUndefined();
|
|
1419
|
+
expect(approvedRun.getInterrupt()).toBeUndefined();
|
|
1420
|
+
expect(rejectedRun.getInterrupt()).toBeUndefined();
|
|
1421
|
+
expect(executedTools).toEqual(['calculator', 'calculator', 'calculator']);
|
|
1422
|
+
expect(approvalHookCalls).toBe(1);
|
|
1423
|
+
const restoredApprovalScopes = new Set(
|
|
1424
|
+
restoreApprovalSpy.mock.calls.map(([, executionScope]) => executionScope)
|
|
1425
|
+
);
|
|
1426
|
+
expect(restoredApprovalScopes.size).toBeGreaterThanOrEqual(4);
|
|
1427
|
+
const initialChildThreads = initialRun.getChildCheckpointThreadIds();
|
|
1428
|
+
const earlyApprovedChildThreads =
|
|
1429
|
+
earlyApprovedRun.getChildCheckpointThreadIds();
|
|
1430
|
+
const approvedChildThreads = approvedRun.getChildCheckpointThreadIds();
|
|
1431
|
+
const rejectedChildThreads = rejectedRun.getChildCheckpointThreadIds();
|
|
1432
|
+
expect(initialChildThreads).toHaveLength(2);
|
|
1433
|
+
expect(earlyApprovedChildThreads).toHaveLength(1);
|
|
1434
|
+
expect(approvedChildThreads).toHaveLength(1);
|
|
1435
|
+
expect(rejectedChildThreads).toHaveLength(1);
|
|
1436
|
+
expect(approvedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
|
|
1437
|
+
expect(earlyApprovedChildThreads[0]).not.toBe(approvedChildThreads[0]);
|
|
1438
|
+
expect(earlyApprovedChildThreads[0]).not.toBe(rejectedChildThreads[0]);
|
|
1439
|
+
expect(initialChildThreads).not.toContain(approvedChildThreads[0]);
|
|
1440
|
+
expect(initialChildThreads).not.toContain(rejectedChildThreads[0]);
|
|
1441
|
+
expect(
|
|
1442
|
+
updates
|
|
1443
|
+
.filter((event) => event.parentToolCallId === tc.id)
|
|
1444
|
+
.map((event) => event.subagentRunId)
|
|
1445
|
+
).toEqual(expect.arrayContaining([sourceChildRunId]));
|
|
1446
|
+
expect(
|
|
1447
|
+
updates
|
|
1448
|
+
.filter((event) => event.parentToolCallId === tc.id)
|
|
1449
|
+
.every((event) => event.subagentRunId === sourceChildRunId)
|
|
1450
|
+
).toBe(true);
|
|
1451
|
+
});
|
|
574
1452
|
|
|
575
|
-
|
|
576
|
-
|
|
1453
|
+
it('forks nested resumes from each checkpoint in the manifest chain', async () => {
|
|
1454
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1455
|
+
if (provider === Providers.OPENAI) {
|
|
1456
|
+
return NestedHitlFakeChatModel;
|
|
1457
|
+
}
|
|
1458
|
+
return originalGetChatModelClass(provider);
|
|
1459
|
+
}) as typeof providers.getChatModelClass);
|
|
1460
|
+
|
|
1461
|
+
const checkpointer = new MemorySaver();
|
|
1462
|
+
const registry = new HookRegistry();
|
|
1463
|
+
const executedTools: string[] = [];
|
|
1464
|
+
const baseRunId = `nested-subagent-rebuild-${Date.now()}`;
|
|
1465
|
+
registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
|
|
1466
|
+
once: true,
|
|
1467
|
+
pattern: '^calculator$',
|
|
1468
|
+
hooks: [
|
|
1469
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
1470
|
+
decision: 'ask',
|
|
1471
|
+
reason: 'review nested calculator',
|
|
1472
|
+
}),
|
|
1473
|
+
],
|
|
1474
|
+
});
|
|
1475
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
1476
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
1477
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
1478
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
1479
|
+
handle: (_event, rawData): void => {
|
|
1480
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
1481
|
+
executedTools.push(...request.toolCalls.map((call) => call.name));
|
|
1482
|
+
request.resolve(
|
|
1483
|
+
request.toolCalls.map((call) => ({
|
|
1484
|
+
toolCallId: call.id,
|
|
1485
|
+
status: 'success' as const,
|
|
1486
|
+
content: '42',
|
|
1487
|
+
}))
|
|
1488
|
+
);
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
};
|
|
1492
|
+
const createRun = (runId: string): Promise<Run<t.IState>> =>
|
|
1493
|
+
Run.create<t.IState>({
|
|
1494
|
+
runId,
|
|
1495
|
+
graphConfig: {
|
|
1496
|
+
type: 'standard',
|
|
1497
|
+
agents: [createParentAgentWithNestedChildTool()],
|
|
1498
|
+
compileOptions: { checkpointer },
|
|
1499
|
+
},
|
|
1500
|
+
returnContent: true,
|
|
1501
|
+
skipCleanup: true,
|
|
1502
|
+
customHandlers,
|
|
1503
|
+
hooks: registry,
|
|
1504
|
+
humanInTheLoop: { enabled: true },
|
|
1505
|
+
});
|
|
1506
|
+
const parentCall = makeSubagentToolCall(
|
|
1507
|
+
'call_nested_researcher',
|
|
1508
|
+
'Delegate a nested calculation'
|
|
1509
|
+
);
|
|
1510
|
+
const initialRun = await createRun(`${baseRunId}-initial`);
|
|
1511
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1512
|
+
parentCall,
|
|
1513
|
+
]);
|
|
1514
|
+
|
|
1515
|
+
await initialRun.processStream(
|
|
1516
|
+
{ messages: [new HumanMessage('calculate through two agents')] },
|
|
577
1517
|
callerConfig
|
|
578
1518
|
);
|
|
1519
|
+
const paused = initialRun.getInterrupt();
|
|
1520
|
+
expect(paused?.payload).toMatchObject({
|
|
1521
|
+
type: 'tool_approval',
|
|
1522
|
+
subagent: {
|
|
1523
|
+
agent_id: 'calculator-grandchild',
|
|
1524
|
+
parent_tool_call_id: 'call_nested_calculator_worker',
|
|
1525
|
+
},
|
|
1526
|
+
});
|
|
1527
|
+
const oldParentCheckpoint = {
|
|
1528
|
+
...callerConfig,
|
|
1529
|
+
configurable: {
|
|
1530
|
+
...callerConfig.configurable,
|
|
1531
|
+
checkpoint_id: paused?.checkpointId,
|
|
1532
|
+
checkpoint_ns: paused?.checkpointNs ?? '',
|
|
1533
|
+
},
|
|
1534
|
+
};
|
|
1535
|
+
const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
|
|
1536
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1537
|
+
const warningSpy = jest
|
|
1538
|
+
.spyOn(console, 'warn')
|
|
1539
|
+
.mockImplementation((): void => undefined);
|
|
1540
|
+
try {
|
|
1541
|
+
await initialRun.resume([{ type: 'approve' }], callerConfig);
|
|
1542
|
+
await rebuiltRun.resume(
|
|
1543
|
+
[{ type: 'reject', reason: 'reject stale nested branch' }],
|
|
1544
|
+
oldParentCheckpoint
|
|
1545
|
+
);
|
|
1546
|
+
expect(warningSpy).not.toHaveBeenCalledWith(
|
|
1547
|
+
expect.stringContaining('toolCallStepIds missing entry')
|
|
1548
|
+
);
|
|
1549
|
+
} finally {
|
|
1550
|
+
warningSpy.mockRestore();
|
|
1551
|
+
}
|
|
579
1552
|
|
|
580
|
-
expect(
|
|
581
|
-
expect(
|
|
582
|
-
|
|
1553
|
+
expect(initialRun.getInterrupt()).toBeUndefined();
|
|
1554
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1555
|
+
expect(executedTools).toEqual(['calculator']);
|
|
1556
|
+
expect(initialRun.getChildCheckpointThreadIds().length).toBeGreaterThan(2);
|
|
1557
|
+
expect(rebuiltRun.getChildCheckpointThreadIds().length).toBeGreaterThan(1);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it('restores child tool-output references into rebuilt branches', async () => {
|
|
1561
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
1562
|
+
if (provider === Providers.OPENAI) {
|
|
1563
|
+
return ReferenceHitlFakeChatModel;
|
|
1564
|
+
}
|
|
1565
|
+
return originalGetChatModelClass(provider);
|
|
1566
|
+
}) as typeof providers.getChatModelClass);
|
|
1567
|
+
|
|
1568
|
+
const referenceValue = 'checkpoint-scoped producer output';
|
|
1569
|
+
const consumedValues: unknown[] = [];
|
|
1570
|
+
const checkpointer = new MemorySaver();
|
|
1571
|
+
const registry = new HookRegistry();
|
|
1572
|
+
const baseRunId = `subagent-reference-rebuild-${Date.now()}`;
|
|
1573
|
+
registry.registerSession(`${baseRunId}-initial`, 'PreToolUse', {
|
|
1574
|
+
once: true,
|
|
1575
|
+
pattern: '^consume_value$',
|
|
1576
|
+
hooks: [
|
|
1577
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
1578
|
+
decision: 'ask',
|
|
1579
|
+
reason: 'review referenced input',
|
|
1580
|
+
}),
|
|
1581
|
+
],
|
|
1582
|
+
});
|
|
1583
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
1584
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
1585
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
1586
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
1587
|
+
handle: (_event, rawData): void => {
|
|
1588
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
1589
|
+
request.resolve(
|
|
1590
|
+
request.toolCalls.map((call) => {
|
|
1591
|
+
if (call.name === 'consume_value') {
|
|
1592
|
+
consumedValues.push(call.args.value);
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
toolCallId: call.id,
|
|
1596
|
+
status: 'success' as const,
|
|
1597
|
+
content:
|
|
1598
|
+
call.name === 'produce_value' ? referenceValue : 'consumed',
|
|
1599
|
+
};
|
|
1600
|
+
})
|
|
1601
|
+
);
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
};
|
|
1605
|
+
const createRun = (runId: string): Promise<Run<t.IState>> =>
|
|
1606
|
+
Run.create<t.IState>({
|
|
1607
|
+
runId,
|
|
1608
|
+
graphConfig: {
|
|
1609
|
+
type: 'standard',
|
|
1610
|
+
agents: [createParentAgentWithReferenceTools()],
|
|
1611
|
+
compileOptions: { checkpointer },
|
|
1612
|
+
},
|
|
1613
|
+
returnContent: true,
|
|
1614
|
+
skipCleanup: true,
|
|
1615
|
+
customHandlers,
|
|
1616
|
+
hooks: registry,
|
|
1617
|
+
humanInTheLoop: { enabled: true },
|
|
1618
|
+
toolOutputReferences: { enabled: true },
|
|
1619
|
+
});
|
|
1620
|
+
const parentCall = makeSubagentToolCall('call_reference_researcher');
|
|
1621
|
+
const initialRun = await createRun(`${baseRunId}-initial`);
|
|
1622
|
+
initialRun.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [
|
|
1623
|
+
parentCall,
|
|
1624
|
+
]);
|
|
1625
|
+
|
|
1626
|
+
await initialRun.processStream(
|
|
1627
|
+
{ messages: [new HumanMessage('produce and consume a value')] },
|
|
1628
|
+
callerConfig
|
|
583
1629
|
);
|
|
584
|
-
|
|
1630
|
+
const paused = initialRun.getInterrupt();
|
|
1631
|
+
expect(paused?.payload).toMatchObject({
|
|
1632
|
+
type: 'tool_approval',
|
|
1633
|
+
action_requests: [
|
|
1634
|
+
{ tool_call_id: 'call_consume_value', name: 'consume_value' },
|
|
1635
|
+
],
|
|
1636
|
+
});
|
|
1637
|
+
const rebuiltRun = await createRun(`${baseRunId}-rebuilt`);
|
|
1638
|
+
rebuiltRun.Graph!.overrideTestModel(['Final answer.'], 1);
|
|
1639
|
+
await rebuiltRun.resume([{ type: 'approve' }], {
|
|
1640
|
+
...callerConfig,
|
|
1641
|
+
configurable: {
|
|
1642
|
+
...callerConfig.configurable,
|
|
1643
|
+
checkpoint_id: paused?.checkpointId,
|
|
1644
|
+
checkpoint_ns: paused?.checkpointNs ?? '',
|
|
1645
|
+
},
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
expect(rebuiltRun.getInterrupt()).toBeUndefined();
|
|
1649
|
+
expect(consumedValues).toEqual([referenceValue]);
|
|
585
1650
|
});
|
|
586
1651
|
});
|