@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.
Files changed (204) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +165 -57
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  6. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
  7. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
  8. package/dist/cjs/graphs/createGraph.cjs +15 -0
  9. package/dist/cjs/graphs/createGraph.cjs.map +1 -0
  10. package/dist/cjs/graphs/index.cjs +1 -0
  11. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  12. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  13. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  14. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  15. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  16. package/dist/cjs/hooks/index.cjs.map +1 -1
  17. package/dist/cjs/hooks/types.cjs +2 -0
  18. package/dist/cjs/hooks/types.cjs.map +1 -1
  19. package/dist/cjs/llm/init.cjs +3 -3
  20. package/dist/cjs/llm/invoke.cjs +2 -2
  21. package/dist/cjs/main.cjs +34 -24
  22. package/dist/cjs/messages/format.cjs +40 -23
  23. package/dist/cjs/messages/format.cjs.map +1 -1
  24. package/dist/cjs/run.cjs +130 -53
  25. package/dist/cjs/run.cjs.map +1 -1
  26. package/dist/cjs/session/AgentSession.cjs +37 -2
  27. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  28. package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
  29. package/dist/cjs/stream.cjs +1 -1
  30. package/dist/cjs/summarization/node.cjs +6 -3
  31. package/dist/cjs/summarization/node.cjs.map +1 -1
  32. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  33. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  35. package/dist/cjs/tools/SubagentTool.cjs +4 -4
  36. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +321 -95
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  40. package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
  41. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
  42. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
  43. package/dist/cjs/tools/search/rerankers.cjs +154 -1
  44. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  45. package/dist/cjs/tools/search/tool.cjs +4 -1
  46. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1114 -233
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/cjs/tools/subagent/SubagentReplay.cjs +177 -0
  52. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  53. package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
  54. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
  55. package/dist/cjs/tools/subagent/index.cjs +1 -0
  56. package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
  57. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
  58. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  59. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  60. package/dist/cjs/utils/index.cjs +2 -2
  61. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  62. package/dist/esm/graphs/Graph.mjs +165 -57
  63. package/dist/esm/graphs/Graph.mjs.map +1 -1
  64. package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
  65. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  66. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
  67. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
  68. package/dist/esm/graphs/createGraph.mjs +15 -0
  69. package/dist/esm/graphs/createGraph.mjs.map +1 -0
  70. package/dist/esm/graphs/index.mjs +1 -0
  71. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  72. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  73. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  74. package/dist/esm/hooks/executeHooks.mjs +60 -14
  75. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  76. package/dist/esm/hooks/index.mjs.map +1 -1
  77. package/dist/esm/hooks/types.mjs +2 -1
  78. package/dist/esm/hooks/types.mjs.map +1 -1
  79. package/dist/esm/llm/init.mjs +1 -1
  80. package/dist/esm/llm/invoke.mjs +2 -2
  81. package/dist/esm/main.mjs +21 -19
  82. package/dist/esm/messages/format.mjs +40 -23
  83. package/dist/esm/messages/format.mjs.map +1 -1
  84. package/dist/esm/run.mjs +131 -54
  85. package/dist/esm/run.mjs.map +1 -1
  86. package/dist/esm/session/AgentSession.mjs +37 -2
  87. package/dist/esm/session/AgentSession.mjs.map +1 -1
  88. package/dist/esm/session/JsonlSessionStore.mjs +2 -2
  89. package/dist/esm/stream.mjs +1 -1
  90. package/dist/esm/summarization/node.mjs +6 -3
  91. package/dist/esm/summarization/node.mjs.map +1 -1
  92. package/dist/esm/tools/BashExecutor.mjs +1 -1
  93. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  94. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  95. package/dist/esm/tools/SubagentTool.mjs +4 -4
  96. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  97. package/dist/esm/tools/ToolNode.mjs +322 -96
  98. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  99. package/dist/esm/tools/ToolSearch.mjs +1 -1
  100. package/dist/esm/tools/eagerEventExecution.mjs +1 -1
  101. package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
  102. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
  103. package/dist/esm/tools/search/rerankers.mjs +154 -1
  104. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  105. package/dist/esm/tools/search/tool.mjs +4 -1
  106. package/dist/esm/tools/search/tool.mjs.map +1 -1
  107. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
  108. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
  109. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1114 -233
  110. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  111. package/dist/esm/tools/subagent/SubagentReplay.mjs +170 -0
  112. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  113. package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
  114. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
  115. package/dist/esm/tools/subagent/index.mjs +1 -0
  116. package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
  117. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
  118. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  119. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  120. package/dist/esm/utils/index.mjs +2 -2
  121. package/dist/types/agents/AgentContext.d.ts +1 -1
  122. package/dist/types/graphs/Graph.d.ts +22 -2
  123. package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
  124. package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
  125. package/dist/types/graphs/createGraph.d.ts +13 -0
  126. package/dist/types/graphs/graphFactory.d.ts +13 -0
  127. package/dist/types/graphs/index.d.ts +2 -0
  128. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  129. package/dist/types/hooks/executeHooks.d.ts +5 -1
  130. package/dist/types/hooks/index.d.ts +2 -2
  131. package/dist/types/hooks/types.d.ts +12 -0
  132. package/dist/types/run.d.ts +6 -3
  133. package/dist/types/session/AgentSession.d.ts +1 -0
  134. package/dist/types/tools/SubagentTool.d.ts +1 -1
  135. package/dist/types/tools/ToolNode.d.ts +23 -15
  136. package/dist/types/tools/search/rerankers.d.ts +28 -0
  137. package/dist/types/tools/search/types.d.ts +38 -1
  138. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
  139. package/dist/types/tools/subagent/SubagentExecutor.d.ts +70 -36
  140. package/dist/types/tools/subagent/SubagentReplay.d.ts +85 -0
  141. package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
  142. package/dist/types/tools/subagent/index.d.ts +1 -1
  143. package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
  144. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  145. package/dist/types/types/graph.d.ts +189 -24
  146. package/dist/types/types/hitl.d.ts +15 -0
  147. package/package.json +8 -2
  148. package/src/agents/AgentContext.ts +1 -1
  149. package/src/graphs/Graph.ts +349 -104
  150. package/src/graphs/MultiAgentGraph.ts +149 -47
  151. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  152. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  153. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  154. package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
  155. package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
  156. package/src/graphs/__tests__/createGraph.test.ts +206 -0
  157. package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
  158. package/src/graphs/createGraph.ts +32 -0
  159. package/src/graphs/graphFactory.ts +12 -0
  160. package/src/graphs/index.ts +2 -0
  161. package/src/hooks/HookRegistry.ts +151 -1
  162. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  163. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  164. package/src/hooks/executeHooks.ts +133 -15
  165. package/src/hooks/index.ts +3 -1
  166. package/src/hooks/types.ts +16 -0
  167. package/src/messages/format.ts +54 -26
  168. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  169. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  170. package/src/messages/formatAgentMessages.test.ts +7 -2
  171. package/src/run.ts +214 -56
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/graph-subagent.live.test.ts +242 -0
  175. package/src/specs/graph-subagent.test.ts +322 -0
  176. package/src/specs/langfuse-routing.integration.test.ts +120 -1
  177. package/src/specs/subagent.test.ts +130 -4
  178. package/src/summarization/__tests__/node.test.ts +32 -14
  179. package/src/summarization/node.ts +15 -11
  180. package/src/tools/SubagentTool.ts +4 -4
  181. package/src/tools/ToolNode.ts +643 -157
  182. package/src/tools/__tests__/SubagentExecutor.test.ts +740 -15
  183. package/src/tools/__tests__/SubagentReplay.test.ts +329 -0
  184. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  185. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +512 -2
  186. package/src/tools/__tests__/hitl.test.ts +234 -5
  187. package/src/tools/__tests__/subagentHooks.test.ts +1126 -61
  188. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  189. package/src/tools/search/rag-api-reranker.test.ts +891 -0
  190. package/src/tools/search/rerankers.ts +260 -0
  191. package/src/tools/search/tool.ts +6 -0
  192. package/src/tools/search/types.ts +45 -1
  193. package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
  194. package/src/tools/subagent/SubagentExecutor.ts +2232 -312
  195. package/src/tools/subagent/SubagentReplay.ts +584 -0
  196. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
  197. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
  198. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
  199. package/src/tools/subagent/childGraphConfig.ts +694 -0
  200. package/src/tools/subagent/index.ts +6 -0
  201. package/src/tools/subagent/runtimeLimits.ts +7 -0
  202. package/src/tools/toolOutputReferences.ts +40 -1
  203. package/src/types/graph.ts +222 -24
  204. 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 fold(outcomes: readonly HookOutcome[]): AggregatedHookResult {
305
- const agg = freshResult();
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
- agg.errors.push(outcome.error);
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
- applyContext(agg, output);
327
- applyInjectedMessages(agg, output);
328
- applyStopFlag(agg, output);
329
- applyDecision(agg, output);
330
- applyUpdatedInput(agg, output);
331
- applyUpdatedOutput(agg, output);
332
- applyAllowedDecisions(agg, output);
333
- }
334
- return agg;
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,
@@ -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,
@@ -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