@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,388 @@
1
+ // src/scripts/preempt-scenarios.ts
2
+ //
3
+ // Live scenario matrix for the seal paths the basic probe does not exercise:
4
+ //
5
+ // npx tsx --env-file=/path/to/.env src/scripts/preempt-scenarios.ts \
6
+ // --scenario multiseal|toolturn|budget|cache|ma-halt|ma-continue|websearch \
7
+ // --provider anthropic|bedrock|...
8
+ //
9
+ // One scenario per invocation; prints SCENARIO_VERDICT as the last line.
10
+ import { config } from 'dotenv';
11
+ config();
12
+ import { z } from 'zod';
13
+ import { v4 as uuidv4 } from 'uuid';
14
+ import { tool } from '@langchain/core/tools';
15
+ import { HumanMessage } from '@langchain/core/messages';
16
+ import type { BaseMessage } from '@langchain/core/messages';
17
+ import type * as t from '@/types';
18
+ import { GraphEvents } from '@/common';
19
+ import { createTokenCounter } from '@/utils/tokens';
20
+ import { getLLMConfig } from '@/utils/llmConfig';
21
+ import { HookRegistry } from '@/hooks';
22
+ import { Run } from '@/run';
23
+
24
+ const LONG_PROMPT =
25
+ 'Write a detailed history of the Byzantine Empire from Constantine to ' +
26
+ '1453 in at least 800 words of flowing prose. No headings or bullets.';
27
+
28
+ function argOf(flag: string, fallback: string): string {
29
+ const index = process.argv.indexOf(flag);
30
+ return index !== -1 && process.argv[index + 1] != null
31
+ ? process.argv[index + 1]
32
+ : fallback;
33
+ }
34
+
35
+ function textOf(message: BaseMessage | undefined): string {
36
+ if (message == null) return '';
37
+ if (typeof message.content === 'string') return message.content;
38
+ let text = '';
39
+ for (const block of message.content) {
40
+ if (block.type === 'text' && typeof block.text === 'string') {
41
+ text += block.text;
42
+ }
43
+ }
44
+ return text;
45
+ }
46
+
47
+ function kindOf(message: BaseMessage): string {
48
+ if (message.getType() === 'human') {
49
+ const source = message.additional_kwargs.source;
50
+ return typeof source === 'string' ? `human(${source})` : 'human';
51
+ }
52
+ if (message.getType() === 'tool') return 'tool';
53
+ return message.getType();
54
+ }
55
+
56
+ type Outcome = {
57
+ scenario: string;
58
+ provider: string;
59
+ ok: boolean;
60
+ detail: Record<string, unknown>;
61
+ error: string | null;
62
+ };
63
+
64
+ async function main(): Promise<Outcome> {
65
+ const scenario = argOf('--scenario', 'multiseal');
66
+ const providerKey = argOf('--provider', 'anthropic');
67
+ const llmConfig = getLLMConfig(providerKey);
68
+ if (llmConfig == null) {
69
+ throw new Error(`No llmConfig entry for provider "${providerKey}"`);
70
+ }
71
+ const tokenCounter = await createTokenCounter();
72
+
73
+ let armed = false;
74
+ let deltas = 0;
75
+ let armAt = 15;
76
+ let injections = 0;
77
+ let maxInjections = 1;
78
+ let chatModelStarts = 0;
79
+ let llmEnds = 0;
80
+ let toolExecutions = 0;
81
+ let haltOnBoundary = false;
82
+
83
+ const hooks = new HookRegistry();
84
+ hooks.register('PreemptBoundary', {
85
+ hooks: [
86
+ async () => {
87
+ armed = false;
88
+ if (haltOnBoundary) {
89
+ return { preventContinuation: true, stopReason: 'scenario_halt' };
90
+ }
91
+ if (injections >= maxInjections) return {};
92
+ injections += 1;
93
+ if (scenario === 'multiseal' && injections < maxInjections + 1) {
94
+ /** Re-arm after the resumed stream produces more deltas. */
95
+ armAt = deltas + 12;
96
+ }
97
+ return {
98
+ injectedMessages: [
99
+ {
100
+ role: 'user' as const,
101
+ content:
102
+ injections === 1 && maxInjections > 1
103
+ ? 'Change of plan: write 300 words about the fall of Constantinople specifically.'
104
+ : 'Stop. In one short sentence: what year did Constantinople fall?',
105
+ source: 'steer',
106
+ },
107
+ ],
108
+ };
109
+ },
110
+ ],
111
+ });
112
+
113
+ const customHandlers: Record<string, t.EventHandler> = {
114
+ [GraphEvents.ON_MESSAGE_DELTA]: {
115
+ handle: (): void => {
116
+ deltas += 1;
117
+ /** Never re-arm past the scenario's injection budget: a real host
118
+ * disarms when its queue drains; this models that. */
119
+ if (deltas >= armAt && injections < maxInjections) armed = true;
120
+ },
121
+ },
122
+ };
123
+
124
+ const calculator = tool(
125
+ async () => {
126
+ toolExecutions += 1;
127
+ return '345';
128
+ },
129
+ {
130
+ name: 'calculator',
131
+ description: 'Evaluates a basic arithmetic expression.',
132
+ schema: z.object({ expression: z.string() }),
133
+ }
134
+ );
135
+
136
+ const streamConfig = {
137
+ runId: uuidv4(),
138
+ configurable: { user_id: 'probe-user', thread_id: `scenario-${scenario}` },
139
+ streamMode: 'values',
140
+ version: 'v2' as const,
141
+ callbacks: [
142
+ {
143
+ handleChatModelStart: (): void => {
144
+ chatModelStarts += 1;
145
+ },
146
+ handleLLMEnd: (): void => {
147
+ llmEnds += 1;
148
+ },
149
+ },
150
+ ],
151
+ };
152
+
153
+ const base = {
154
+ runId: uuidv4(),
155
+ tokenCounter,
156
+ customHandlers,
157
+ hooks,
158
+ returnContent: true,
159
+ skipCleanup: true,
160
+ };
161
+
162
+ let run: Run<t.IState>;
163
+ let prompt = LONG_PROMPT;
164
+
165
+ switch (scenario) {
166
+ case 'multiseal':
167
+ maxInjections = 2;
168
+ run = await Run.create<t.IState>({
169
+ ...base,
170
+ graphConfig: { type: 'standard', llmConfig, instructions: 'You are a history assistant.' },
171
+ preemption: { shouldPreempt: () => armed, maxSeals: 3 },
172
+ });
173
+ break;
174
+ case 'toolturn':
175
+ armAt = 0;
176
+ armed = true;
177
+ prompt =
178
+ 'Use the calculator tool to compute 15 * 23, then explain the historical significance of the number 345 in Roman history in at least 300 words.';
179
+ run = await Run.create<t.IState>({
180
+ ...base,
181
+ graphConfig: {
182
+ type: 'standard',
183
+ llmConfig,
184
+ tools: [calculator],
185
+ instructions:
186
+ 'Call the calculator immediately, before writing ANY text. After the tool result, write the explanation.',
187
+ },
188
+ preemption: { shouldPreempt: () => armed, maxSeals: 2 },
189
+ });
190
+ break;
191
+ case 'budget':
192
+ run = await Run.create<t.IState>({
193
+ ...base,
194
+ graphConfig: { type: 'standard', llmConfig, instructions: 'You are a history assistant.' },
195
+ preemption: {
196
+ shouldPreempt: () => {
197
+ /** Deliberately NEVER disarmed by the host: level-triggered forever. */
198
+ return deltas >= 15;
199
+ },
200
+ maxSeals: 1,
201
+ },
202
+ });
203
+ break;
204
+ case 'cache':
205
+ run = await Run.create<t.IState>({
206
+ ...base,
207
+ graphConfig: {
208
+ type: 'standard',
209
+ llmConfig: { ...llmConfig, promptCache: true } as typeof llmConfig,
210
+ instructions:
211
+ 'You are a history assistant. '.repeat(80) +
212
+ 'Answer at length in flowing prose.',
213
+ },
214
+ preemption: { shouldPreempt: () => armed, maxSeals: 2 },
215
+ });
216
+ break;
217
+ case 'ma-halt':
218
+ case 'ma-continue':
219
+ case 'ma-baseline':
220
+ haltOnBoundary = scenario === 'ma-halt';
221
+ run = await Run.create<t.IState>({
222
+ ...base,
223
+ graphConfig: {
224
+ type: 'multi-agent',
225
+ agents: (() => {
226
+ const { provider, ...clientOptions } = llmConfig as {
227
+ provider: t.AgentInputs['provider'];
228
+ } & Record<string, unknown>;
229
+ return [
230
+ {
231
+ agentId: 'writer',
232
+ provider,
233
+ clientOptions,
234
+ instructions: 'You are the writer. Write the requested essay.',
235
+ },
236
+ {
237
+ agentId: 'critic',
238
+ provider,
239
+ clientOptions,
240
+ instructions:
241
+ 'You are the critic. In two sentences, critique what the writer produced.',
242
+ },
243
+ ] as t.AgentInputs[];
244
+ })(),
245
+ edges: [{ from: 'writer', to: 'critic', edgeType: 'direct' }],
246
+ },
247
+ ...(scenario === 'ma-baseline'
248
+ ? {}
249
+ : { preemption: { shouldPreempt: () => armed, maxSeals: 1 } }),
250
+ });
251
+ break;
252
+ case 'websearch':
253
+ armAt = Number(argOf('--arm-at', '1'));
254
+ prompt =
255
+ 'Search the web for the current population of Istanbul, then write a 300-word reflection on the city\'s growth.';
256
+ run = await Run.create<t.IState>({
257
+ ...base,
258
+ graphConfig: {
259
+ type: 'standard',
260
+ llmConfig,
261
+ tools: [
262
+ { type: 'web_search_20250305', name: 'web_search', max_uses: 1 },
263
+ ] as unknown as t.GraphTools,
264
+ instructions: 'Use web search before answering.',
265
+ },
266
+ preemption: { shouldPreempt: () => armed, maxSeals: 2 },
267
+ });
268
+ break;
269
+ default:
270
+ throw new Error(`Unknown scenario: ${scenario}`);
271
+ }
272
+
273
+ await run.processStream({ messages: [new HumanMessage(prompt)] }, streamConfig);
274
+
275
+ const messages = run.getRunMessages() ?? [];
276
+ const sequence = messages.map(kindOf);
277
+ const stats = run.getPreemptStats();
278
+ const steerCount = sequence.filter((k) => k === 'human(steer)').length;
279
+ const finalText = textOf(messages[messages.length - 1]);
280
+ const detail: Record<string, unknown> = {
281
+ sequence,
282
+ seals: stats.seals,
283
+ emptyBoundaries: stats.emptyBoundaries,
284
+ steerCount,
285
+ chatModelStarts,
286
+ llmEnds,
287
+ runsLeftOpen: chatModelStarts - llmEnds,
288
+ toolExecutions,
289
+ haltReason: run.getHaltReason() ?? null,
290
+ finalChars: finalText.length,
291
+ sealedBlockTypes: Array.isArray(messages[0]?.content)
292
+ ? (messages[0].content as Array<{ type?: string }>).map((b) => b.type)
293
+ : ['string'],
294
+ lastTexts: messages.slice(-3).map((m) => ({
295
+ kind: kindOf(m),
296
+ agent: (m as { name?: string }).name ?? m.additional_kwargs.agentId ?? null,
297
+ chars: textOf(m).length,
298
+ head: textOf(m).slice(0, 80),
299
+ })),
300
+ };
301
+
302
+ let ok = false;
303
+ switch (scenario) {
304
+ case 'multiseal':
305
+ ok =
306
+ stats.seals === 2 &&
307
+ steerCount === 2 &&
308
+ chatModelStarts === 3 &&
309
+ llmEnds === 3 &&
310
+ /1453/.test(finalText);
311
+ break;
312
+ case 'toolturn': {
313
+ const toolIdx = sequence.indexOf('tool');
314
+ const steerIdx = sequence.indexOf('human(steer)');
315
+ ok =
316
+ stats.seals === 1 &&
317
+ toolExecutions === 1 &&
318
+ toolIdx !== -1 &&
319
+ steerIdx > toolIdx &&
320
+ chatModelStarts === llmEnds &&
321
+ finalText.trim().length > 0;
322
+ break;
323
+ }
324
+ case 'budget':
325
+ ok =
326
+ stats.seals === 1 &&
327
+ steerCount === 1 &&
328
+ chatModelStarts === 2 &&
329
+ llmEnds === 2 &&
330
+ /1453/.test(finalText);
331
+ break;
332
+ case 'cache':
333
+ ok =
334
+ stats.seals === 1 &&
335
+ steerCount === 1 &&
336
+ chatModelStarts === llmEnds &&
337
+ finalText.trim().length > 0;
338
+ break;
339
+ case 'ma-halt':
340
+ ok =
341
+ stats.seals === 1 &&
342
+ run.getHaltReason() === 'scenario_halt' &&
343
+ chatModelStarts === 1 &&
344
+ llmEnds === 1;
345
+ break;
346
+ case 'ma-baseline':
347
+ ok = chatModelStarts === 2 && llmEnds === 2;
348
+ break;
349
+ case 'ma-continue':
350
+ ok =
351
+ stats.seals === 1 &&
352
+ steerCount === 1 &&
353
+ chatModelStarts === 3 &&
354
+ llmEnds === 3 &&
355
+ finalText.trim().length > 0;
356
+ break;
357
+ case 'websearch':
358
+ /**
359
+ * Best-effort: the load-bearing claims are (a) no seal orphans an
360
+ * unanswered server-tool call — the provider would 400 the resume —
361
+ * and (b) the run completes with balanced lifecycle counters.
362
+ */
363
+ ok =
364
+ chatModelStarts === llmEnds &&
365
+ finalText.trim().length > 0 &&
366
+ stats.seals <= 2;
367
+ break;
368
+ }
369
+
370
+ return { scenario, provider: providerKey, ok, detail, error: null };
371
+ }
372
+
373
+ main()
374
+ .then((outcome) => {
375
+ console.log(`SCENARIO_VERDICT ${JSON.stringify(outcome)}`);
376
+ process.exit(outcome.ok ? 0 : 1);
377
+ })
378
+ .catch((error: unknown) => {
379
+ console.log(
380
+ `SCENARIO_VERDICT ${JSON.stringify({
381
+ scenario: argOf('--scenario', '?'),
382
+ provider: argOf('--provider', '?'),
383
+ ok: false,
384
+ error: error instanceof Error ? error.message.slice(0, 400) : String(error),
385
+ })}`
386
+ );
387
+ process.exit(1);
388
+ });
@@ -7,7 +7,11 @@ import type {
7
7
  import type * as t from '@/types';
8
8
  import { ModelEndHandler, ToolEndHandler } from '@/events';
9
9
  import { toJsonValue } from './messageSerialization';
10
- import { createContentAggregator } from '@/stream';
10
+ import {
11
+ createContentAggregator,
12
+ dispatchesChatModelStream,
13
+ SDK_STREAM_DISPATCH,
14
+ } from '@/stream';
11
15
  import { createTimestamp } from './ids';
12
16
  import { GraphEvents } from '@/common';
13
17
 
@@ -107,18 +111,34 @@ export function createRunHandlers(params: {
107
111
 
108
112
  emitEvent(createEvent('run.started'));
109
113
 
110
- const handlers: Record<string, t.EventHandler> = {
111
- [GraphEvents.CHAT_MODEL_STREAM]: {
112
- handle: async (event, data, metadata, graph): Promise<void> => {
113
- await callUserHandler({
114
- userHandlers: params.userHandlers,
115
- event,
116
- data,
117
- metadata,
118
- graph,
119
- });
120
- },
114
+ /**
115
+ * Forwards to the host's own handler, so it inherits that handler's
116
+ * capability. Branding matters because this wrapper is installed on EVERY
117
+ * session run and merged last: without it, a host that registered
118
+ * `new ChatModelStreamHandler()` would silently lose the sealing opt-out
119
+ * that registration is supposed to buy.
120
+ */
121
+ const streamWrapper: t.EventHandler = {
122
+ handle: async (event, data, metadata, graph): Promise<void> => {
123
+ await callUserHandler({
124
+ userHandlers: params.userHandlers,
125
+ event,
126
+ data,
127
+ metadata,
128
+ graph,
129
+ });
121
130
  },
131
+ };
132
+ if (
133
+ dispatchesChatModelStream(
134
+ params.userHandlers?.[GraphEvents.CHAT_MODEL_STREAM]
135
+ )
136
+ ) {
137
+ Object.defineProperty(streamWrapper, SDK_STREAM_DISPATCH, { value: true });
138
+ }
139
+
140
+ const handlers: Record<string, t.EventHandler> = {
141
+ [GraphEvents.CHAT_MODEL_STREAM]: streamWrapper,
122
142
  [GraphEvents.CHAT_MODEL_END]: {
123
143
  handle: async (event, data, metadata, graph): Promise<void> => {
124
144
  await modelEndHandler.handle(
@@ -0,0 +1,165 @@
1
+ // src/specs/handoffCue.test.ts
2
+ /**
3
+ * End-to-end #345: a bare direct-edge successor on a prefill-semantics
4
+ * provider must receive a user-turn handoff cue after the predecessor's
5
+ * trailing assistant output — and tolerant providers must not.
6
+ */
7
+ import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
8
+ import type { BaseMessage } from '@langchain/core/messages';
9
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
10
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
11
+ import type * as t from '@/types';
12
+ import { Providers } from '@/common';
13
+ import { PREDECESSOR_HANDOFF_CUE } from '@/messages/handoffCue';
14
+ import { FakeChatModel } from '@/llm/fake';
15
+ import { Run } from '@/run';
16
+
17
+ class CapturingChatModel extends FakeChatModel {
18
+ readonly invocations: BaseMessage[][] = [];
19
+
20
+ override async *_streamResponseChunks(
21
+ messages: BaseMessage[],
22
+ options: this['ParsedCallOptions'],
23
+ runManager?: CallbackManagerForLLMRun
24
+ ): AsyncGenerator<ChatGenerationChunk> {
25
+ this.invocations.push(messages);
26
+ yield* super._streamResponseChunks(messages, options, runManager);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * A ChatModel whose streamed chunks never carry ids. Overrides
32
+ * `_streamIterator` — the seam BELOW LangChain's run-id stamping (which
33
+ * happens in `_generateUncached`, downstream of `_streamResponseChunks`, so
34
+ * a subclass override there cannot produce this shape) but still a Runnable,
35
+ * so `systemRunnable.pipe(model)` works. The public ChatModel contract
36
+ * requires no ids; custom implementations really do ship this.
37
+ */
38
+ class IdlessChatModel extends FakeChatModel {
39
+ readonly invocations: BaseMessage[][] = [];
40
+ private turn = 0;
41
+
42
+ constructor(private texts: string[]) {
43
+ super({ responses: texts });
44
+ }
45
+
46
+ override async *_streamIterator(
47
+ input: BaseMessage[]
48
+ ): AsyncGenerator<AIMessageChunk> {
49
+ this.invocations.push(input);
50
+ const text = this.texts[Math.min(this.turn, this.texts.length - 1)];
51
+ this.turn += 1;
52
+ yield new AIMessageChunk({ content: text });
53
+ }
54
+ }
55
+
56
+ const streamConfig = {
57
+ configurable: { thread_id: 'handoff-cue-e2e' },
58
+ streamMode: 'values' as const,
59
+ version: 'v2' as const,
60
+ };
61
+
62
+ async function runTwoAgents(
63
+ provider: Providers,
64
+ modelId = 'test-model',
65
+ idlessModel = false
66
+ ): Promise<{ criticPayload: BaseMessage[]; messages: BaseMessage[] }> {
67
+ const run = await Run.create<t.IState>({
68
+ runId: `handoff-cue-${provider}`,
69
+ graphConfig: {
70
+ type: 'multi-agent',
71
+ agents: [
72
+ {
73
+ agentId: 'writer',
74
+ provider,
75
+ clientOptions: { model: modelId, apiKey: 'test-key' },
76
+ instructions: 'You are the writer.',
77
+ },
78
+ {
79
+ agentId: 'critic',
80
+ provider,
81
+ clientOptions: { model: modelId, apiKey: 'test-key' },
82
+ instructions: 'You are the critic.',
83
+ },
84
+ ],
85
+ edges: [{ from: 'writer', to: 'critic', edgeType: 'direct' }],
86
+ },
87
+ returnContent: true,
88
+ skipCleanup: true,
89
+ });
90
+ if (!run.Graph) {
91
+ throw new Error('Expected graph to be initialized');
92
+ }
93
+ const model = idlessModel
94
+ ? new IdlessChatModel(['The essay text.', 'The critique text.'])
95
+ : new CapturingChatModel({
96
+ responses: ['The essay text.', 'The critique text.'],
97
+ });
98
+ run.Graph.overrideModel = model as never;
99
+
100
+ await run.processStream(
101
+ { messages: [new HumanMessage('write the essay')] },
102
+ streamConfig
103
+ );
104
+
105
+ expect(model.invocations).toHaveLength(2);
106
+ return {
107
+ criticPayload: model.invocations[1],
108
+ messages: run.getRunMessages() ?? [],
109
+ };
110
+ }
111
+
112
+ describe('bare direct-edge handoff cue (#345)', () => {
113
+ jest.setTimeout(15000);
114
+
115
+ it('anthropic successor payload ends with the cue, not the predecessor prefill', async () => {
116
+ const { criticPayload, messages } = await runTwoAgents(
117
+ Providers.ANTHROPIC
118
+ );
119
+ const last = criticPayload[criticPayload.length - 1];
120
+ expect(last.getType()).toBe('human');
121
+ expect(last.content).toBe(PREDECESSOR_HANDOFF_CUE);
122
+ expect(criticPayload[criticPayload.length - 2].getType()).toBe('ai');
123
+ /**
124
+ * Wire-only: the cue exists in the provider payload, never in run
125
+ * state or host-visible messages.
126
+ */
127
+ expect(
128
+ messages.some((m) => m.content === PREDECESSOR_HANDOFF_CUE)
129
+ ).toBe(false);
130
+ });
131
+
132
+ it('bedrock successor payload also gets the cue', async () => {
133
+ /** Bedrock anthropic-likeness is model-dependent — needs a Claude id. */
134
+ const { criticPayload } = await runTwoAgents(
135
+ Providers.BEDROCK,
136
+ 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
137
+ );
138
+ const last = criticPayload[criticPayload.length - 1];
139
+ expect(last.content).toBe(PREDECESSOR_HANDOFF_CUE);
140
+ });
141
+
142
+ /**
143
+ * The public ChatModel contract does not require ids. Provenance must not
144
+ * depend on the model setting one — the node assigns a reducer-style id
145
+ * before recording, so the successor still gets the cue.
146
+ */
147
+ it('applies the cue even when the model never sets message ids', async () => {
148
+ const { criticPayload } = await runTwoAgents(
149
+ Providers.ANTHROPIC,
150
+ 'test-model',
151
+ true
152
+ );
153
+ const last = criticPayload[criticPayload.length - 1];
154
+ expect(last.content).toBe(PREDECESSOR_HANDOFF_CUE);
155
+ });
156
+
157
+ it('openAI successor payload is untouched', async () => {
158
+ const { criticPayload } = await runTwoAgents(Providers.OPENAI);
159
+ const last = criticPayload[criticPayload.length - 1];
160
+ expect(last.getType()).toBe('ai');
161
+ expect(
162
+ criticPayload.some((m) => m.content === PREDECESSOR_HANDOFF_CUE)
163
+ ).toBe(false);
164
+ });
165
+ });