@librechat/agents 3.3.3 → 3.3.5

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 (274) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +2 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/constants.cjs +21 -0
  4. package/dist/cjs/common/constants.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +10 -1
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +793 -111
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
  10. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  11. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  12. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  13. package/dist/cjs/hooks/index.cjs +12 -0
  14. package/dist/cjs/hooks/index.cjs.map +1 -1
  15. package/dist/cjs/hooks/types.cjs +1 -0
  16. package/dist/cjs/hooks/types.cjs.map +1 -1
  17. package/dist/cjs/langfuse.cjs +8 -0
  18. package/dist/cjs/langfuse.cjs.map +1 -1
  19. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
  20. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  21. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +38 -13
  22. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/google/utils/common.cjs +19 -7
  24. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  25. package/dist/cjs/llm/invoke.cjs +331 -5
  26. package/dist/cjs/llm/invoke.cjs.map +1 -1
  27. package/dist/cjs/llm/openai/index.cjs +41 -5
  28. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  29. package/dist/cjs/llm/openai/utils/index.cjs +25 -4
  30. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  31. package/dist/cjs/llm/openrouter/index.cjs +4 -1
  32. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  33. package/dist/cjs/llm/preempt.cjs +132 -0
  34. package/dist/cjs/llm/preempt.cjs.map +1 -0
  35. package/dist/cjs/main.cjs +36 -5
  36. package/dist/cjs/messages/alternation.cjs +92 -0
  37. package/dist/cjs/messages/alternation.cjs.map +1 -0
  38. package/dist/cjs/messages/cache.cjs +54 -33
  39. package/dist/cjs/messages/cache.cjs.map +1 -1
  40. package/dist/cjs/messages/contextPruning.cjs +17 -43
  41. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  42. package/dist/cjs/messages/core.cjs +315 -23
  43. package/dist/cjs/messages/core.cjs.map +1 -1
  44. package/dist/cjs/messages/format.cjs +290 -60
  45. package/dist/cjs/messages/format.cjs.map +1 -1
  46. package/dist/cjs/messages/handoffCue.cjs +63 -0
  47. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  48. package/dist/cjs/messages/index.cjs +4 -1
  49. package/dist/cjs/messages/injected.cjs +60 -0
  50. package/dist/cjs/messages/injected.cjs.map +1 -0
  51. package/dist/cjs/messages/prune.cjs +503 -127
  52. package/dist/cjs/messages/prune.cjs.map +1 -1
  53. package/dist/cjs/run.cjs +80 -7
  54. package/dist/cjs/run.cjs.map +1 -1
  55. package/dist/cjs/session/handlers.cjs +18 -9
  56. package/dist/cjs/session/handlers.cjs.map +1 -1
  57. package/dist/cjs/stream.cjs +35 -10
  58. package/dist/cjs/stream.cjs.map +1 -1
  59. package/dist/cjs/summarization/node.cjs +28 -14
  60. package/dist/cjs/summarization/node.cjs.map +1 -1
  61. package/dist/cjs/tools/ToolNode.cjs +56 -63
  62. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  63. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  64. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  65. package/dist/cjs/tools/search/tool.cjs +9 -1
  66. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  67. package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
  68. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  69. package/dist/cjs/utils/index.cjs +1 -1
  70. package/dist/cjs/utils/llm.cjs +1 -1
  71. package/dist/cjs/utils/llm.cjs.map +1 -1
  72. package/dist/cjs/utils/tokens.cjs +307 -20
  73. package/dist/cjs/utils/tokens.cjs.map +1 -1
  74. package/dist/cjs/utils/toolContent.cjs +1514 -0
  75. package/dist/cjs/utils/toolContent.cjs.map +1 -0
  76. package/dist/esm/agents/AgentContext.mjs +2 -1
  77. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  78. package/dist/esm/common/constants.mjs +19 -1
  79. package/dist/esm/common/constants.mjs.map +1 -1
  80. package/dist/esm/events.mjs +10 -1
  81. package/dist/esm/events.mjs.map +1 -1
  82. package/dist/esm/graphs/Graph.mjs +798 -116
  83. package/dist/esm/graphs/Graph.mjs.map +1 -1
  84. package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
  85. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  86. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  87. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  88. package/dist/esm/hooks/index.mjs +12 -1
  89. package/dist/esm/hooks/index.mjs.map +1 -1
  90. package/dist/esm/hooks/types.mjs +1 -0
  91. package/dist/esm/hooks/types.mjs.map +1 -1
  92. package/dist/esm/langfuse.mjs +9 -1
  93. package/dist/esm/langfuse.mjs.map +1 -1
  94. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
  95. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  96. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +38 -13
  97. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  98. package/dist/esm/llm/google/utils/common.mjs +19 -7
  99. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  100. package/dist/esm/llm/invoke.mjs +332 -8
  101. package/dist/esm/llm/invoke.mjs.map +1 -1
  102. package/dist/esm/llm/openai/index.mjs +41 -5
  103. package/dist/esm/llm/openai/index.mjs.map +1 -1
  104. package/dist/esm/llm/openai/utils/index.mjs +25 -4
  105. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  106. package/dist/esm/llm/openrouter/index.mjs +4 -1
  107. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  108. package/dist/esm/llm/preempt.mjs +131 -0
  109. package/dist/esm/llm/preempt.mjs.map +1 -0
  110. package/dist/esm/main.mjs +14 -11
  111. package/dist/esm/messages/alternation.mjs +91 -0
  112. package/dist/esm/messages/alternation.mjs.map +1 -0
  113. package/dist/esm/messages/cache.mjs +55 -34
  114. package/dist/esm/messages/cache.mjs.map +1 -1
  115. package/dist/esm/messages/contextPruning.mjs +17 -43
  116. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  117. package/dist/esm/messages/core.mjs +305 -24
  118. package/dist/esm/messages/core.mjs.map +1 -1
  119. package/dist/esm/messages/format.mjs +290 -61
  120. package/dist/esm/messages/format.mjs.map +1 -1
  121. package/dist/esm/messages/handoffCue.mjs +61 -0
  122. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  123. package/dist/esm/messages/index.mjs +4 -1
  124. package/dist/esm/messages/injected.mjs +60 -0
  125. package/dist/esm/messages/injected.mjs.map +1 -0
  126. package/dist/esm/messages/prune.mjs +500 -128
  127. package/dist/esm/messages/prune.mjs.map +1 -1
  128. package/dist/esm/run.mjs +80 -7
  129. package/dist/esm/run.mjs.map +1 -1
  130. package/dist/esm/session/handlers.mjs +19 -10
  131. package/dist/esm/session/handlers.mjs.map +1 -1
  132. package/dist/esm/stream.mjs +34 -11
  133. package/dist/esm/stream.mjs.map +1 -1
  134. package/dist/esm/summarization/node.mjs +28 -14
  135. package/dist/esm/summarization/node.mjs.map +1 -1
  136. package/dist/esm/tools/ToolNode.mjs +56 -63
  137. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  138. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  139. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  140. package/dist/esm/tools/search/tool.mjs +9 -1
  141. package/dist/esm/tools/search/tool.mjs.map +1 -1
  142. package/dist/esm/tools/toolOutputReferences.mjs +6 -0
  143. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  144. package/dist/esm/utils/index.mjs +1 -1
  145. package/dist/esm/utils/llm.mjs +1 -1
  146. package/dist/esm/utils/llm.mjs.map +1 -1
  147. package/dist/esm/utils/tokens.mjs +307 -21
  148. package/dist/esm/utils/tokens.mjs.map +1 -1
  149. package/dist/esm/utils/toolContent.mjs +1503 -0
  150. package/dist/esm/utils/toolContent.mjs.map +1 -0
  151. package/dist/types/common/constants.d.ts +18 -0
  152. package/dist/types/graphs/Graph.d.ts +139 -2
  153. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  154. package/dist/types/hooks/index.d.ts +12 -1
  155. package/dist/types/hooks/types.d.ts +45 -6
  156. package/dist/types/index.d.ts +1 -1
  157. package/dist/types/llm/invoke.d.ts +34 -1
  158. package/dist/types/llm/openai/index.d.ts +4 -0
  159. package/dist/types/llm/openai/utils/index.d.ts +2 -0
  160. package/dist/types/llm/openrouter/index.d.ts +5 -0
  161. package/dist/types/llm/preempt.d.ts +40 -0
  162. package/dist/types/messages/alternation.d.ts +23 -0
  163. package/dist/types/messages/contextPruning.d.ts +2 -1
  164. package/dist/types/messages/core.d.ts +36 -0
  165. package/dist/types/messages/format.d.ts +6 -0
  166. package/dist/types/messages/handoffCue.d.ts +40 -0
  167. package/dist/types/messages/index.d.ts +3 -0
  168. package/dist/types/messages/injected.d.ts +3 -0
  169. package/dist/types/messages/prune.d.ts +16 -12
  170. package/dist/types/run.d.ts +7 -0
  171. package/dist/types/stream.d.ts +14 -0
  172. package/dist/types/tools/ToolNode.d.ts +0 -7
  173. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  174. package/dist/types/tools/search/types.d.ts +31 -2
  175. package/dist/types/types/graph.d.ts +8 -1
  176. package/dist/types/types/run.d.ts +65 -0
  177. package/dist/types/types/stream.d.ts +1 -26
  178. package/dist/types/utils/tokens.d.ts +7 -0
  179. package/dist/types/utils/toolContent.d.ts +107 -0
  180. package/package.json +1 -1
  181. package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
  182. package/src/agents/AgentContext.ts +1 -0
  183. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  184. package/src/common/constants.ts +21 -0
  185. package/src/events.ts +15 -1
  186. package/src/graphs/Graph.ts +1261 -198
  187. package/src/graphs/MultiAgentGraph.ts +6 -1
  188. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1139 -4
  189. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  190. package/src/hooks/HookRegistry.ts +40 -0
  191. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  192. package/src/hooks/index.ts +16 -2
  193. package/src/hooks/types.ts +47 -3
  194. package/src/index.ts +1 -1
  195. package/src/langfuse.ts +26 -1
  196. package/src/llm/anthropic/utils/message_inputs.ts +158 -12
  197. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
  198. package/src/llm/bedrock/utils/message_inputs.test.ts +131 -1
  199. package/src/llm/bedrock/utils/message_inputs.ts +100 -24
  200. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  201. package/src/llm/google/utils/common.test.ts +75 -9
  202. package/src/llm/google/utils/common.ts +34 -3
  203. package/src/llm/invoke.alternation.test.ts +87 -0
  204. package/src/llm/invoke.handoffCue.test.ts +112 -0
  205. package/src/llm/invoke.test.ts +488 -0
  206. package/src/llm/invoke.ts +611 -8
  207. package/src/llm/openai/index.ts +106 -6
  208. package/src/llm/openai/utils/index.ts +109 -53
  209. package/src/llm/openai/utils/messages.test.ts +330 -1
  210. package/src/llm/openrouter/index.ts +12 -2
  211. package/src/llm/openrouter/reasoning.test.ts +314 -0
  212. package/src/llm/preempt.test.ts +323 -0
  213. package/src/llm/preempt.ts +178 -0
  214. package/src/messages/__tests__/observationMasking.test.ts +93 -2
  215. package/src/messages/alternation.test.ts +212 -0
  216. package/src/messages/alternation.ts +112 -0
  217. package/src/messages/cache.tail.test.ts +193 -0
  218. package/src/messages/cache.test.ts +113 -0
  219. package/src/messages/cache.ts +92 -60
  220. package/src/messages/contextPruning.test.ts +184 -0
  221. package/src/messages/contextPruning.ts +49 -42
  222. package/src/messages/core.ts +653 -40
  223. package/src/messages/ensureThinkingBlock.test.ts +49 -1
  224. package/src/messages/foldToollessToolBlocks.test.ts +163 -5
  225. package/src/messages/format.ts +454 -91
  226. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  227. package/src/messages/formatAgentMessages.test.ts +604 -14
  228. package/src/messages/formatAgentMessages.tools.test.ts +103 -0
  229. package/src/messages/handoffCue.test.ts +96 -0
  230. package/src/messages/handoffCue.ts +78 -0
  231. package/src/messages/index.ts +3 -0
  232. package/src/messages/injected.test.ts +90 -0
  233. package/src/messages/injected.ts +74 -0
  234. package/src/messages/prune.ts +996 -183
  235. package/src/run.ts +91 -6
  236. package/src/scripts/preempt-probe.ts +330 -0
  237. package/src/scripts/preempt-scenarios.ts +388 -0
  238. package/src/session/handlers.ts +32 -12
  239. package/src/specs/handoffCue.test.ts +165 -0
  240. package/src/specs/langfuse-callbacks.test.ts +352 -2
  241. package/src/specs/preemptSeal.test.ts +309 -0
  242. package/src/specs/prune.test.ts +1083 -6
  243. package/src/specs/summarization-unit.test.ts +105 -0
  244. package/src/specs/tokens.test.ts +609 -32
  245. package/src/stream.dispatch.test.ts +63 -0
  246. package/src/stream.ts +59 -23
  247. package/src/summarization/__tests__/node.test.ts +77 -0
  248. package/src/summarization/node.ts +46 -18
  249. package/src/tools/ToolNode.ts +150 -99
  250. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
  251. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
  252. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
  253. package/src/tools/__tests__/directToolHooks.test.ts +255 -7
  254. package/src/tools/search/keenable-scraper.test.ts +153 -0
  255. package/src/tools/search/keenable-scraper.ts +137 -0
  256. package/src/tools/search/tool.ts +13 -2
  257. package/src/tools/search/types.ts +50 -3
  258. package/src/tools/toolOutputReferences.ts +6 -0
  259. package/src/types/graph.ts +12 -1
  260. package/src/types/run.ts +67 -0
  261. package/src/types/stream.ts +1 -42
  262. package/src/utils/llm.test.ts +18 -0
  263. package/src/utils/llm.ts +4 -1
  264. package/src/utils/tokens.ts +586 -31
  265. package/src/utils/toolContent.test.ts +1432 -0
  266. package/src/utils/toolContent.ts +2707 -0
  267. package/dist/cjs/splitStream.cjs +0 -151
  268. package/dist/cjs/splitStream.cjs.map +0 -1
  269. package/dist/esm/splitStream.mjs +0 -150
  270. package/dist/esm/splitStream.mjs.map +0 -1
  271. package/dist/types/mockStream.d.ts +0 -32
  272. package/dist/types/splitStream.d.ts +0 -37
  273. package/src/mockStream.ts +0 -99
  274. package/src/splitStream.ts +0 -234
