@librechat/agents 3.3.13 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist/cjs/graphs/Graph.cjs +121 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs.map +1 -1
  8. package/dist/cjs/hooks/types.cjs +2 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/llm/anthropic/index.cjs +35 -206
  11. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  12. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  13. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  14. package/dist/cjs/llm/google/index.cjs +19 -12
  15. package/dist/cjs/llm/google/index.cjs.map +1 -1
  16. package/dist/cjs/llm/init.cjs +3 -3
  17. package/dist/cjs/llm/invoke.cjs +2 -2
  18. package/dist/cjs/llm/mistral/index.cjs +26 -0
  19. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  20. package/dist/cjs/llm/openai/index.cjs +82 -80
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  23. package/dist/cjs/llm/providers.cjs +19 -19
  24. package/dist/cjs/llm/providers.cjs.map +1 -1
  25. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  26. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  27. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  28. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  29. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  30. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  31. package/dist/cjs/main.cjs +25 -16
  32. package/dist/cjs/messages/format.cjs +40 -23
  33. package/dist/cjs/messages/format.cjs.map +1 -1
  34. package/dist/cjs/run.cjs +82 -15
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/AgentSession.cjs +37 -2
  37. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  38. package/dist/cjs/stream.cjs +1 -1
  39. package/dist/cjs/summarization/node.cjs +6 -3
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  42. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  43. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  44. package/dist/cjs/tools/ToolNode.cjs +316 -94
  45. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  46. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +664 -80
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  50. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  51. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  52. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  53. package/dist/cjs/utils/index.cjs +2 -2
  54. package/dist/cjs/utils/tokens.cjs +76 -29
  55. package/dist/cjs/utils/tokens.cjs.map +1 -1
  56. package/dist/esm/graphs/Graph.mjs +121 -27
  57. package/dist/esm/graphs/Graph.mjs.map +1 -1
  58. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  59. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  60. package/dist/esm/hooks/executeHooks.mjs +60 -14
  61. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  62. package/dist/esm/hooks/index.mjs.map +1 -1
  63. package/dist/esm/hooks/types.mjs +2 -1
  64. package/dist/esm/hooks/types.mjs.map +1 -1
  65. package/dist/esm/llm/anthropic/index.mjs +34 -205
  66. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  67. package/dist/esm/llm/bedrock/index.mjs +120 -240
  68. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  69. package/dist/esm/llm/google/index.mjs +19 -12
  70. package/dist/esm/llm/google/index.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +1 -1
  72. package/dist/esm/llm/invoke.mjs +2 -2
  73. package/dist/esm/llm/mistral/index.mjs +26 -0
  74. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  75. package/dist/esm/llm/openai/index.mjs +82 -80
  76. package/dist/esm/llm/openai/index.mjs.map +1 -1
  77. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  78. package/dist/esm/llm/providers.mjs +3 -3
  79. package/dist/esm/llm/providers.mjs.map +1 -1
  80. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  81. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  82. package/dist/esm/llm/stream/smoother.mjs +365 -0
  83. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  84. package/dist/esm/llm/vertexai/index.mjs +13 -1
  85. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  86. package/dist/esm/main.mjs +13 -11
  87. package/dist/esm/messages/format.mjs +40 -23
  88. package/dist/esm/messages/format.mjs.map +1 -1
  89. package/dist/esm/run.mjs +83 -16
  90. package/dist/esm/run.mjs.map +1 -1
  91. package/dist/esm/session/AgentSession.mjs +37 -2
  92. package/dist/esm/session/AgentSession.mjs.map +1 -1
  93. package/dist/esm/stream.mjs +1 -1
  94. package/dist/esm/summarization/node.mjs +6 -3
  95. package/dist/esm/summarization/node.mjs.map +1 -1
  96. package/dist/esm/tools/BashExecutor.mjs +1 -1
  97. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  98. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  99. package/dist/esm/tools/ToolNode.mjs +317 -95
  100. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  101. package/dist/esm/tools/ToolSearch.mjs +1 -1
  102. package/dist/esm/tools/subagent/SubagentExecutor.mjs +665 -81
  103. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  104. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  105. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  106. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  107. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  108. package/dist/esm/utils/index.mjs +2 -2
  109. package/dist/esm/utils/tokens.mjs +76 -30
  110. package/dist/esm/utils/tokens.mjs.map +1 -1
  111. package/dist/types/graphs/Graph.d.ts +19 -1
  112. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  113. package/dist/types/hooks/executeHooks.d.ts +5 -1
  114. package/dist/types/hooks/index.d.ts +2 -2
  115. package/dist/types/hooks/types.d.ts +12 -0
  116. package/dist/types/index.d.ts +3 -0
  117. package/dist/types/llm/google/index.d.ts +2 -0
  118. package/dist/types/llm/mistral/index.d.ts +11 -0
  119. package/dist/types/llm/openai/index.d.ts +20 -4
  120. package/dist/types/llm/openrouter/index.d.ts +4 -1
  121. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  122. package/dist/types/llm/stream/smoother.d.ts +95 -0
  123. package/dist/types/llm/vertexai/index.d.ts +2 -0
  124. package/dist/types/run.d.ts +4 -1
  125. package/dist/types/session/AgentSession.d.ts +1 -0
  126. package/dist/types/tools/ToolNode.d.ts +23 -15
  127. package/dist/types/tools/subagent/SubagentExecutor.d.ts +49 -7
  128. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  129. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  130. package/dist/types/types/graph.d.ts +5 -5
  131. package/dist/types/types/hitl.d.ts +15 -0
  132. package/dist/types/types/llm.d.ts +21 -14
  133. package/dist/types/utils/tokens.d.ts +10 -0
  134. package/package.json +6 -2
  135. package/src/graphs/Graph.ts +257 -52
  136. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  137. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  138. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  139. package/src/hooks/HookRegistry.ts +151 -1
  140. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  141. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  142. package/src/hooks/executeHooks.ts +133 -15
  143. package/src/hooks/index.ts +3 -1
  144. package/src/hooks/types.ts +16 -0
  145. package/src/index.ts +8 -0
  146. package/src/llm/anthropic/index.ts +85 -354
  147. package/src/llm/bedrock/index.ts +174 -390
  148. package/src/llm/bedrock/llm.spec.ts +2 -0
  149. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  150. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  151. package/src/llm/google/index.ts +17 -18
  152. package/src/llm/google/streamSmoothing.test.ts +121 -0
  153. package/src/llm/mistral/index.ts +33 -0
  154. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  155. package/src/llm/openai/deepseek.test.ts +56 -0
  156. package/src/llm/openai/index.ts +119 -126
  157. package/src/llm/openrouter/index.ts +4 -1
  158. package/src/llm/providers.ts +3 -3
  159. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  160. package/src/llm/stream/chunkAdapters.ts +317 -0
  161. package/src/llm/stream/reassembly.test.ts +241 -0
  162. package/src/llm/stream/smoother.bench.test.ts +155 -0
  163. package/src/llm/stream/smoother.test.ts +519 -0
  164. package/src/llm/stream/smoother.ts +574 -0
  165. package/src/llm/vertexai/index.ts +17 -1
  166. package/src/llm/vertexai/streamSmoothing.test.ts +109 -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 +164 -18
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/subagent.test.ts +44 -0
  175. package/src/specs/tokens.test.ts +159 -14
  176. package/src/summarization/__tests__/node.test.ts +32 -14
  177. package/src/summarization/node.ts +15 -11
  178. package/src/tools/ToolNode.ts +631 -157
  179. package/src/tools/__tests__/SubagentExecutor.test.ts +590 -7
  180. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  181. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  182. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  183. package/src/tools/__tests__/hitl.test.ts +234 -5
  184. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  185. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  186. package/src/tools/subagent/SubagentExecutor.ts +1438 -136
  187. package/src/tools/subagent/SubagentReplay.ts +575 -0
  188. package/src/tools/toolOutputReferences.ts +40 -1
  189. package/src/types/graph.ts +5 -5
  190. package/src/types/hitl.ts +16 -0
  191. package/src/types/llm.ts +53 -36
  192. package/src/utils/tokens.ts +115 -30
