@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
|
@@ -7,15 +7,27 @@ import {
|
|
|
7
7
|
START,
|
|
8
8
|
StateGraph,
|
|
9
9
|
MemorySaver,
|
|
10
|
+
GraphInterrupt,
|
|
10
11
|
isInterrupted,
|
|
11
12
|
MessagesAnnotation,
|
|
12
13
|
Command,
|
|
13
14
|
} from '@langchain/langgraph';
|
|
14
15
|
import type { Runnable, RunnableConfig } from '@langchain/core/runnables';
|
|
15
16
|
import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
17
|
+
import type { ToolCall } from '@langchain/core/messages/tool';
|
|
16
18
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
19
|
+
import type {
|
|
20
|
+
ReplayableSubagentTool,
|
|
21
|
+
SettledSubagentToolOutput,
|
|
22
|
+
} from '@/tools/subagent/SubagentReplay';
|
|
17
23
|
import type { PreToolUseHookOutput } from '@/hooks';
|
|
18
24
|
import type * as t from '@/types';
|
|
25
|
+
import {
|
|
26
|
+
getSubagentResumeManifest,
|
|
27
|
+
stripSubagentResumeManifest,
|
|
28
|
+
SUBAGENT_PARENT_BATCH_CONFIG_KEY,
|
|
29
|
+
SUBAGENT_REPLAY_CONTROLLER,
|
|
30
|
+
} from '@/tools/subagent/SubagentReplay';
|
|
19
31
|
import { HookRegistry } from '@/hooks';
|
|
20
32
|
import { ToolNode } from '../ToolNode';
|
|
21
33
|
|
|
@@ -36,7 +48,7 @@ import { ToolNode } from '../ToolNode';
|
|
|
36
48
|
*/
|
|
37
49
|
|
|
38
50
|
function aiCall(
|
|
39
|
-
callId: string,
|
|
51
|
+
callId: string | undefined,
|
|
40
52
|
name: string,
|
|
41
53
|
args: Record<string, unknown>
|
|
42
54
|
): AIMessage {
|
|
@@ -53,7 +65,11 @@ type CompiledMessagesGraph = Runnable<unknown, { messages: BaseMessage[] }> & {
|
|
|
53
65
|
|
|
54
66
|
function buildGraph(
|
|
55
67
|
toolNode: ToolNode,
|
|
56
|
-
toolCalls: Array<{
|
|
68
|
+
toolCalls: Array<{
|
|
69
|
+
id: string | undefined;
|
|
70
|
+
name: string;
|
|
71
|
+
args: Record<string, unknown>;
|
|
72
|
+
}>
|
|
57
73
|
): CompiledMessagesGraph {
|
|
58
74
|
let agentInvocations = 0;
|
|
59
75
|
const builder = new StateGraph(MessagesAnnotation)
|
|
@@ -82,6 +98,422 @@ describe('direct-path HITL: resume scope', () => {
|
|
|
82
98
|
jest.restoreAllMocks();
|
|
83
99
|
});
|
|
84
100
|
|
|
101
|
+
it('persists a resume manifest beside a primitive child interrupt', async () => {
|
|
102
|
+
const directTool = tool(
|
|
103
|
+
async () => {
|
|
104
|
+
throw new GraphInterrupt([
|
|
105
|
+
{ id: 'primitive-interrupt', value: 'confirm child' },
|
|
106
|
+
]);
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'subagent',
|
|
110
|
+
description: 'primitive interrupt subagent',
|
|
111
|
+
schema: z.object({ description: z.string() }),
|
|
112
|
+
}
|
|
113
|
+
) as unknown as StructuredToolInterface;
|
|
114
|
+
const manifest = {
|
|
115
|
+
version: 1 as const,
|
|
116
|
+
executions: [
|
|
117
|
+
{
|
|
118
|
+
parentToolCallId: 'call_primitive',
|
|
119
|
+
childRunId: 'child-run',
|
|
120
|
+
approvalExecutionScope: 'child-approval-attempt',
|
|
121
|
+
checkpoints: [
|
|
122
|
+
{
|
|
123
|
+
threadId: 'child-thread',
|
|
124
|
+
checkpointId: 'child-checkpoint',
|
|
125
|
+
checkpointNs: '',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
graphState: {
|
|
129
|
+
toolCallSteps: [],
|
|
130
|
+
toolSessions: [],
|
|
131
|
+
toolNodes: [],
|
|
132
|
+
eagerToolUsage: [],
|
|
133
|
+
eagerToolSuppressions: [],
|
|
134
|
+
},
|
|
135
|
+
approvalReplays: [],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
const replayableTool = directTool as StructuredToolInterface &
|
|
140
|
+
ReplayableSubagentTool;
|
|
141
|
+
const getResumeManifest = jest.fn(
|
|
142
|
+
async (
|
|
143
|
+
_parentToolCallIds?: ReadonlySet<string>,
|
|
144
|
+
_config?: RunnableConfig
|
|
145
|
+
) => manifest
|
|
146
|
+
);
|
|
147
|
+
const getSettledOutput = jest.fn(
|
|
148
|
+
async (
|
|
149
|
+
_call: ToolCall,
|
|
150
|
+
_config: RunnableConfig
|
|
151
|
+
): Promise<SettledSubagentToolOutput | undefined> => undefined
|
|
152
|
+
);
|
|
153
|
+
replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
|
|
154
|
+
getResumeManifest,
|
|
155
|
+
getSettledOutput,
|
|
156
|
+
persistSettledOutput: async () => {},
|
|
157
|
+
};
|
|
158
|
+
const node = new ToolNode({
|
|
159
|
+
tools: [directTool],
|
|
160
|
+
directToolNames: new Set(['subagent']),
|
|
161
|
+
interruptingToolNames: new Set(['subagent']),
|
|
162
|
+
});
|
|
163
|
+
const graph = buildGraph(node, [
|
|
164
|
+
{
|
|
165
|
+
id: 'call_primitive',
|
|
166
|
+
name: 'subagent',
|
|
167
|
+
args: { description: 'pause with primitive data' },
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const interrupted = await graph.invoke(
|
|
172
|
+
{ messages: [] },
|
|
173
|
+
{ configurable: { thread_id: 'parent-thread' } }
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(isInterrupted(interrupted)).toBe(true);
|
|
177
|
+
if (!isInterrupted(interrupted)) {
|
|
178
|
+
throw new Error('expected primitive child interrupt');
|
|
179
|
+
}
|
|
180
|
+
const internalPayload = interrupted.__interrupt__[0].value;
|
|
181
|
+
const replayBatchKey =
|
|
182
|
+
getSettledOutput.mock.calls[0]?.[1].configurable?.[
|
|
183
|
+
SUBAGENT_PARENT_BATCH_CONFIG_KEY
|
|
184
|
+
];
|
|
185
|
+
expect(replayBatchKey).toEqual(expect.any(String));
|
|
186
|
+
expect(getResumeManifest).toHaveBeenCalledWith(
|
|
187
|
+
new Set(['call_primitive']),
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
configurable: expect.objectContaining({
|
|
190
|
+
thread_id: 'parent-thread',
|
|
191
|
+
[SUBAGENT_PARENT_BATCH_CONFIG_KEY]: replayBatchKey,
|
|
192
|
+
}),
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
expect(getSubagentResumeManifest(internalPayload)).toEqual(manifest);
|
|
196
|
+
expect(stripSubagentResumeManifest(internalPayload)).toBe('confirm child');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('preserves a one-shot approval until a reject decision is applied', async () => {
|
|
200
|
+
const sideEffect = jest.fn(() => 'must not execute');
|
|
201
|
+
const directTool = tool(async () => sideEffect(), {
|
|
202
|
+
name: 'echo',
|
|
203
|
+
description: 'one-shot approval tool',
|
|
204
|
+
schema: z.object({ command: z.string() }),
|
|
205
|
+
}) as unknown as StructuredToolInterface;
|
|
206
|
+
const registry = new HookRegistry();
|
|
207
|
+
let hookInvocations = 0;
|
|
208
|
+
registry.register('PreToolUse', {
|
|
209
|
+
once: true,
|
|
210
|
+
pattern: '^echo$',
|
|
211
|
+
hooks: [
|
|
212
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
213
|
+
hookInvocations += 1;
|
|
214
|
+
return { decision: 'ask', reason: 'review once' };
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
const node = new ToolNode({
|
|
219
|
+
tools: [directTool],
|
|
220
|
+
eventDrivenMode: true,
|
|
221
|
+
hookRegistry: registry,
|
|
222
|
+
directToolNames: new Set(['echo']),
|
|
223
|
+
humanInTheLoop: { enabled: true },
|
|
224
|
+
});
|
|
225
|
+
const graph = buildGraph(node, [
|
|
226
|
+
{ id: 'call_once', name: 'echo', args: { command: 'go' } },
|
|
227
|
+
]);
|
|
228
|
+
const config = { configurable: { thread_id: 'thread-once-reject' } };
|
|
229
|
+
|
|
230
|
+
const first = await graph.invoke({ messages: [] }, config);
|
|
231
|
+
expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
|
|
232
|
+
|
|
233
|
+
const resumed = await graph.invoke(
|
|
234
|
+
new Command({
|
|
235
|
+
resume: [{ type: 'reject', reason: 'rejected once' }],
|
|
236
|
+
}),
|
|
237
|
+
config
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(hookInvocations).toBe(1);
|
|
241
|
+
expect(sideEffect).not.toHaveBeenCalled();
|
|
242
|
+
const messages = (resumed as { messages: ToolMessage[] }).messages;
|
|
243
|
+
const toolMessage = messages.find(
|
|
244
|
+
(message) => message instanceof ToolMessage
|
|
245
|
+
);
|
|
246
|
+
expect(toolMessage?.status).toBe('error');
|
|
247
|
+
expect(String(toolMessage?.content)).toContain('rejected once');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('truncates direct respond decisions before returning them', async () => {
|
|
251
|
+
const executeTool = jest.fn(async () => 'must not execute');
|
|
252
|
+
const directTool = tool(executeTool, {
|
|
253
|
+
name: 'echo',
|
|
254
|
+
description: 'approval response tool',
|
|
255
|
+
schema: z.object({ command: z.string() }),
|
|
256
|
+
}) as unknown as StructuredToolInterface;
|
|
257
|
+
const registry = new HookRegistry();
|
|
258
|
+
registry.register('PreToolUse', {
|
|
259
|
+
hooks: [async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' })],
|
|
260
|
+
});
|
|
261
|
+
const node = new ToolNode({
|
|
262
|
+
tools: [directTool],
|
|
263
|
+
hookRegistry: registry,
|
|
264
|
+
directToolNames: new Set(['echo']),
|
|
265
|
+
humanInTheLoop: { enabled: true },
|
|
266
|
+
maxToolResultChars: 50,
|
|
267
|
+
});
|
|
268
|
+
const graph = buildGraph(node, [
|
|
269
|
+
{ id: 'call_respond', name: 'echo', args: { command: 'go' } },
|
|
270
|
+
]);
|
|
271
|
+
const config = { configurable: { thread_id: 'thread-respond-truncate' } };
|
|
272
|
+
|
|
273
|
+
const interrupted = await graph.invoke({ messages: [] }, config);
|
|
274
|
+
expect(isInterrupted(interrupted)).toBe(true);
|
|
275
|
+
|
|
276
|
+
const oversized = 'A'.repeat(200);
|
|
277
|
+
const resumed = await graph.invoke(
|
|
278
|
+
new Command({
|
|
279
|
+
resume: [{ type: 'respond', responseText: oversized }],
|
|
280
|
+
}),
|
|
281
|
+
config
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const messages = (resumed as { messages: ToolMessage[] }).messages;
|
|
285
|
+
const toolMessage = messages.find(
|
|
286
|
+
(message) => message instanceof ToolMessage
|
|
287
|
+
);
|
|
288
|
+
expect(String(toolMessage?.content).length).toBeLessThan(oversized.length);
|
|
289
|
+
expect(executeTool).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('stamps distinct assistant batches into subagent replay configs', async () => {
|
|
293
|
+
const invokedBatchKeys: unknown[] = [];
|
|
294
|
+
const directTool = tool(
|
|
295
|
+
async (_input, config) => {
|
|
296
|
+
invokedBatchKeys.push(
|
|
297
|
+
config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
|
|
298
|
+
);
|
|
299
|
+
return 'done';
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'subagent',
|
|
303
|
+
description: 'replayable direct tool',
|
|
304
|
+
schema: z.object({ description: z.string() }),
|
|
305
|
+
}
|
|
306
|
+
) as unknown as StructuredToolInterface;
|
|
307
|
+
const replayConfigKeys: unknown[] = [];
|
|
308
|
+
const replayableTool = directTool as StructuredToolInterface &
|
|
309
|
+
ReplayableSubagentTool;
|
|
310
|
+
replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
|
|
311
|
+
getSettledOutput: async (_call, config) => {
|
|
312
|
+
replayConfigKeys.push(
|
|
313
|
+
config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
|
|
314
|
+
);
|
|
315
|
+
return undefined;
|
|
316
|
+
},
|
|
317
|
+
persistSettledOutput: async () => {},
|
|
318
|
+
};
|
|
319
|
+
const node = new ToolNode({
|
|
320
|
+
tools: [directTool],
|
|
321
|
+
directToolNames: new Set(['subagent']),
|
|
322
|
+
});
|
|
323
|
+
const config = {
|
|
324
|
+
configurable: { run_id: 'run-shared', thread_id: 'thread-shared' },
|
|
325
|
+
};
|
|
326
|
+
const input = (assistantMessageId: string): { messages: AIMessage[] } => ({
|
|
327
|
+
messages: [
|
|
328
|
+
new AIMessage({
|
|
329
|
+
id: assistantMessageId,
|
|
330
|
+
content: '',
|
|
331
|
+
tool_calls: [
|
|
332
|
+
{
|
|
333
|
+
id: 'call_reused',
|
|
334
|
+
name: 'subagent',
|
|
335
|
+
args: { description: assistantMessageId },
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
}),
|
|
339
|
+
],
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await node.invoke(input('assistant-1'), config);
|
|
343
|
+
await node.invoke(input('assistant-2'), config);
|
|
344
|
+
|
|
345
|
+
expect(replayConfigKeys).toHaveLength(2);
|
|
346
|
+
expect(replayConfigKeys[0]).not.toBe(replayConfigKeys[1]);
|
|
347
|
+
expect(invokedBatchKeys).toEqual(replayConfigKeys);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('stamps the assistant batch into Send-input subagent configs', async () => {
|
|
351
|
+
const invokedBatchKeys: unknown[] = [];
|
|
352
|
+
const directTool = tool(
|
|
353
|
+
async (_input, config) => {
|
|
354
|
+
invokedBatchKeys.push(
|
|
355
|
+
config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
|
|
356
|
+
);
|
|
357
|
+
return 'done';
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: 'subagent',
|
|
361
|
+
description: 'replayable direct tool',
|
|
362
|
+
schema: z.object({ description: z.string() }),
|
|
363
|
+
}
|
|
364
|
+
) as unknown as StructuredToolInterface;
|
|
365
|
+
const replayConfigKeys: unknown[] = [];
|
|
366
|
+
const replayableTool = directTool as StructuredToolInterface &
|
|
367
|
+
ReplayableSubagentTool;
|
|
368
|
+
replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
|
|
369
|
+
getSettledOutput: async (_call, config) => {
|
|
370
|
+
replayConfigKeys.push(
|
|
371
|
+
config.configurable?.[SUBAGENT_PARENT_BATCH_CONFIG_KEY]
|
|
372
|
+
);
|
|
373
|
+
return undefined;
|
|
374
|
+
},
|
|
375
|
+
persistSettledOutput: async () => {},
|
|
376
|
+
};
|
|
377
|
+
const node = new ToolNode({
|
|
378
|
+
tools: [directTool],
|
|
379
|
+
directToolNames: new Set(['subagent']),
|
|
380
|
+
});
|
|
381
|
+
const toolCall = {
|
|
382
|
+
id: 'call_send',
|
|
383
|
+
name: 'subagent',
|
|
384
|
+
args: { description: 'send task' },
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
await node.invoke(
|
|
388
|
+
{
|
|
389
|
+
messages: [
|
|
390
|
+
new AIMessage({
|
|
391
|
+
id: 'assistant-send',
|
|
392
|
+
content: '',
|
|
393
|
+
tool_calls: [toolCall],
|
|
394
|
+
}),
|
|
395
|
+
],
|
|
396
|
+
lg_tool_call: toolCall,
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
configurable: {
|
|
400
|
+
run_id: 'run-send',
|
|
401
|
+
thread_id: 'thread-send',
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
expect(replayConfigKeys).toHaveLength(1);
|
|
407
|
+
expect(replayConfigKeys[0]).toBeDefined();
|
|
408
|
+
expect(invokedBatchKeys).toEqual(replayConfigKeys);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('fails closed when an id-less direct call requires approval', async () => {
|
|
412
|
+
const sideEffect = jest.fn(() => 'must not execute');
|
|
413
|
+
const directTool = tool(async () => sideEffect(), {
|
|
414
|
+
name: 'echo',
|
|
415
|
+
description: 'id-less approval tool',
|
|
416
|
+
schema: z.object({ command: z.string() }),
|
|
417
|
+
}) as unknown as StructuredToolInterface;
|
|
418
|
+
const registry = new HookRegistry();
|
|
419
|
+
registry.register('PreToolUse', {
|
|
420
|
+
pattern: '^echo$',
|
|
421
|
+
hooks: [
|
|
422
|
+
async (): Promise<PreToolUseHookOutput> => ({
|
|
423
|
+
decision: 'ask',
|
|
424
|
+
reason: 'review id-less call',
|
|
425
|
+
}),
|
|
426
|
+
],
|
|
427
|
+
});
|
|
428
|
+
const node = new ToolNode({
|
|
429
|
+
tools: [directTool],
|
|
430
|
+
eventDrivenMode: true,
|
|
431
|
+
hookRegistry: registry,
|
|
432
|
+
directToolNames: new Set(['echo']),
|
|
433
|
+
humanInTheLoop: { enabled: true },
|
|
434
|
+
});
|
|
435
|
+
const graph = buildGraph(node, [
|
|
436
|
+
{ id: undefined, name: 'echo', args: { command: 'go' } },
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
const result = await graph.invoke(
|
|
440
|
+
{ messages: [] },
|
|
441
|
+
{ configurable: { thread_id: 'thread-id-less-approval' } }
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
expect(isInterrupted(result)).toBe(false);
|
|
445
|
+
expect(sideEffect).not.toHaveBeenCalled();
|
|
446
|
+
const messages = (result as { messages: ToolMessage[] }).messages;
|
|
447
|
+
const toolMessage = messages.find(
|
|
448
|
+
(message) => message instanceof ToolMessage
|
|
449
|
+
);
|
|
450
|
+
expect(toolMessage?.status).toBe('error');
|
|
451
|
+
expect(String(toolMessage?.content)).toContain(
|
|
452
|
+
'requires a non-empty tool call ID'
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('reruns ordinary hooks while replaying a consumed one-shot approval', async () => {
|
|
457
|
+
const sideEffect = jest.fn(() => 'must not execute');
|
|
458
|
+
const directTool = tool(async () => sideEffect(), {
|
|
459
|
+
name: 'echo',
|
|
460
|
+
description: 'mixed replay hook tool',
|
|
461
|
+
schema: z.object({ command: z.string() }),
|
|
462
|
+
}) as unknown as StructuredToolInterface;
|
|
463
|
+
const registry = new HookRegistry();
|
|
464
|
+
let onceCalls = 0;
|
|
465
|
+
let ordinaryCalls = 0;
|
|
466
|
+
registry.register('PreToolUse', {
|
|
467
|
+
once: true,
|
|
468
|
+
pattern: '^echo$',
|
|
469
|
+
hooks: [
|
|
470
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
471
|
+
onceCalls += 1;
|
|
472
|
+
return { decision: 'ask', reason: 'review once' };
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
});
|
|
476
|
+
registry.register('PreToolUse', {
|
|
477
|
+
pattern: '^echo$',
|
|
478
|
+
hooks: [
|
|
479
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
480
|
+
ordinaryCalls += 1;
|
|
481
|
+
return ordinaryCalls === 1
|
|
482
|
+
? { decision: 'allow' }
|
|
483
|
+
: { decision: 'deny', reason: 'policy changed on resume' };
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
});
|
|
487
|
+
const node = new ToolNode({
|
|
488
|
+
tools: [directTool],
|
|
489
|
+
eventDrivenMode: true,
|
|
490
|
+
hookRegistry: registry,
|
|
491
|
+
directToolNames: new Set(['echo']),
|
|
492
|
+
humanInTheLoop: { enabled: true },
|
|
493
|
+
});
|
|
494
|
+
const graph = buildGraph(node, [
|
|
495
|
+
{ id: 'call_mixed_hooks', name: 'echo', args: { command: 'go' } },
|
|
496
|
+
]);
|
|
497
|
+
const config = { configurable: { thread_id: 'thread-mixed-hooks' } };
|
|
498
|
+
|
|
499
|
+
const first = await graph.invoke({ messages: [] }, config);
|
|
500
|
+
expect(isInterrupted(first)).toBe(true);
|
|
501
|
+
const resumed = await graph.invoke(
|
|
502
|
+
new Command({ resume: [{ type: 'approve' }] }),
|
|
503
|
+
config
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
expect(onceCalls).toBe(1);
|
|
507
|
+
expect(ordinaryCalls).toBe(2);
|
|
508
|
+
expect(sideEffect).not.toHaveBeenCalled();
|
|
509
|
+
const messages = (resumed as { messages: ToolMessage[] }).messages;
|
|
510
|
+
const toolMessage = messages.find(
|
|
511
|
+
(message) => message instanceof ToolMessage
|
|
512
|
+
);
|
|
513
|
+
expect(toolMessage?.status).toBe('error');
|
|
514
|
+
expect(String(toolMessage?.content)).toContain('policy changed on resume');
|
|
515
|
+
});
|
|
516
|
+
|
|
85
517
|
it('re-executes the direct tool body on resume when interrupt() fires from the direct path', async () => {
|
|
86
518
|
const sideEffect = jest.fn(() => 'EXECUTED');
|
|
87
519
|
const directTool = tool(async () => sideEffect(), {
|
|
@@ -375,6 +807,84 @@ describe('direct-path HITL: resume scope', () => {
|
|
|
375
807
|
});
|
|
376
808
|
});
|
|
377
809
|
|
|
810
|
+
describe('untrusted resume decisions', () => {
|
|
811
|
+
it.each([
|
|
812
|
+
[
|
|
813
|
+
{ type: 'respond' },
|
|
814
|
+
'Approval payload `respond` was missing a string `responseText`',
|
|
815
|
+
],
|
|
816
|
+
[
|
|
817
|
+
{ type: 'aproved' },
|
|
818
|
+
'Unknown approval decision type "aproved" — failing closed',
|
|
819
|
+
],
|
|
820
|
+
])(
|
|
821
|
+
'blocks and persists malformed direct decision %#',
|
|
822
|
+
async (rawDecision, expectedError) => {
|
|
823
|
+
const executeTool = jest.fn(async () => 'should-not-execute');
|
|
824
|
+
const directTool = tool(executeTool, {
|
|
825
|
+
name: 'subagent',
|
|
826
|
+
description: 'replayable direct tool',
|
|
827
|
+
schema: z.object({ description: z.string() }),
|
|
828
|
+
}) as unknown as StructuredToolInterface;
|
|
829
|
+
const persistSettledOutput = jest.fn(
|
|
830
|
+
async (
|
|
831
|
+
_call: ToolCall,
|
|
832
|
+
_config: RunnableConfig,
|
|
833
|
+
_settled: SettledSubagentToolOutput
|
|
834
|
+
): Promise<void> => {}
|
|
835
|
+
);
|
|
836
|
+
const replayableTool = directTool as StructuredToolInterface &
|
|
837
|
+
ReplayableSubagentTool;
|
|
838
|
+
replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
|
|
839
|
+
getSettledOutput: async () => undefined,
|
|
840
|
+
persistSettledOutput,
|
|
841
|
+
};
|
|
842
|
+
const registry = new HookRegistry();
|
|
843
|
+
registry.register('PreToolUse', {
|
|
844
|
+
hooks: [
|
|
845
|
+
async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' }),
|
|
846
|
+
],
|
|
847
|
+
});
|
|
848
|
+
const node = new ToolNode({
|
|
849
|
+
tools: [directTool],
|
|
850
|
+
hookRegistry: registry,
|
|
851
|
+
directToolNames: new Set(['subagent']),
|
|
852
|
+
humanInTheLoop: { enabled: true },
|
|
853
|
+
});
|
|
854
|
+
const graph = buildGraph(node, [
|
|
855
|
+
{
|
|
856
|
+
id: 'call_malformed',
|
|
857
|
+
name: 'subagent',
|
|
858
|
+
args: { description: 'inspect' },
|
|
859
|
+
},
|
|
860
|
+
]);
|
|
861
|
+
const config = {
|
|
862
|
+
configurable: { thread_id: `thread-malformed-${rawDecision.type}` },
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
await graph.invoke({ messages: [] }, config);
|
|
866
|
+
const resumed = await graph.invoke(
|
|
867
|
+
new Command({
|
|
868
|
+
resume: [rawDecision as unknown as t.ToolApprovalDecision],
|
|
869
|
+
}),
|
|
870
|
+
config
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
const messages = (resumed as { messages: ToolMessage[] }).messages;
|
|
874
|
+
const toolMessage = messages.find(
|
|
875
|
+
(message) => message instanceof ToolMessage
|
|
876
|
+
);
|
|
877
|
+
expect(toolMessage?.status).toBe('error');
|
|
878
|
+
expect(String(toolMessage?.content)).toContain(expectedError);
|
|
879
|
+
expect(executeTool).not.toHaveBeenCalled();
|
|
880
|
+
expect(persistSettledOutput).toHaveBeenCalledTimes(1);
|
|
881
|
+
expect(persistSettledOutput.mock.calls[0]?.[2].output.status).toBe(
|
|
882
|
+
'error'
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
});
|
|
887
|
+
|
|
378
888
|
describe('usage counter stability across resume (Codex P2 #30)', () => {
|
|
379
889
|
it('turn stays the same across an interrupt + resume — does not double-increment', async () => {
|
|
380
890
|
// Pre-fix the P2 #27 turn-race fix incremented before the
|