@@ -0,0 +1,309 @@
1
+ // src/specs/preemptSeal.test.ts
2
+ /**
3
+ * End-to-end cooperative seal flow through a real `Run`: fake model streams,
4
+ * the host requests a preempt, the stream seals at a safe chunk, the
5
+ * `PreemptBoundary` drain decides what happens next. Everything below runs
6
+ * the dispatch-synchronous loop in `attemptInvoke` (no registered
7
+ * CHAT_MODEL_STREAM handler), which is the only loop allowed to seal.
8
+ */
9
+ import { HumanMessage } from '@langchain/core/messages';
10
+ import type { BaseMessage } from '@langchain/core/messages';
11
+ import { RunnableBinding } from '@langchain/core/runnables';
12
+ import type * as t from '@/types';
13
+ import { Providers } from '@/common';
14
+ import { HookRegistry } from '@/hooks/HookRegistry';
15
+ import type { HookCallback } from '@/hooks/types';
16
+ import { FakeChatModel } from '@/llm/fake';
17
+ import { Run } from '@/run';
18
+
19
+ const FULL_RESPONSE = 'Alpha beta gamma delta epsilon zeta';
20
+ const RESUMED_RESPONSE = 'Continuing after the steer.';
21
+
22
+ const streamConfig = {
23
+ configurable: { thread_id: 'preempt-seal-e2e' },
24
+ streamMode: 'values' as const,
25
+ version: 'v2' as const,
26
+ };
27
+
28
+ async function createSealRun(options: {
29
+ runId: string;
30
+ hook: HookCallback<'PreemptBoundary'>;
31
+ responses: string[];
32
+ stopHook?: HookCallback<'Stop'>;
33
+ modelCallbacks?: FakeChatModel['callbacks'];
34
+ }): Promise<Run<t.IState>> {
35
+ const registry = new HookRegistry();
36
+ registry.register('PreemptBoundary', { hooks: [options.hook] });
37
+ if (options.stopHook) {
38
+ registry.register('Stop', { hooks: [options.stopHook] });
39
+ }
40
+ const run = await Run.create<t.IState>({
41
+ runId: options.runId,
42
+ graphConfig: {
43
+ type: 'standard',
44
+ llmConfig: {
45
+ provider: Providers.OPENAI,
46
+ model: 'gpt-4o-mini',
47
+ apiKey: 'test-key',
48
+ },
49
+ instructions: 'Answer plainly.',
50
+ },
51
+ hooks: registry,
52
+ preemption: { shouldPreempt: () => true, maxSeals: 1 },
53
+ returnContent: true,
54
+ skipCleanup: true,
55
+ });
56
+ if (!run.Graph) {
57
+ throw new Error('Expected graph to be initialized');
58
+ }
59
+ const model = new FakeChatModel({
60
+ responses: options.responses,
61
+ });
62
+ if (options.modelCallbacks != null) {
63
+ model.callbacks = options.modelCallbacks;
64
+ }
65
+ /**
66
+ * Wrapped, not bare, when the test watches model-level callbacks: with
67
+ * tools bound, production hands `attemptInvoke` a `RunnableBinding` (and a
68
+ * system runnable pipes a sequence on top), while `clientOptions.callbacks`
69
+ * lives on the chat model at the bottom. A bare override would let a
70
+ * naive `model.callbacks` property read pass the detector while missing
71
+ * every real tool-enabled run.
72
+ */
73
+ run.Graph.overrideModel = (
74
+ options.modelCallbacks != null
75
+ ? new RunnableBinding({ bound: model, kwargs: {}, config: {} })
76
+ : model
77
+ ) as typeof model;
78
+ return run;
79
+ }
80
+
81
+ class CountingChatModel extends FakeChatModel {
82
+ invocations = 0;
83
+
84
+ override async *_streamResponseChunks(
85
+ ...args: Parameters<FakeChatModel['_streamResponseChunks']>
86
+ ): ReturnType<FakeChatModel['_streamResponseChunks']> {
87
+ this.invocations += 1;
88
+ yield* super._streamResponseChunks(...args);
89
+ }
90
+ }
91
+
92
+ const aiContents = (messages: BaseMessage[]): string[] =>
93
+ messages
94
+ .filter((message) => message.getType() === 'ai')
95
+ .map((message) =>
96
+ typeof message.content === 'string'
97
+ ? message.content
98
+ : JSON.stringify(message.content)
99
+ );
100
+
101
+ describe('cooperative seal (end-to-end via Run)', () => {
102
+ jest.setTimeout(15000);
103
+
104
+ it('surfaces an empty boundary as preempt_incomplete instead of a natural finish', async () => {
105
+ const run = await createSealRun({
106
+ runId: 'seal-empty-boundary',
107
+ hook: async () => ({}),
108
+ responses: [FULL_RESPONSE],
109
+ });
110
+
111
+ await run.processStream(
112
+ { messages: [new HumanMessage('hello there')] },
113
+ streamConfig
114
+ );
115
+
116
+ /**
117
+ * The answer really was cut short: the host asked to preempt, the seal
118
+ * took the budget, and the drain had nothing to resume with. A terminal
119
+ * consumer reading only completion events would persist a truncated
120
+ * answer as finished — `getHaltReason()` is the channel that prevents
121
+ * that (AgentSession emits `run.halted` off it).
122
+ */
123
+ expect(run.getHaltReason()).toBe('preempt_incomplete');
124
+ expect(run.Graph?.preemptIncomplete).toBe(true);
125
+ expect(run.Graph?.preemptEmptyBoundaries).toBe(1);
126
+
127
+ const contents = aiContents(run.getRunMessages() ?? []);
128
+ expect(contents).toHaveLength(1);
129
+ expect(contents[0].length).toBeGreaterThan(0);
130
+ expect(contents[0].length).toBeLessThan(FULL_RESPONSE.length);
131
+ expect(FULL_RESPONSE.startsWith(contents[0])).toBe(true);
132
+ });
133
+
134
+ it('forwards a halting hook\'s own stopReason to Stop hooks and getHaltReason', async () => {
135
+ let stopReasonSeen: string | undefined;
136
+ const run = await createSealRun({
137
+ runId: 'seal-halt-reason',
138
+ hook: async () => ({
139
+ preventContinuation: true,
140
+ stopReason: 'host_policy_stop',
141
+ }),
142
+ responses: [FULL_RESPONSE],
143
+ stopHook: async (input) => {
144
+ stopReasonSeen = input.stopReason;
145
+ return {};
146
+ },
147
+ });
148
+
149
+ await run.processStream(
150
+ { messages: [new HumanMessage('hello there')] },
151
+ streamConfig
152
+ );
153
+
154
+ /**
155
+ * The hook-supplied reason must win end to end: a persistence/audit Stop
156
+ * hook records the actual cause, not the generic preempt_incomplete
157
+ * label, and getHaltReason() reports the same string afterward. A
158
+ * halting boundary that injected nothing also counts as an empty
159
+ * boundary in the truncated-seal telemetry.
160
+ */
161
+ expect(stopReasonSeen).toBe('host_policy_stop');
162
+ expect(run.getHaltReason()).toBe('host_policy_stop');
163
+ expect(run.Graph?.preemptIncomplete).toBe(true);
164
+ expect(run.Graph?.preemptEmptyBoundaries).toBe(1);
165
+ });
166
+
167
+ it('closes model-level callbacks for the sealed run, not just config-level ones', async () => {
168
+ let starts = 0;
169
+ let ends = 0;
170
+ const run = await createSealRun({
171
+ runId: 'seal-model-callbacks',
172
+ hook: async () => ({
173
+ injectedMessages: [
174
+ { role: 'user' as const, content: 'Shorter.', source: 'steer' },
175
+ ],
176
+ }),
177
+ responses: [FULL_RESPONSE, RESUMED_RESPONSE],
178
+ /**
179
+ * A handler supplied on the MODEL (clientOptions.callbacks) gets
180
+ * handleChatModelStart from the real run, so the sealed turn's
181
+ * synthetic close must reach it too — otherwise its span for the
182
+ * sealed run never closes. Two runs (sealed + resumed): both must
183
+ * balance.
184
+ */
185
+ modelCallbacks: [
186
+ {
187
+ handleChatModelStart: (): void => {
188
+ starts += 1;
189
+ },
190
+ handleLLMEnd: (): void => {
191
+ ends += 1;
192
+ },
193
+ },
194
+ ],
195
+ });
196
+
197
+ await run.processStream(
198
+ { messages: [new HumanMessage('hello there')] },
199
+ streamConfig
200
+ );
201
+
202
+ expect(run.Graph?.preemptSealCount).toBe(1);
203
+ expect(starts).toBe(2);
204
+ expect(ends).toBe(2);
205
+ });
206
+
207
+ it('a halting boundary stops multi-agent successors, not just the sealed subgraph', async () => {
208
+ const registry = new HookRegistry();
209
+ registry.register('PreemptBoundary', {
210
+ hooks: [
211
+ async () => ({
212
+ preventContinuation: true,
213
+ stopReason: 'stop_everything',
214
+ }),
215
+ ],
216
+ });
217
+ const run = await Run.create<t.IState>({
218
+ runId: 'seal-halt-multiagent',
219
+ graphConfig: {
220
+ type: 'multi-agent',
221
+ agents: [
222
+ {
223
+ agentId: 'agent_a',
224
+ provider: Providers.OPENAI,
225
+ clientOptions: { model: 'gpt-4o-mini', apiKey: 'test-key' },
226
+ instructions: 'You are agent A.',
227
+ },
228
+ {
229
+ agentId: 'agent_b',
230
+ provider: Providers.OPENAI,
231
+ clientOptions: { model: 'gpt-4o-mini', apiKey: 'test-key' },
232
+ instructions: 'You are agent B.',
233
+ },
234
+ ],
235
+ edges: [{ from: 'agent_a', to: 'agent_b', edgeType: 'direct' }],
236
+ },
237
+ hooks: registry,
238
+ preemption: { shouldPreempt: () => true, maxSeals: 1 },
239
+ returnContent: true,
240
+ skipCleanup: true,
241
+ });
242
+ if (!run.Graph) {
243
+ throw new Error('Expected graph to be initialized');
244
+ }
245
+ const model = new CountingChatModel({
246
+ responses: [FULL_RESPONSE, 'agent B should never say this'],
247
+ });
248
+ run.Graph.overrideModel = model;
249
+
250
+ await run.processStream(
251
+ { messages: [new HumanMessage('hello there')] },
252
+ streamConfig
253
+ );
254
+
255
+ /**
256
+ * The registry halt is cleared to protect the sealed commit, so nothing
257
+ * in processStream's poll stops the outer workflow — the createCallModel
258
+ * entry guard is what keeps the direct-edge successor from taking a
259
+ * model turn after the halting boundary.
260
+ */
261
+ expect(model.invocations).toBe(1);
262
+ expect(run.getHaltReason()).toBe('stop_everything');
263
+ expect(run.Graph.preemptIncomplete).toBe(true);
264
+ const contents = aiContents(run.getRunMessages() ?? []);
265
+ expect(contents.some((c) => c.includes('agent B should never'))).toBe(
266
+ false
267
+ );
268
+ });
269
+
270
+ it('resumes after an injecting boundary and completes without a halt reason', async () => {
271
+ const run = await createSealRun({
272
+ runId: 'seal-inject-resume',
273
+ hook: async () => ({
274
+ injectedMessages: [
275
+ { role: 'user' as const, content: 'Make it shorter.', source: 'steer' },
276
+ ],
277
+ }),
278
+ responses: [FULL_RESPONSE, RESUMED_RESPONSE],
279
+ });
280
+
281
+ await run.processStream(
282
+ { messages: [new HumanMessage('hello there')] },
283
+ streamConfig
284
+ );
285
+
286
+ expect(run.getHaltReason()).toBeUndefined();
287
+ expect(run.Graph?.preemptIncomplete).toBe(false);
288
+ expect(run.Graph?.preemptSealCount).toBe(1);
289
+
290
+ const messages = run.getRunMessages() ?? [];
291
+ const steer = messages.find(
292
+ (message) => message.additional_kwargs.source === 'steer'
293
+ );
294
+ expect(steer).toBeDefined();
295
+ expect(steer?.content).toBe('Make it shorter.');
296
+
297
+ /**
298
+ * Two assistant turns: the sealed partial and the post-steer
299
+ * continuation, which must have run to completion — the seal budget was
300
+ * spent, so the second stream cannot seal again even though
301
+ * `shouldPreempt` still answers true.
302
+ */
303
+ const contents = aiContents(messages);
304
+ expect(contents).toHaveLength(2);
305
+ expect(FULL_RESPONSE.startsWith(contents[0])).toBe(true);
306
+ expect(contents[0].length).toBeLessThan(FULL_RESPONSE.length);
307
+ expect(contents[1]).toBe(RESUMED_RESPONSE);
308
+ });
309
+ });