@@ -0,0 +1,300 @@
1
+ import {
2
+ getSubagentResumeManifest,
3
+ attachSubagentResumeManifest,
4
+ requireValidSubagentResumeManifest,
5
+ stripSubagentResumeManifest,
6
+ } from '@/tools/subagent/SubagentReplay';
7
+
8
+ describe('SubagentReplay manifest', () => {
9
+ const execution = {
10
+ parentToolCallId: 'call_parent',
11
+ childRunId: 'child-run',
12
+ approvalExecutionScope: 'child-approval-scope',
13
+ checkpoints: [
14
+ {
15
+ threadId: 'child-thread',
16
+ checkpointId: 'checkpoint-root',
17
+ checkpointNs: '',
18
+ },
19
+ {
20
+ threadId: 'child-thread',
21
+ checkpointId: 'checkpoint-agent',
22
+ checkpointNs: 'agent:task',
23
+ },
24
+ ],
25
+ graphState: {
26
+ toolCallSteps: [{ toolCallId: 'call_tool', stepId: 'step_tool' }],
27
+ toolSessions: [
28
+ {
29
+ toolName: 'execute_code',
30
+ context: { session_id: 'session', lastUpdated: 1 },
31
+ },
32
+ ],
33
+ toolNodes: [
34
+ {
35
+ stateKey: 'child-agent',
36
+ toolUsageCounts: [{ toolName: 'calculator', count: 1 }],
37
+ directPathTurns: [{ toolCallId: 'call_tool', turn: 0 }],
38
+ },
39
+ ],
40
+ eagerToolUsage: [
41
+ {
42
+ agentId: 'child-agent',
43
+ toolUsageCounts: [{ toolName: 'calculator', count: 1 }],
44
+ },
45
+ ],
46
+ eagerToolSuppressions: ['unstable_search'],
47
+ toolOutputReferences: {
48
+ entries: [{ key: 'tool0turn0', value: '42' }],
49
+ turnCounter: 1,
50
+ warnedNonStringTools: [],
51
+ },
52
+ },
53
+ approvalReplays: [
54
+ {
55
+ key: {
56
+ executionScope: 'child-approval-scope',
57
+ agentId: 'child-agent',
58
+ toolUseId: 'call_tool',
59
+ },
60
+ result: {
61
+ decision: 'ask' as const,
62
+ reason: 'review tool',
63
+ additionalContexts: [],
64
+ injectedMessages: [],
65
+ errors: [],
66
+ },
67
+ },
68
+ ],
69
+ };
70
+
71
+ it('round-trips a private manifest without exposing it publicly', () => {
72
+ const manifest = { version: 1 as const, executions: [execution] };
73
+ const payload = attachSubagentResumeManifest(
74
+ { type: 'tool_approval', visible: true },
75
+ manifest
76
+ );
77
+
78
+ expect(getSubagentResumeManifest(payload)).toEqual(manifest);
79
+ expect(stripSubagentResumeManifest(payload)).toEqual({
80
+ type: 'tool_approval',
81
+ visible: true,
82
+ });
83
+ });
84
+
85
+ it.each([
86
+ ['string', 'approve this child'],
87
+ ['number', 42],
88
+ ['null', null],
89
+ ['array', ['approve', { call: 1 }]],
90
+ ])(
91
+ 'round-trips a private manifest around a %s interrupt payload',
92
+ (_label, visiblePayload) => {
93
+ const manifest = { version: 1 as const, executions: [execution] };
94
+ const payload = attachSubagentResumeManifest(visiblePayload, manifest);
95
+
96
+ expect(getSubagentResumeManifest(payload)).toEqual(manifest);
97
+ expect(stripSubagentResumeManifest(payload)).toEqual(visiblePayload);
98
+ }
99
+ );
100
+
101
+ it.each([
102
+ ['Map', new Map([['decision', 'approve']])],
103
+ ['Set', new Set(['approve'])],
104
+ ['RegExp', /^approve$/i],
105
+ ])(
106
+ 'round-trips a private manifest around a %s interrupt payload',
107
+ (_label, visiblePayload) => {
108
+ const manifest = { version: 1 as const, executions: [execution] };
109
+ const payload = attachSubagentResumeManifest(visiblePayload, manifest);
110
+
111
+ expect(getSubagentResumeManifest(payload)).toEqual(manifest);
112
+ expect(stripSubagentResumeManifest(payload)).toBe(visiblePayload);
113
+ }
114
+ );
115
+
116
+ it.each([
117
+ {
118
+ __librechat_subagent_resume_manifest: { visible: true },
119
+ value: 'manifest collision',
120
+ },
121
+ {
122
+ __librechat_subagent_resume_wrapper: 1,
123
+ __librechat_subagent_resume_payload: 'visible nested value',
124
+ value: 'wrapper collision',
125
+ },
126
+ ])(
127
+ 'preserves a custom payload containing private transport keys',
128
+ (value) => {
129
+ const manifest = { version: 1 as const, executions: [execution] };
130
+ const payload = attachSubagentResumeManifest(value, manifest);
131
+
132
+ expect(stripSubagentResumeManifest(value)).toBe(value);
133
+ expect(getSubagentResumeManifest(payload)).toEqual(manifest);
134
+ expect(stripSubagentResumeManifest(payload)).toBe(value);
135
+ }
136
+ );
137
+
138
+ it('updates the manifest on an existing inline payload', () => {
139
+ const firstManifest = { version: 1 as const, executions: [execution] };
140
+ const nextManifest = {
141
+ version: 1 as const,
142
+ executions: [{ ...execution, childRunId: 'next-inline-child-run' }],
143
+ };
144
+ const firstPayload = attachSubagentResumeManifest(
145
+ { type: 'tool_approval', visible: true },
146
+ firstManifest
147
+ );
148
+ const nextPayload = attachSubagentResumeManifest(
149
+ firstPayload,
150
+ nextManifest
151
+ );
152
+
153
+ expect(getSubagentResumeManifest(nextPayload)).toEqual(nextManifest);
154
+ expect(stripSubagentResumeManifest(nextPayload)).toEqual({
155
+ type: 'tool_approval',
156
+ visible: true,
157
+ });
158
+ });
159
+
160
+ it('updates the manifest on an existing private wrapper', () => {
161
+ const firstManifest = { version: 1 as const, executions: [execution] };
162
+ const nextManifest = {
163
+ version: 1 as const,
164
+ executions: [{ ...execution, childRunId: 'next-child-run' }],
165
+ };
166
+ const firstPayload = attachSubagentResumeManifest(
167
+ 'visible payload',
168
+ firstManifest
169
+ );
170
+ const nextPayload = attachSubagentResumeManifest(
171
+ firstPayload,
172
+ nextManifest
173
+ );
174
+
175
+ expect(getSubagentResumeManifest(nextPayload)).toEqual(nextManifest);
176
+ expect(stripSubagentResumeManifest(nextPayload)).toBe('visible payload');
177
+ });
178
+
179
+ it('rejects ambiguous or cross-execution replay data', () => {
180
+ const mismatchedScope = {
181
+ ...execution,
182
+ approvalReplays: [
183
+ {
184
+ ...execution.approvalReplays[0],
185
+ key: {
186
+ ...execution.approvalReplays[0].key,
187
+ executionScope: 'different-child',
188
+ },
189
+ },
190
+ ],
191
+ };
192
+ const duplicateParent = {
193
+ version: 1 as const,
194
+ executions: [execution, { ...execution }],
195
+ };
196
+ const duplicateToolCall = {
197
+ ...execution,
198
+ graphState: {
199
+ ...execution.graphState,
200
+ toolCallSteps: [
201
+ ...execution.graphState.toolCallSteps,
202
+ { ...execution.graphState.toolCallSteps[0] },
203
+ ],
204
+ },
205
+ };
206
+
207
+ expect(
208
+ getSubagentResumeManifest(
209
+ attachSubagentResumeManifest(
210
+ { type: 'tool_approval' },
211
+ { version: 1, executions: [mismatchedScope] }
212
+ )
213
+ )
214
+ ).toBeUndefined();
215
+ expect(
216
+ getSubagentResumeManifest(
217
+ attachSubagentResumeManifest({ type: 'tool_approval' }, duplicateParent)
218
+ )
219
+ ).toBeUndefined();
220
+ expect(
221
+ getSubagentResumeManifest(
222
+ attachSubagentResumeManifest(
223
+ { type: 'tool_approval' },
224
+ { version: 1, executions: [duplicateToolCall] }
225
+ )
226
+ )
227
+ ).toBeUndefined();
228
+ });
229
+
230
+ it('fails closed for malformed nested references', () => {
231
+ const malformed = {
232
+ __librechat_subagent_resume_manifest: {
233
+ version: 1,
234
+ executions: [{ ...execution, checkpoints: [null] }],
235
+ },
236
+ };
237
+ expect(getSubagentResumeManifest(malformed)).toBeUndefined();
238
+ expect(() => requireValidSubagentResumeManifest(malformed)).toThrow(
239
+ 'Invalid subagent resume manifest.'
240
+ );
241
+ expect(
242
+ getSubagentResumeManifest({
243
+ __librechat_subagent_resume_manifest: {
244
+ version: 1,
245
+ executions: [
246
+ {
247
+ ...execution,
248
+ graphState: {
249
+ ...execution.graphState,
250
+ toolCallSteps: [null],
251
+ },
252
+ },
253
+ ],
254
+ },
255
+ })
256
+ ).toBeUndefined();
257
+ });
258
+
259
+ it('rejects duplicate checkpoint namespaces and approval replay keys', () => {
260
+ const duplicateCheckpointNamespace = {
261
+ ...execution,
262
+ checkpoints: [execution.checkpoints[0], { ...execution.checkpoints[0] }],
263
+ };
264
+ const duplicateApprovalReplay = {
265
+ ...execution,
266
+ approvalReplays: [
267
+ execution.approvalReplays[0],
268
+ { ...execution.approvalReplays[0] },
269
+ ],
270
+ };
271
+
272
+ for (const invalidExecution of [
273
+ duplicateCheckpointNamespace,
274
+ duplicateApprovalReplay,
275
+ ]) {
276
+ expect(
277
+ getSubagentResumeManifest(
278
+ attachSubagentResumeManifest(
279
+ { type: 'tool_approval' },
280
+ { version: 1, executions: [invalidExecution] }
281
+ )
282
+ )
283
+ ).toBeUndefined();
284
+ }
285
+ });
286
+
287
+ it('rejects cyclic descendant manifests', () => {
288
+ const cyclic: {
289
+ version: number;
290
+ executions: Array<typeof execution & { descendant?: object }>;
291
+ } = { version: 1, executions: [{ ...execution }] };
292
+ cyclic.executions[0].descendant = cyclic;
293
+
294
+ expect(
295
+ getSubagentResumeManifest({
296
+ __librechat_subagent_resume_manifest: cyclic,
297
+ })
298
+ ).toBeUndefined();
299
+ });
300
+ });
@@ -1,15 +1,22 @@
1
1
  import { z } from 'zod';
