@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
|
@@ -164,6 +164,102 @@ describe('HookRegistry', () => {
|
|
|
164
164
|
expect(registry.hasHookFor('PreToolUse', 'run-a')).toBe(true);
|
|
165
165
|
expect(registry.hasHookFor('PreToolUse', 'run-b')).toBe(false);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it('copies session policy without consuming the source or sibling branches', () => {
|
|
169
|
+
const registry = new HookRegistry();
|
|
170
|
+
const matcher = makePreToolUseMatcher('subagent');
|
|
171
|
+
registry.registerSession('source', 'PreToolUse', matcher);
|
|
172
|
+
|
|
173
|
+
registry.copySession('source', 'branch-a');
|
|
174
|
+
registry.copySession('source', 'branch-b');
|
|
175
|
+
|
|
176
|
+
expect(registry.getMatchers('PreToolUse', 'source')).toEqual([matcher]);
|
|
177
|
+
expect(registry.getMatchers('PreToolUse', 'branch-a')).toEqual([matcher]);
|
|
178
|
+
expect(registry.getMatchers('PreToolUse', 'branch-b')).toEqual([matcher]);
|
|
179
|
+
|
|
180
|
+
registry.clearSession('branch-a');
|
|
181
|
+
expect(registry.getMatchers('PreToolUse', 'source')).toEqual([matcher]);
|
|
182
|
+
expect(registry.getMatchers('PreToolUse', 'branch-b')).toEqual([matcher]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('copies pending one-shot approvals and clears them with the target session', () => {
|
|
186
|
+
const registry = new HookRegistry();
|
|
187
|
+
const approval = {
|
|
188
|
+
decision: 'ask' as const,
|
|
189
|
+
reason: 'review tool',
|
|
190
|
+
additionalContexts: [],
|
|
191
|
+
injectedMessages: [],
|
|
192
|
+
errors: [],
|
|
193
|
+
};
|
|
194
|
+
const key = {
|
|
195
|
+
executionScope: 'child-a',
|
|
196
|
+
agentId: 'researcher',
|
|
197
|
+
toolUseId: 'call_1',
|
|
198
|
+
};
|
|
199
|
+
const siblingKey = { ...key, executionScope: 'child-b' };
|
|
200
|
+
registry.setPendingToolApproval('source', key, approval);
|
|
201
|
+
registry.setPendingToolApproval('source', siblingKey, {
|
|
202
|
+
...approval,
|
|
203
|
+
reason: 'review sibling',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
registry.copySession('source', 'branch');
|
|
207
|
+
|
|
208
|
+
expect(registry.getPendingToolApproval('source', key)).toBe(approval);
|
|
209
|
+
expect(registry.getPendingToolApproval('branch', key)).toBe(approval);
|
|
210
|
+
expect(
|
|
211
|
+
registry.getPendingToolApproval('branch', siblingKey)?.reason
|
|
212
|
+
).toBe('review sibling');
|
|
213
|
+
registry.clearPendingToolApproval('branch', key);
|
|
214
|
+
expect(registry.getPendingToolApproval('branch', key)).toBeUndefined();
|
|
215
|
+
expect(
|
|
216
|
+
registry.getPendingToolApproval('branch', siblingKey)?.reason
|
|
217
|
+
).toBe('review sibling');
|
|
218
|
+
const checkpointSnapshot = registry.snapshotPendingToolApprovals(
|
|
219
|
+
'source',
|
|
220
|
+
'child-a'
|
|
221
|
+
);
|
|
222
|
+
expect(checkpointSnapshot).toEqual([{ key, result: approval }]);
|
|
223
|
+
registry.clearSession('branch');
|
|
224
|
+
expect(
|
|
225
|
+
registry.getPendingToolApproval('branch', siblingKey)
|
|
226
|
+
).toBeUndefined();
|
|
227
|
+
expect(registry.getPendingToolApproval('source', key)).toBe(approval);
|
|
228
|
+
registry.setPendingToolApproval('restored', key, {
|
|
229
|
+
...approval,
|
|
230
|
+
reason: 'stale approval',
|
|
231
|
+
});
|
|
232
|
+
registry.setPendingToolApproval('restored', siblingKey, {
|
|
233
|
+
...approval,
|
|
234
|
+
reason: 'preserved sibling',
|
|
235
|
+
});
|
|
236
|
+
registry.restorePendingToolApprovals(
|
|
237
|
+
'restored',
|
|
238
|
+
'child-a',
|
|
239
|
+
checkpointSnapshot
|
|
240
|
+
);
|
|
241
|
+
expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
|
|
242
|
+
expect(
|
|
243
|
+
registry.getPendingToolApproval('restored', siblingKey)?.reason
|
|
244
|
+
).toBe('preserved sibling');
|
|
245
|
+
registry.restorePendingToolApprovals('restored', 'child-b', []);
|
|
246
|
+
expect(
|
|
247
|
+
registry.getPendingToolApproval('restored', siblingKey)
|
|
248
|
+
).toBeUndefined();
|
|
249
|
+
|
|
250
|
+
const branchKey = { ...key, executionScope: 'child-a-attempt-2' };
|
|
251
|
+
registry.restorePendingToolApprovals(
|
|
252
|
+
'restored',
|
|
253
|
+
branchKey.executionScope,
|
|
254
|
+
checkpointSnapshot
|
|
255
|
+
);
|
|
256
|
+
expect(registry.getPendingToolApproval('restored', branchKey)).toBe(
|
|
257
|
+
approval
|
|
258
|
+
);
|
|
259
|
+
expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
|
|
260
|
+
registry.clearPendingToolApproval('restored', branchKey);
|
|
261
|
+
expect(registry.getPendingToolApproval('restored', key)).toBe(approval);
|
|
262
|
+
});
|
|
167
263
|
});
|
|
168
264
|
|
|
169
265
|
describe('session isolation under parallel registration', () => {
|
|
@@ -764,6 +764,166 @@ describe('executeHooks', () => {
|
|
|
764
764
|
});
|
|
765
765
|
|
|
766
766
|
describe('once: true self-removal', () => {
|
|
767
|
+
it('replays a one-shot approval until the tool decision is consumed', async () => {
|
|
768
|
+
const registry = new HookRegistry();
|
|
769
|
+
let calls = 0;
|
|
770
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
771
|
+
once: true,
|
|
772
|
+
hooks: [
|
|
773
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
774
|
+
calls += 1;
|
|
775
|
+
return { decision: 'ask', reason: 'review once' };
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
});
|
|
779
|
+
const options = {
|
|
780
|
+
registry,
|
|
781
|
+
input: preToolUseInput('Bash'),
|
|
782
|
+
matchQuery: 'Bash',
|
|
783
|
+
sessionId: 'run-1',
|
|
784
|
+
onceReplayKey: {
|
|
785
|
+
executionScope: 'child-a',
|
|
786
|
+
agentId: 'researcher',
|
|
787
|
+
toolUseId: 'call_1',
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
await executeHooks(options);
|
|
792
|
+
const replay = await executeHooks(options);
|
|
793
|
+
|
|
794
|
+
expect(replay).toMatchObject({
|
|
795
|
+
decision: 'ask',
|
|
796
|
+
reason: 'review once',
|
|
797
|
+
});
|
|
798
|
+
expect(calls).toBe(1);
|
|
799
|
+
expect(registry.getMatchers('PreToolUse', 'run-1')).toHaveLength(0);
|
|
800
|
+
|
|
801
|
+
const sibling = await executeHooks({
|
|
802
|
+
...options,
|
|
803
|
+
onceReplayKey: {
|
|
804
|
+
...options.onceReplayKey,
|
|
805
|
+
executionScope: 'child-b',
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
expect(sibling.decision).toBeUndefined();
|
|
809
|
+
|
|
810
|
+
registry.clearPendingToolApproval('run-1', options.onceReplayKey);
|
|
811
|
+
const afterDecision = await executeHooks(options);
|
|
812
|
+
expect(afterDecision.decision).toBeUndefined();
|
|
813
|
+
expect(calls).toBe(1);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('replays only one-shot contributions while rerunning ordinary hooks', async () => {
|
|
817
|
+
const registry = new HookRegistry();
|
|
818
|
+
let onceCalls = 0;
|
|
819
|
+
let ordinaryCalls = 0;
|
|
820
|
+
let ordinaryDecision: 'allow' | 'deny' = 'allow';
|
|
821
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
822
|
+
once: true,
|
|
823
|
+
hooks: [
|
|
824
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
825
|
+
onceCalls += 1;
|
|
826
|
+
return {
|
|
827
|
+
decision: 'ask',
|
|
828
|
+
reason: 'review once',
|
|
829
|
+
additionalContext: 'once-context',
|
|
830
|
+
};
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
});
|
|
834
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
835
|
+
hooks: [
|
|
836
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
837
|
+
ordinaryCalls += 1;
|
|
838
|
+
return {
|
|
839
|
+
decision: ordinaryDecision,
|
|
840
|
+
reason: `ordinary-${ordinaryDecision}`,
|
|
841
|
+
additionalContext: `ordinary-${ordinaryCalls}`,
|
|
842
|
+
};
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
});
|
|
846
|
+
const options = {
|
|
847
|
+
registry,
|
|
848
|
+
input: preToolUseInput('Bash'),
|
|
849
|
+
matchQuery: 'Bash',
|
|
850
|
+
sessionId: 'run-1',
|
|
851
|
+
onceReplayKey: {
|
|
852
|
+
executionScope: 'child-a',
|
|
853
|
+
agentId: 'researcher',
|
|
854
|
+
toolUseId: 'call_1',
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const first = await executeHooks(options);
|
|
859
|
+
ordinaryDecision = 'deny';
|
|
860
|
+
const replay = await executeHooks(options);
|
|
861
|
+
|
|
862
|
+
expect(first.decision).toBe('ask');
|
|
863
|
+
expect(replay).toMatchObject({
|
|
864
|
+
decision: 'deny',
|
|
865
|
+
reason: 'ordinary-deny',
|
|
866
|
+
additionalContexts: ['once-context', 'ordinary-2'],
|
|
867
|
+
});
|
|
868
|
+
expect(onceCalls).toBe(1);
|
|
869
|
+
expect(ordinaryCalls).toBe(2);
|
|
870
|
+
expect(
|
|
871
|
+
registry.getPendingToolApproval('run-1', options.onceReplayKey)
|
|
872
|
+
).toBeUndefined();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it('retains a consumed one-shot rewrite when an ordinary hook asks', async () => {
|
|
876
|
+
const registry = new HookRegistry();
|
|
877
|
+
let onceCalls = 0;
|
|
878
|
+
let ordinaryCalls = 0;
|
|
879
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
880
|
+
once: true,
|
|
881
|
+
hooks: [
|
|
882
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
883
|
+
onceCalls += 1;
|
|
884
|
+
return {
|
|
885
|
+
decision: 'allow',
|
|
886
|
+
updatedInput: { command: 'sanitized' },
|
|
887
|
+
};
|
|
888
|
+
},
|
|
889
|
+
],
|
|
890
|
+
});
|
|
891
|
+
registry.registerSession('run-1', 'PreToolUse', {
|
|
892
|
+
hooks: [
|
|
893
|
+
async (): Promise<PreToolUseHookOutput> => {
|
|
894
|
+
ordinaryCalls += 1;
|
|
895
|
+
return { decision: 'ask', reason: 'ordinary approval' };
|
|
896
|
+
},
|
|
897
|
+
],
|
|
898
|
+
});
|
|
899
|
+
const options = {
|
|
900
|
+
registry,
|
|
901
|
+
input: preToolUseInput('Bash'),
|
|
902
|
+
matchQuery: 'Bash',
|
|
903
|
+
sessionId: 'run-1',
|
|
904
|
+
onceReplayKey: {
|
|
905
|
+
executionScope: 'child-a',
|
|
906
|
+
agentId: 'researcher',
|
|
907
|
+
toolUseId: 'call_1',
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
const first = await executeHooks(options);
|
|
912
|
+
const replay = await executeHooks(options);
|
|
913
|
+
|
|
914
|
+
expect(first).toMatchObject({
|
|
915
|
+
decision: 'ask',
|
|
916
|
+
updatedInput: { command: 'sanitized' },
|
|
917
|
+
});
|
|
918
|
+
expect(replay).toMatchObject({
|
|
919
|
+
decision: 'ask',
|
|
920
|
+
reason: 'ordinary approval',
|
|
921
|
+
updatedInput: { command: 'sanitized' },
|
|
922
|
+
});
|
|
923
|
+
expect(onceCalls).toBe(1);
|
|
924
|
+
expect(ordinaryCalls).toBe(2);
|
|
925
|
+
});
|
|
926
|
+
|
|
767
927
|
it('removes the matcher after a successful fire', async () => {
|
|
768
928
|
const registry = new HookRegistry();
|
|
769
929
|
let calls = 0;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ToolDecision,
|
|
9
9
|
StopDecision,
|
|
10
10
|
HookCallback,
|
|
11
|
+
ToolApprovalReplayKey,
|
|
11
12
|
AggregatedHookResult,
|
|
12
13
|
} from './types';
|
|
13
14
|
import type { HookRegistry } from './HookRegistry';
|
|
@@ -28,6 +29,10 @@ export interface ExecuteHooksOptions {
|
|
|
28
29
|
sessionId?: string;
|
|
29
30
|
/** Query string matched against each matcher's pattern (tool name, etc.). */
|
|
30
31
|
matchQuery?: string;
|
|
32
|
+
/** Stable tool-call key for replaying consumed one-shot contributions. */
|
|
33
|
+
onceReplayKey?: ToolApprovalReplayKey;
|
|
34
|
+
/** Storage scope for replay state when it must outlive or branch from the hook session. */
|
|
35
|
+
onceReplaySessionId?: string;
|
|
31
36
|
/** Parent AbortSignal — combined with per-hook timeout into the hook signal. */
|
|
32
37
|
signal?: AbortSignal;
|
|
33
38
|
/** Default per-hook timeout; overridden by `matcher.timeout` when present. */
|
|
@@ -301,12 +306,79 @@ function applyAllowedDecisions(
|
|
|
301
306
|
agg.allowedDecisions = output.allowedDecisions;
|
|
302
307
|
}
|
|
303
308
|
|
|
304
|
-
function
|
|
305
|
-
|
|
309
|
+
function applyAggregatedResult(
|
|
310
|
+
target: AggregatedHookResult,
|
|
311
|
+
source: AggregatedHookResult
|
|
312
|
+
): void {
|
|
313
|
+
target.additionalContexts.push(...source.additionalContexts);
|
|
314
|
+
target.injectedMessages.push(...source.injectedMessages);
|
|
315
|
+
target.errors.push(...source.errors);
|
|
316
|
+
if (source.decision != null) {
|
|
317
|
+
applyToolDecision(target, source.decision, source.reason);
|
|
318
|
+
}
|
|
319
|
+
if (source.stopDecision != null) {
|
|
320
|
+
applyStopDecision(target, source.stopDecision, source.reason);
|
|
321
|
+
}
|
|
322
|
+
if (source.updatedInput != null) {
|
|
323
|
+
target.updatedInput = source.updatedInput;
|
|
324
|
+
}
|
|
325
|
+
if (source.updatedOutput !== undefined) {
|
|
326
|
+
target.updatedOutput = source.updatedOutput;
|
|
327
|
+
}
|
|
328
|
+
if (source.allowedDecisions != null) {
|
|
329
|
+
target.allowedDecisions = source.allowedDecisions;
|
|
330
|
+
}
|
|
331
|
+
if (source.preventContinuation === true) {
|
|
332
|
+
target.preventContinuation = true;
|
|
333
|
+
target.stopReason ??= source.stopReason;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function combineAggregatedResults(
|
|
338
|
+
first: AggregatedHookResult | undefined,
|
|
339
|
+
second: AggregatedHookResult
|
|
340
|
+
): AggregatedHookResult {
|
|
341
|
+
if (first == null) {
|
|
342
|
+
return second;
|
|
343
|
+
}
|
|
344
|
+
const combined = freshResult();
|
|
345
|
+
applyAggregatedResult(combined, first);
|
|
346
|
+
applyAggregatedResult(combined, second);
|
|
347
|
+
return combined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function createPendingApprovalReplay(
|
|
351
|
+
approvalResult: AggregatedHookResult,
|
|
352
|
+
pendingApproval: AggregatedHookResult | undefined,
|
|
353
|
+
onceContribution: AggregatedHookResult
|
|
354
|
+
): AggregatedHookResult {
|
|
355
|
+
const replay = freshResult();
|
|
356
|
+
if (pendingApproval != null) {
|
|
357
|
+
applyAggregatedResult(replay, pendingApproval);
|
|
358
|
+
}
|
|
359
|
+
applyAggregatedResult(replay, onceContribution);
|
|
360
|
+
replay.decision = 'ask';
|
|
361
|
+
replay.reason = approvalResult.reason;
|
|
362
|
+
if (approvalResult.allowedDecisions == null) {
|
|
363
|
+
delete replay.allowedDecisions;
|
|
364
|
+
} else {
|
|
365
|
+
replay.allowedDecisions = approvalResult.allowedDecisions;
|
|
366
|
+
}
|
|
367
|
+
return replay;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function fold(outcomes: readonly HookOutcome[]): {
|
|
371
|
+
aggregated: AggregatedHookResult;
|
|
372
|
+
onceReplayContribution?: AggregatedHookResult;
|
|
373
|
+
} {
|
|
374
|
+
const aggregated = freshResult();
|
|
375
|
+
const onceContribution = freshResult();
|
|
376
|
+
let hasOnceContribution = false;
|
|
306
377
|
for (const outcome of outcomes) {
|
|
378
|
+
const isOnceMatcher = outcome.matcher.once === true;
|
|
307
379
|
if (outcome.error !== null) {
|
|
308
380
|
if (outcome.matcher.internal !== true) {
|
|
309
|
-
|
|
381
|
+
aggregated.errors.push(outcome.error);
|
|
310
382
|
}
|
|
311
383
|
continue;
|
|
312
384
|
}
|
|
@@ -323,15 +395,28 @@ function fold(outcomes: readonly HookOutcome[]): AggregatedHookResult {
|
|
|
323
395
|
if (output.async === true) {
|
|
324
396
|
continue;
|
|
325
397
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
398
|
+
const targets = isOnceMatcher
|
|
399
|
+
? [aggregated, onceContribution]
|
|
400
|
+
: [aggregated];
|
|
401
|
+
if (isOnceMatcher) {
|
|
402
|
+
hasOnceContribution = true;
|
|
403
|
+
}
|
|
404
|
+
for (const target of targets) {
|
|
405
|
+
applyContext(target, output);
|
|
406
|
+
applyInjectedMessages(target, output);
|
|
407
|
+
applyStopFlag(target, output);
|
|
408
|
+
applyDecision(target, output);
|
|
409
|
+
applyUpdatedInput(target, output);
|
|
410
|
+
applyUpdatedOutput(target, output);
|
|
411
|
+
applyAllowedDecisions(target, output);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
aggregated,
|
|
416
|
+
...(hasOnceContribution
|
|
417
|
+
? { onceReplayContribution: onceContribution }
|
|
418
|
+
: {}),
|
|
419
|
+
};
|
|
335
420
|
}
|
|
336
421
|
|
|
337
422
|
/**
|
|
@@ -396,14 +481,21 @@ export async function executeHooks(
|
|
|
396
481
|
input,
|
|
397
482
|
sessionId,
|
|
398
483
|
matchQuery,
|
|
484
|
+
onceReplayKey,
|
|
485
|
+
onceReplaySessionId,
|
|
399
486
|
signal,
|
|
400
487
|
timeoutMs = DEFAULT_HOOK_TIMEOUT_MS,
|
|
401
488
|
logger,
|
|
402
489
|
} = opts;
|
|
403
490
|
const event = input.hook_event_name;
|
|
491
|
+
const replaySessionId = onceReplaySessionId ?? sessionId;
|
|
492
|
+
const pendingApproval =
|
|
493
|
+
event === 'PreToolUse' && replaySessionId != null && onceReplayKey != null
|
|
494
|
+
? registry.getPendingToolApproval(replaySessionId, onceReplayKey)
|
|
495
|
+
: undefined;
|
|
404
496
|
const matchers = registry.getMatchers(event, sessionId);
|
|
405
497
|
if (matchers.length === 0) {
|
|
406
|
-
return freshResult();
|
|
498
|
+
return pendingApproval ?? freshResult();
|
|
407
499
|
}
|
|
408
500
|
|
|
409
501
|
// --- SYNC CRITICAL SECTION: once-matcher removal must complete before any await ---
|
|
@@ -424,12 +516,38 @@ export async function executeHooks(
|
|
|
424
516
|
}
|
|
425
517
|
// --- END SYNC CRITICAL SECTION ---
|
|
426
518
|
if (tasks.length === 0) {
|
|
427
|
-
return freshResult();
|
|
519
|
+
return pendingApproval ?? freshResult();
|
|
428
520
|
}
|
|
429
521
|
|
|
430
522
|
const outcomes = (await Promise.all(tasks)).flat();
|
|
431
523
|
reportErrors(outcomes, event, logger);
|
|
432
|
-
const aggregated = fold(outcomes);
|
|
524
|
+
const { aggregated: liveResult, onceReplayContribution } = fold(outcomes);
|
|
525
|
+
const aggregated = combineAggregatedResults(pendingApproval, liveResult);
|
|
526
|
+
if (
|
|
527
|
+
event === 'PreToolUse' &&
|
|
528
|
+
aggregated.decision === 'ask' &&
|
|
529
|
+
onceReplayContribution != null &&
|
|
530
|
+
replaySessionId != null &&
|
|
531
|
+
onceReplayKey != null
|
|
532
|
+
) {
|
|
533
|
+
registry.setPendingToolApproval(
|
|
534
|
+
replaySessionId,
|
|
535
|
+
onceReplayKey,
|
|
536
|
+
createPendingApprovalReplay(
|
|
537
|
+
aggregated,
|
|
538
|
+
pendingApproval,
|
|
539
|
+
onceReplayContribution
|
|
540
|
+
)
|
|
541
|
+
);
|
|
542
|
+
} else if (
|
|
543
|
+
event === 'PreToolUse' &&
|
|
544
|
+
aggregated.decision !== 'ask' &&
|
|
545
|
+
pendingApproval != null &&
|
|
546
|
+
replaySessionId != null &&
|
|
547
|
+
onceReplayKey != null
|
|
548
|
+
) {
|
|
549
|
+
registry.clearPendingToolApproval(replaySessionId, onceReplayKey);
|
|
550
|
+
}
|
|
433
551
|
/**
|
|
434
552
|
* Centralized `preventContinuation` propagation: when any hook (across
|
|
435
553
|
* any callsite — RunStart, PreToolUse, PostToolBatch, SubagentStop,
|
package/src/hooks/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export type {
|
|
|
42
42
|
WorkspacePolicyConfig,
|
|
43
43
|
PathExtractor,
|
|
44
44
|
} from './createWorkspacePolicyHook';
|
|
45
|
-
export { HOOK_EVENTS } from './types';
|
|
45
|
+
export { HOOK_EVENTS, TOOL_APPROVAL_EXECUTION_SCOPE_CONFIG_KEY } from './types';
|
|
46
46
|
export type {
|
|
47
47
|
HookEvent,
|
|
48
48
|
HookInput,
|
|
@@ -56,6 +56,8 @@ export type {
|
|
|
56
56
|
BaseHookOutput,
|
|
57
57
|
ToolDecision,
|
|
58
58
|
StopDecision,
|
|
59
|
+
ToolApprovalReplayKey,
|
|
60
|
+
ToolApprovalReplaySnapshot,
|
|
59
61
|
AggregatedHookResult,
|
|
60
62
|
RunStartHookInput,
|
|
61
63
|
UserPromptSubmitHookInput,
|
package/src/hooks/types.ts
CHANGED
|
@@ -27,6 +27,9 @@ export const HOOK_EVENTS = [
|
|
|
27
27
|
'PostCompact',
|
|
28
28
|
] as const;
|
|
29
29
|
|
|
30
|
+
export const TOOL_APPROVAL_EXECUTION_SCOPE_CONFIG_KEY =
|
|
31
|
+
'__librechat_tool_approval_execution_scope';
|
|
32
|
+
|
|
30
33
|
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
31
34
|
|
|
32
35
|
/** Tool-gating decision; executeHooks folds with `deny > ask > allow` precedence. */
|
|
@@ -393,6 +396,19 @@ export interface PreToolUseHookOutput extends BaseHookOutput {
|
|
|
393
396
|
allowedDecisions?: ReadonlyArray<'approve' | 'reject' | 'edit' | 'respond'>;
|
|
394
397
|
}
|
|
395
398
|
|
|
399
|
+
/** Stable identity for replaying a consumed one-shot tool approval. */
|
|
400
|
+
export interface ToolApprovalReplayKey {
|
|
401
|
+
executionScope: string;
|
|
402
|
+
agentId: string;
|
|
403
|
+
toolUseId: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Checkpoint-safe copy of a consumed one-shot approval contribution. */
|
|
407
|
+
export interface ToolApprovalReplaySnapshot {
|
|
408
|
+
key: ToolApprovalReplayKey;
|
|
409
|
+
result: AggregatedHookResult;
|
|
410
|
+
}
|
|
411
|
+
|
|
396
412
|
export interface PostToolUseHookOutput extends BaseHookOutput {
|
|
397
413
|
/**
|
|
398
414
|
* Replacement tool output. Flows through the aggregated result so the
|