2
2
  import { tool } from '@langchain/core/tools';
3
+ import { GraphInterrupt } from '@langchain/langgraph';
3
4
  import { AIMessage, ToolMessage } from '@langchain/core/messages';
4
5
  import { describe, it, expect, jest, afterEach } from '@jest/globals';
5
6
  import type { StructuredToolInterface } from '@langchain/core/tools';
6
7
  import type { RunnableConfig } from '@langchain/core/runnables';
8
+ import type {
9
+ ReplayableSubagentTool,
10
+ SettledSubagentToolOutput,
11
+ } from '@/tools/subagent/SubagentReplay';
12
+ import type { PreToolUseHookOutput } from '@/hooks';
7
13
  import type * as t from '@/types';
8
- import * as events from '@/utils/events';
9
14
  import {
10
15
  StreamLimitExceededError,
11
16
  RUN_BREAKER_SCOPE_CONFIG_KEY,
12
17
  } from '@/llm/streamLimits';
18
+ import { SUBAGENT_REPLAY_CONTROLLER } from '@/tools/subagent/SubagentReplay';
19
+ import * as events from '@/utils/events';
13
20
  import { GraphEvents } from '@/common';
14
21
  import { HookRegistry } from '@/hooks';
15
22
  import { ToolNode } from '../ToolNode';
@@ -212,6 +219,202 @@ describe('ToolNode breaker signal composition', () => {
212
219
  expect(sideEffectRan).toBe(false);
213
220
  });
214
221
 
222
+ it('prioritizes a sibling breaker trip over an approval interrupt', async () => {
223
+ const breaker = new AbortController();
224
+ const trip = new StreamLimitExceededError({
225
+ kind: 'tool_call_args',
226
+ limit: 10,
227
+ observed: 11,
228
+ toolName: 'db_query',
229
+ });
230
+ const approval = createSignalBlindTool('ask_question', async () => {
231
+ throw new GraphInterrupt([]);
232
+ });
233
+ const tripper = createSignalBlindTool('tripping_child', async () => {
234
+ breaker.abort(trip);
235
+ return 'tripped';
236
+ });
237
+ const node = new ToolNode({
238
+ tools: [approval, tripper],
239
+ interruptingToolNames: new Set(['ask_question', 'tripping_child']),
240
+ getBreakerSignal: () => breaker.signal,
241
+ });
242
+
243
+ await expect(
244
+ node.invoke({
245
+ messages: [
246
+ new AIMessage({
247
+ content: '',
248
+ tool_calls: [
249
+ { id: 'call_1', name: 'ask_question', args: {} },
250
+ { id: 'call_2', name: 'tripping_child', args: {} },
251
+ ],
252
+ }),
253
+ ],
254
+ })
255
+ ).rejects.toBe(trip);
256
+ });
257
+
258
+ it('reuses terminal interrupting sibling outputs across replay', async () => {
259
+ let terminalRuns = 0;
260
+ let terminalPreHooks = 0;
261
+ let approvalRuns = 0;
262
+ const completions: Array<{
263
+ result?: { tool_call?: { id?: string; args?: string } };
264
+ }> = [];
265
+ jest
266
+ .spyOn(events, 'safeDispatchCustomEvent')
267
+ .mockImplementation(async (event, data): Promise<boolean> => {
268
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
269
+ completions.push(data as (typeof completions)[number]);
270
+ }
271
+ return true;
272
+ });
273
+ const hookRegistry = new HookRegistry();
274
+ hookRegistry.register('PreToolUse', {
275
+ hooks: [
276
+ async (input): Promise<PreToolUseHookOutput> => {
277
+ if (input.toolName !== 'terminal_child') {
278
+ return {};
279
+ }
280
+ terminalPreHooks += 1;
281
+ return {
282
+ additionalContext: 'cached terminal context',
283
+ updatedInput: { rewritten: true },
284
+ };
285
+ },
286
+ ],
287
+ });
288
+ const terminal = createSignalBlindTool('terminal_child', async () => {
289
+ terminalRuns += 1;
290
+ return 'completed';
291
+ });
292
+ const approval = createSignalBlindTool('approval_child', async () => {
293
+ approvalRuns += 1;
294
+ if (approvalRuns === 1) {
295
+ throw new GraphInterrupt([
296
+ { id: 'approval-interrupt', value: { type: 'approval' } },
297
+ ]);
298
+ }
299
+ return 'approved';
300
+ });
301
+ const node = new ToolNode({
302
+ tools: [terminal, approval],
303
+ interruptingToolNames: new Set(['terminal_child', 'approval_child']),
304
+ hookRegistry,
305
+ toolCallStepIds: new Map([
306
+ ['call_terminal', 'step_terminal'],
307
+ ['call_approval', 'step_approval'],
308
+ ]),
309
+ });
310
+ const input = {
311
+ messages: [
312
+ new AIMessage({
313
+ content: '',
314
+ tool_calls: [
315
+ { id: 'call_terminal', name: 'terminal_child', args: {} },
316
+ { id: 'call_approval', name: 'approval_child', args: {} },
317
+ ],
318
+ }),
319
+ ],
320
+ };
321
+
322
+ await expect(node.invoke(input)).rejects.toBeInstanceOf(GraphInterrupt);
323
+ const replayResult = await node.invoke(input);
324
+ expect(terminalRuns).toBe(1);
325
+ expect(terminalPreHooks).toBe(1);
326
+ expect(JSON.stringify(replayResult)).toContain('cached terminal context');
327
+ const terminalCompletion = completions.find(
328
+ (completion) => completion.result?.tool_call?.id === 'call_terminal'
329
+ );
330
+ expect(terminalCompletion?.result?.tool_call?.args).toContain(
331
+ '"rewritten":true'
332
+ );
333
+
334
+ await node.invoke({
335
+ messages: [
336
+ new AIMessage({
337
+ content: '',
338
+ tool_calls: [
339
+ {
340
+ id: 'call_terminal',
341
+ name: 'terminal_child',
342
+ args: { laterTurn: true },
343
+ type: 'tool_call',
344
+ },
345
+ ],
346
+ }),
347
+ ],
348
+ });
349
+ expect(terminalRuns).toBe(2);
350
+ expect(terminalPreHooks).toBe(2);
351
+ });
352
+
353
+ it('persists a hook-terminal outcome before a sibling interrupts', async () => {
354
+ const persistedOutputs: SettledSubagentToolOutput[] = [];
355
+ const terminal = createSignalBlindTool('terminal_child', async () => {
356
+ throw new Error('denied tool must not execute');
357
+ }) as StructuredToolInterface & ReplayableSubagentTool;
358
+ terminal[SUBAGENT_REPLAY_CONTROLLER] = {
359
+ getSettledOutput: async () => undefined,
360
+ persistSettledOutput: async (_call, _config, settled): Promise<void> => {
361
+ persistedOutputs.push(settled);
362
+ },
363
+ };
364
+ const approval = createSignalBlindTool('approval_child', async () => {
365
+ throw new GraphInterrupt([
366
+ { id: 'approval-interrupt', value: { type: 'approval' } },
367
+ ]);
368
+ });
369
+ const hookRegistry = new HookRegistry();
370
+ hookRegistry.register('PreToolUse', {
371
+ hooks: [
372
+ async (input): Promise<PreToolUseHookOutput> =>
373
+ input.toolName === 'terminal_child'
374
+ ? { decision: 'deny', reason: 'blocked by policy' }
375
+ : {},
376
+ ],
377
+ });
378
+ const node = new ToolNode({
379
+ tools: [terminal, approval],
380
+ hookRegistry,
381
+ interruptingToolNames: new Set(['terminal_child', 'approval_child']),
382
+ });
383
+
384
+ await expect(
385
+ node.invoke({
386
+ messages: [
387
+ new AIMessage({
388
+ content: '',
389
+ tool_calls: [
390
+ {
391
+ id: 'call_terminal',
392
+ name: 'terminal_child',
393
+ args: {},
394
+ type: 'tool_call',
395
+ },
396
+ {
397
+ id: 'call_approval',
398
+ name: 'approval_child',
399
+ args: {},
400
+ type: 'tool_call',
401
+ },
402
+ ],
403
+ }),
404
+ ],
405
+ })
406
+ ).rejects.toBeInstanceOf(GraphInterrupt);
407
+
408
+ expect(persistedOutputs).toHaveLength(1);
409
+ expect(persistedOutputs[0]).toMatchObject({
410
+ output: {
411
+ content: 'Blocked: blocked by policy',
412
+ status: 'error',
413
+ tool_call_id: 'call_terminal',
414
+ },
415
+ });
416
+ });
417
+
215
418
  it('stops event dispatch when a direct tool trips the breaker mid-batch', async () => {
216
419
  const breaker = new AbortController();
217
420
  const trip = new StreamLimitExceededError({
@@ -323,7 +526,10 @@ describe('ToolNode breaker signal composition', () => {
323
526
  });
324
527
 
325
528
  it('stamps the batch-entry run scope into tool configs and strips it from host batches', async () => {
326
- const scope = Object.freeze({ epoch: 3, controller: new AbortController() });
529
+ const scope = Object.freeze({
530
+ epoch: 3,
531
+ controller: new AbortController(),
532
+ });
327
533
  let seenScope: unknown;
328
534
  const capture = {
329
535
  name: 'capture_scope',