@librechat/agents 3.3.4 → 3.3.6

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 (263) hide show
  1. package/dist/cjs/common/constants.cjs +21 -0
  2. package/dist/cjs/common/constants.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +10 -1
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +456 -7
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  9. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  10. package/dist/cjs/hooks/index.cjs +12 -0
  11. package/dist/cjs/hooks/index.cjs.map +1 -1
  12. package/dist/cjs/hooks/types.cjs +1 -0
  13. package/dist/cjs/hooks/types.cjs.map +1 -1
  14. package/dist/cjs/langfuse.cjs +8 -0
  15. package/dist/cjs/langfuse.cjs.map +1 -1
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  17. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  18. package/dist/cjs/llm/invoke.cjs +268 -3
  19. package/dist/cjs/llm/invoke.cjs.map +1 -1
  20. package/dist/cjs/llm/openai/index.cjs +51 -6
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/preempt.cjs +132 -0
  23. package/dist/cjs/llm/preempt.cjs.map +1 -0
  24. package/dist/cjs/main.cjs +31 -3
  25. package/dist/cjs/messages/alternation.cjs +92 -0
  26. package/dist/cjs/messages/alternation.cjs.map +1 -0
  27. package/dist/cjs/messages/format.cjs +72 -0
  28. package/dist/cjs/messages/format.cjs.map +1 -1
  29. package/dist/cjs/messages/handoffCue.cjs +63 -0
  30. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  31. package/dist/cjs/messages/index.cjs +3 -0
  32. package/dist/cjs/messages/injected.cjs +60 -0
  33. package/dist/cjs/messages/injected.cjs.map +1 -0
  34. package/dist/cjs/run.cjs +80 -7
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
  37. package/dist/cjs/session/handlers.cjs +18 -9
  38. package/dist/cjs/session/handlers.cjs.map +1 -1
  39. package/dist/cjs/stream.cjs +34 -8
  40. package/dist/cjs/stream.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +3 -1
  42. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  43. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -0
  44. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  45. package/dist/cjs/tools/CodeExecutor.cjs +19 -16
  46. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  47. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +2 -0
  48. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  49. package/dist/cjs/tools/ReadFile.cjs +8 -4
  50. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  51. package/dist/cjs/tools/SkillTool.cjs +2 -0
  52. package/dist/cjs/tools/SkillTool.cjs.map +1 -1
  53. package/dist/cjs/tools/SubagentTool.cjs +3 -0
  54. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  55. package/dist/cjs/tools/ToolNode.cjs +91 -50
  56. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  57. package/dist/cjs/tools/ToolSearch.cjs +5 -1
  58. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  59. package/dist/cjs/tools/intentArg.cjs +230 -0
  60. package/dist/cjs/tools/intentArg.cjs.map +1 -0
  61. package/dist/cjs/tools/local/CompileCheckTool.cjs +3 -2
  62. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  63. package/dist/cjs/tools/local/LocalCodingTools.cjs +13 -12
  64. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  65. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  66. package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
  67. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  68. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  69. package/dist/cjs/tools/search/schema.cjs +2 -0
  70. package/dist/cjs/tools/search/schema.cjs.map +1 -1
  71. package/dist/cjs/tools/search/tool.cjs +40 -2
  72. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  73. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  74. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  75. package/dist/cjs/tools/toolOutputReferences.cjs +41 -7
  76. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  77. package/dist/esm/common/constants.mjs +19 -1
  78. package/dist/esm/common/constants.mjs.map +1 -1
  79. package/dist/esm/events.mjs +10 -1
  80. package/dist/esm/events.mjs.map +1 -1
  81. package/dist/esm/graphs/Graph.mjs +455 -6
  82. package/dist/esm/graphs/Graph.mjs.map +1 -1
  83. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  84. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  85. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  86. package/dist/esm/hooks/index.mjs +12 -1
  87. package/dist/esm/hooks/index.mjs.map +1 -1
  88. package/dist/esm/hooks/types.mjs +1 -0
  89. package/dist/esm/hooks/types.mjs.map +1 -1
  90. package/dist/esm/langfuse.mjs +9 -1
  91. package/dist/esm/langfuse.mjs.map +1 -1
  92. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  93. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  94. package/dist/esm/llm/invoke.mjs +270 -6
  95. package/dist/esm/llm/invoke.mjs.map +1 -1
  96. package/dist/esm/llm/openai/index.mjs +51 -6
  97. package/dist/esm/llm/openai/index.mjs.map +1 -1
  98. package/dist/esm/llm/preempt.mjs +131 -0
  99. package/dist/esm/llm/preempt.mjs.map +1 -0
  100. package/dist/esm/main.mjs +11 -7
  101. package/dist/esm/messages/alternation.mjs +91 -0
  102. package/dist/esm/messages/alternation.mjs.map +1 -0
  103. package/dist/esm/messages/format.mjs +72 -0
  104. package/dist/esm/messages/format.mjs.map +1 -1
  105. package/dist/esm/messages/handoffCue.mjs +61 -0
  106. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  107. package/dist/esm/messages/index.mjs +3 -0
  108. package/dist/esm/messages/injected.mjs +60 -0
  109. package/dist/esm/messages/injected.mjs.map +1 -0
  110. package/dist/esm/run.mjs +80 -7
  111. package/dist/esm/run.mjs.map +1 -1
  112. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  113. package/dist/esm/session/handlers.mjs +19 -10
  114. package/dist/esm/session/handlers.mjs.map +1 -1
  115. package/dist/esm/stream.mjs +33 -9
  116. package/dist/esm/stream.mjs.map +1 -1
  117. package/dist/esm/tools/BashExecutor.mjs +3 -1
  118. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  119. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -0
  120. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  121. package/dist/esm/tools/CodeExecutor.mjs +19 -16
  122. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  123. package/dist/esm/tools/ProgrammaticToolCalling.mjs +2 -0
  124. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  125. package/dist/esm/tools/ReadFile.mjs +8 -4
  126. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  127. package/dist/esm/tools/SkillTool.mjs +2 -0
  128. package/dist/esm/tools/SkillTool.mjs.map +1 -1
  129. package/dist/esm/tools/SubagentTool.mjs +3 -0
  130. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  131. package/dist/esm/tools/ToolNode.mjs +91 -51
  132. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  133. package/dist/esm/tools/ToolSearch.mjs +5 -1
  134. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  135. package/dist/esm/tools/intentArg.mjs +220 -0
  136. package/dist/esm/tools/intentArg.mjs.map +1 -0
  137. package/dist/esm/tools/local/CompileCheckTool.mjs +3 -2
  138. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  139. package/dist/esm/tools/local/LocalCodingTools.mjs +13 -12
  140. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  141. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  142. package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
  143. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  144. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  145. package/dist/esm/tools/search/schema.mjs +2 -0
  146. package/dist/esm/tools/search/schema.mjs.map +1 -1
  147. package/dist/esm/tools/search/tool.mjs +40 -3
  148. package/dist/esm/tools/search/tool.mjs.map +1 -1
  149. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  150. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  151. package/dist/esm/tools/toolOutputReferences.mjs +41 -7
  152. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  153. package/dist/types/common/constants.d.ts +18 -0
  154. package/dist/types/graphs/Graph.d.ts +138 -1
  155. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  156. package/dist/types/hooks/index.d.ts +12 -1
  157. package/dist/types/hooks/types.d.ts +45 -6
  158. package/dist/types/index.d.ts +2 -1
  159. package/dist/types/llm/invoke.d.ts +7 -0
  160. package/dist/types/llm/openai/index.d.ts +0 -11
  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/handoffCue.d.ts +40 -0
  164. package/dist/types/messages/index.d.ts +3 -0
  165. package/dist/types/messages/injected.d.ts +3 -0
  166. package/dist/types/run.d.ts +7 -0
  167. package/dist/types/stream.d.ts +14 -0
  168. package/dist/types/tools/BashExecutor.d.ts +18 -0
  169. package/dist/types/tools/CodeExecutor.d.ts +18 -0
  170. package/dist/types/tools/ReadFile.d.ts +18 -0
  171. package/dist/types/tools/SkillTool.d.ts +18 -0
  172. package/dist/types/tools/SubagentTool.d.ts +9 -0
  173. package/dist/types/tools/ToolNode.d.ts +37 -12
  174. package/dist/types/tools/ToolSearch.d.ts +18 -0
  175. package/dist/types/tools/intentArg.d.ts +119 -0
  176. package/dist/types/tools/ptcTimeout.d.ts +2 -0
  177. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  178. package/dist/types/tools/search/schema.d.ts +18 -0
  179. package/dist/types/tools/search/tool.d.ts +18 -0
  180. package/dist/types/tools/search/types.d.ts +31 -2
  181. package/dist/types/tools/toolOutputReferences.d.ts +12 -2
  182. package/dist/types/types/graph.d.ts +8 -1
  183. package/dist/types/types/run.d.ts +65 -0
  184. package/dist/types/types/stream.d.ts +12 -25
  185. package/dist/types/types/tools.d.ts +27 -2
  186. package/package.json +1 -1
  187. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  188. package/src/common/constants.ts +21 -0
  189. package/src/events.ts +15 -1
  190. package/src/graphs/Graph.ts +568 -3
  191. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  192. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  193. package/src/hooks/HookRegistry.ts +40 -0
  194. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  195. package/src/hooks/index.ts +16 -2
  196. package/src/hooks/types.ts +47 -3
  197. package/src/index.ts +2 -1
  198. package/src/langfuse.ts +26 -1
  199. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  200. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  201. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  202. package/src/llm/invoke.alternation.test.ts +87 -0
  203. package/src/llm/invoke.handoffCue.test.ts +112 -0
  204. package/src/llm/invoke.ts +417 -8
  205. package/src/llm/openai/index.ts +74 -10
  206. package/src/llm/openai/llm.spec.ts +105 -0
  207. package/src/llm/preempt.test.ts +323 -0
  208. package/src/llm/preempt.ts +178 -0
  209. package/src/messages/alternation.test.ts +212 -0
  210. package/src/messages/alternation.ts +112 -0
  211. package/src/messages/format.ts +91 -0
  212. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  213. package/src/messages/handoffCue.test.ts +96 -0
  214. package/src/messages/handoffCue.ts +78 -0
  215. package/src/messages/index.ts +3 -0
  216. package/src/messages/injected.test.ts +90 -0
  217. package/src/messages/injected.ts +74 -0
  218. package/src/run.ts +91 -6
  219. package/src/scripts/preempt-probe.ts +330 -0
  220. package/src/scripts/preempt-scenarios.ts +388 -0
  221. package/src/session/handlers.ts +32 -12
  222. package/src/specs/handoffCue.test.ts +165 -0
  223. package/src/specs/langfuse-callbacks.test.ts +352 -2
  224. package/src/specs/preemptSeal.test.ts +309 -0
  225. package/src/stream.dispatch.test.ts +63 -0
  226. package/src/stream.ts +48 -9
  227. package/src/tools/BashExecutor.ts +6 -0
  228. package/src/tools/BashProgrammaticToolCalling.ts +2 -0
  229. package/src/tools/CodeExecutor.ts +6 -0
  230. package/src/tools/ProgrammaticToolCalling.ts +2 -0
  231. package/src/tools/ReadFile.ts +2 -0
  232. package/src/tools/SkillTool.ts +2 -0
  233. package/src/tools/SubagentTool.ts +3 -0
  234. package/src/tools/ToolNode.ts +193 -74
  235. package/src/tools/ToolSearch.ts +3 -0
  236. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +174 -0
  237. package/src/tools/__tests__/intentArg.test.ts +281 -0
  238. package/src/tools/__tests__/intentCoverage.test.ts +139 -0
  239. package/src/tools/__tests__/toolOutputReferences.test.ts +83 -0
  240. package/src/tools/intentArg.ts +323 -0
  241. package/src/tools/local/CompileCheckTool.ts +4 -3
  242. package/src/tools/local/LocalCodingTools.ts +14 -13
  243. package/src/tools/ptcTimeout.ts +2 -0
  244. package/src/tools/search/keenable-scraper.test.ts +153 -0
  245. package/src/tools/search/keenable-scraper.ts +137 -0
  246. package/src/tools/search/outcome.test.ts +90 -0
  247. package/src/tools/search/schema.ts +3 -0
  248. package/src/tools/search/tool.ts +60 -3
  249. package/src/tools/search/types.ts +50 -3
  250. package/src/tools/subagent/SubagentExecutor.ts +2 -1
  251. package/src/tools/toolOutputReferences.ts +77 -9
  252. package/src/types/graph.ts +12 -1
  253. package/src/types/run.ts +67 -0
  254. package/src/types/stream.ts +12 -41
  255. package/src/types/tools.ts +24 -2
  256. package/dist/cjs/splitStream.cjs +0 -151
  257. package/dist/cjs/splitStream.cjs.map +0 -1
  258. package/dist/esm/splitStream.mjs +0 -150
  259. package/dist/esm/splitStream.mjs.map +0 -1
  260. package/dist/types/mockStream.d.ts +0 -32
  261. package/dist/types/splitStream.d.ts +0 -37
  262. package/src/mockStream.ts +0 -99
  263. 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
+ });
@@ -0,0 +1,63 @@
1
+ import type * as t from '@/types';
2
+ import {
3
+ ChatModelStreamHandler,
4
+ dispatchesChatModelStream,
5
+ SDK_STREAM_DISPATCH,
6
+ } from '@/stream';
7
+ import { composeEventHandlers } from '@/events';
8
+ import { GraphEvents } from '@/common';
9
+
10
+ const inert: t.EventHandler = {
11
+ handle: (): void => {
12
+ /* renders nothing */
13
+ },
14
+ };
15
+
16
+ describe('dispatchesChatModelStream', () => {
17
+ it('recognizes the dispatcher itself', () => {
18
+ expect(dispatchesChatModelStream(new ChatModelStreamHandler())).toBe(true);
19
+ });
20
+
21
+ it('is false for a handler that owns no content-part dispatch', () => {
22
+ expect(dispatchesChatModelStream(inert)).toBe(false);
23
+ });
24
+
25
+ it('is false for nothing registered', () => {
26
+ expect(dispatchesChatModelStream(undefined)).toBe(false);
27
+ });
28
+
29
+ /**
30
+ * The reason identity is not a usable test: hosts wrap handlers, and every
31
+ * wrapper fails `instanceof` while still driving the same dispatch. A run
32
+ * that seals on that basis would be sealing against its own contract.
33
+ */
34
+ it('sees through a composed wrapper in either position', () => {
35
+ const first = composeEventHandlers(
36
+ { [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler() },
37
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert }
38
+ )[GraphEvents.CHAT_MODEL_STREAM];
39
+ const second = composeEventHandlers(
40
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert },
41
+ { [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler() }
42
+ )[GraphEvents.CHAT_MODEL_STREAM];
43
+
44
+ expect(first).not.toBeInstanceOf(ChatModelStreamHandler);
45
+ expect(second).not.toBeInstanceOf(ChatModelStreamHandler);
46
+ expect(dispatchesChatModelStream(first)).toBe(true);
47
+ expect(dispatchesChatModelStream(second)).toBe(true);
48
+ });
49
+
50
+ it('does not brand a composition of inert handlers', () => {
51
+ const composed = composeEventHandlers(
52
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert },
53
+ { [GraphEvents.CHAT_MODEL_STREAM]: inert }
54
+ )[GraphEvents.CHAT_MODEL_STREAM];
55
+ expect(dispatchesChatModelStream(composed)).toBe(false);
56
+ });
57
+
58
+ it('exposes the brand as a cross-realm-safe registered symbol', () => {
59
+ expect(SDK_STREAM_DISPATCH).toBe(
60
+ Symbol.for('@librechat/agents:chatModelStreamDispatch')
61
+ );
62
+ });
63
+ });
package/src/stream.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  calculateMaxToolResultChars,
40
40
  truncateToolResultContent,
41
41
  } from '@/utils/truncation';
42
+ import { resolveToolOutcome, outcomeFieldsFromResult } from '@/tools/intentArg';
42
43
  import { TOOL_OUTPUT_REF_PATTERN } from '@/tools/toolOutputReferences';
43
44
  import { safeDispatchCustomEvent } from '@/utils/events';
44
45
  import { isGoogleLike } from '@/utils/llm';
@@ -859,6 +860,11 @@ async function dispatchEagerToolCompletions(args: {
859
860
  maxToolResultChars
860
861
  ).content;
861
862
  }
863
+ const outcome = resolveToolOutcome(
864
+ record.request.args,
865
+ outcomeFieldsFromResult(result),
866
+ { isError: result.status === 'error' }
867
+ );
862
868
 
863
869
  try {
864
870
  const dispatched = await safeDispatchCustomEvent(
@@ -878,6 +884,7 @@ async function dispatchEagerToolCompletions(args: {
878
884
  id: result.toolCallId,
879
885
  output,
880
886
  progress: 1,
887
+ ...(outcome != null && { outcome }),
881
888
  } as t.ProcessedToolCall,
882
889
  },
883
890
  },
@@ -1400,7 +1407,34 @@ function shouldSkipLateOpenRouterReasoningChunk({
1400
1407
  );
1401
1408
  }
1402
1409
 
1410
+ /**
1411
+ * Brands a handler as one that dispatches content parts for the SDK — either
1412
+ * `ChatModelStreamHandler` itself or a wrapper forwarding to one.
1413
+ *
1414
+ * Identity alone is not a usable contract here. Hosts compose and wrap
1415
+ * handlers (`composeEventHandlers`, `createRunHandlers`), and every wrapper
1416
+ * fails `instanceof` while still driving the same dispatch. A brand survives
1417
+ * wrapping, so "does this handler own content-part dispatch" can be answered
1418
+ * about a value the SDK did not construct.
1419
+ */
1420
+ export const SDK_STREAM_DISPATCH = Symbol.for(
1421
+ '@librechat/agents:chatModelStreamDispatch'
1422
+ );
1423
+
1424
+ /** True when `handler` is, or forwards to, the SDK's stream dispatcher. */
1425
+ export function dispatchesChatModelStream(handler?: t.EventHandler): boolean {
1426
+ if (handler == null) {
1427
+ return false;
1428
+ }
1429
+ if (handler instanceof ChatModelStreamHandler) {
1430
+ return true;
1431
+ }
1432
+ return Reflect.get(handler, SDK_STREAM_DISPATCH) === true;
1433
+ }
1434
+
1403
1435
  export class ChatModelStreamHandler implements t.EventHandler {
1436
+ readonly [SDK_STREAM_DISPATCH] = true;
1437
+
1404
1438
  async handle(
1405
1439
  event: string,
1406
1440
  data: t.StreamEventData,
@@ -1846,13 +1880,16 @@ export function createContentAggregator(): t.ContentAggregatorResult {
1846
1880
  number,
1847
1881
  { agentId?: string; groupId?: number }
1848
1882
  >();
1849
- const getFirstContentPart = (
1883
+ /** A delta's content may carry several parts (e.g. Google server-side tool
1884
+ * chunks emit multiple reasoning entries at once); every entry must reach
1885
+ * the step's slot, in order, or streamed text is silently lost. */
1886
+ const getDeltaContentParts = (
1850
1887
  content?: t.MessageDelta['content'] | t.MessageContentComplex
1851
- ): t.MessageContentComplex | undefined => {
1888
+ ): t.MessageContentComplex[] => {
1852
1889
  if (content == null) {
1853
- return undefined;
1890
+ return [];
1854
1891
  }
1855
- return Array.isArray(content) ? content[0] : content;
1892
+ return Array.isArray(content) ? content : [content];
1856
1893
  };
1857
1894
  const indexContentPart = (
1858
1895
  index: number,
@@ -2130,7 +2167,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2130
2167
  toolCallContentIndexMap.delete(existingToolCallId);
2131
2168
  }
2132
2169
 
2133
- const newToolCall: ToolCall & t.PartMetadata = {
2170
+ const newToolCall: ToolCall & t.PartMetadata & { outcome?: string } = {
2134
2171
  id,
2135
2172
  name,
2136
2173
  args,
@@ -2150,6 +2187,10 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2150
2187
  if (finalUpdate) {
2151
2188
  newToolCall.progress = 1;
2152
2189
  newToolCall.output = contentPart.tool_call.output;
2190
+ const outcome = (contentPart.tool_call as t.ToolCallPart).outcome;
2191
+ if (outcome != null) {
2192
+ newToolCall.outcome = outcome;
2193
+ }
2153
2194
  }
2154
2195
 
2155
2196
  contentParts[index] = {
@@ -2284,8 +2325,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2284
2325
  return;
2285
2326
  }
2286
2327
 
2287
- const contentPart = getFirstContentPart(messageDelta.delta.content);
2288
- if (contentPart != null) {
2328
+ for (const contentPart of getDeltaContentParts(messageDelta.delta.content)) {
2289
2329
  updateContent(runStep.index, contentPart);
2290
2330
  }
2291
2331
  } else if (
@@ -2314,8 +2354,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2314
2354
  return;
2315
2355
  }
2316
2356
 
2317
- const contentPart = getFirstContentPart(reasoningDelta.delta.content);
2318
- if (contentPart != null) {
2357
+ for (const contentPart of getDeltaContentParts(reasoningDelta.delta.content)) {
2319
2358
  updateContent(runStep.index, contentPart);
2320
2359
  }
2321
2360
  } else if (event === GraphEvents.ON_RUN_STEP_DELTA) {
@@ -16,6 +16,7 @@ import {
16
16
  normalizeCodeApiRequestError,
17
17
  resolveCodeApiAuthHeaders,
18
18
  } from './CodeExecutor';
19
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
19
20
  import { Constants } from '@/common';
20
21
 
21
22
  config();
@@ -26,6 +27,7 @@ const EXEC_ENDPOINT = `${baseEndpoint}/exec`;
26
27
  export const BashExecutionToolSchema = {
27
28
  type: 'object',
28
29
  properties: {
30
+ intent: { ...INTENT_PROPERTY },
29
31
  command: {
30
32
  type: 'string',
31
33
  description: `The bash command or script to execute.
@@ -187,16 +189,20 @@ function createBashExecutionTool(
187
189
  /* Drop any model-supplied `runtime_session_hint` from the raw args: the
188
190
  * hint must only come from ToolNode's injected `_runtime_session_hint`
189
191
  * (below), never from the tool call itself. */
192
+ /* `intent` is a UI display label — never part of the wire body. */
190
193
  const {
191
194
  command,
195
+ intent: _ignoredIntent,
192
196
  runtime_session_hint: _ignoredModelHint,
193
197
  ...rest
194
198
  } = rawInput as {
195
199
  command: string;
200
+ intent?: unknown;
196
201
  runtime_session_hint?: unknown;
197
202
  args?: string[];
198
203
  };
199
204
  void _ignoredModelHint;
205
+ void _ignoredIntent;
200
206
  const { session_id, _injected_files, _runtime_session_hint } =
201
207
  (config.toolCall ?? {}) as {
202
208
  session_id?: string;
@@ -21,6 +21,7 @@ import {
21
21
  executeTools,
22
22
  formatCompletedResponse,
23
23
  } from './ProgrammaticToolCalling';
24
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
24
25
  import { Constants } from '@/common';
25
26
 
26
27
  config();
@@ -115,6 +116,7 @@ export function createBashProgrammaticToolCallingSchema(
115
116
  return {
116
117
  type: 'object',
117
118
  properties: {
119
+ intent: { ...INTENT_PROPERTY },
118
120
  code: {
119
121
  type: 'string',
120
122
  minLength: 1,
@@ -5,6 +5,7 @@ import { getEnvironmentVariable } from '@langchain/core/utils/env';
5
5
  import { tool, DynamicStructuredTool } from '@langchain/core/tools';
6
6
  import type * as t from '@/types';
7
7
  import { appendCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
8
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
8
9
  import { EnvVar, Constants } from '@/common';
9
10
 
10
11
  export {
@@ -75,6 +76,7 @@ const SUPPORTED_LANGUAGES = [
75
76
  export const CodeExecutionToolSchema = {
76
77
  type: 'object',
77
78
  properties: {
79
+ intent: { ...INTENT_PROPERTY },
78
80
  lang: {
79
81
  type: 'string',
80
82
  enum: SUPPORTED_LANGUAGES,
@@ -346,18 +348,22 @@ function createCodeExecutionTool(
346
348
  * injected `_runtime_session_hint` (below). Spreading `...rest` into
347
349
  * postData would otherwise let a tool call opt itself into / pick a
348
350
  * stateful runtime even when statefulSessions is off. */
351
+ /* `intent` is a UI display label — never part of the wire body. */
349
352
  const {
350
353
  lang,
351
354
  code,
355
+ intent: _ignoredIntent,
352
356
  runtime_session_hint: _ignoredModelHint,
353
357
  ...rest
354
358
  } = rawInput as {
355
359
  lang: SupportedLanguage;
356
360
  code: string;
361
+ intent?: unknown;
357
362
  runtime_session_hint?: unknown;
358
363
  args?: string[];
359
364
  };
360
365
  void _ignoredModelHint;
366
+ void _ignoredIntent;
361
367
  /**
362
368
  * Extract session context from config.toolCall (injected by ToolNode).
363
369
  * - session_id: associates with the previous run.
@@ -24,6 +24,7 @@ import {
24
24
  createCodeApiRunTimeoutSchema,
25
25
  resolveCodeApiRunTimeoutMs,
26
26
  } from './ptcTimeout';
27
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
27
28
  import { Constants } from '@/common';
28
29
 
29
30
  config();
@@ -89,6 +90,7 @@ export function createProgrammaticToolCallingSchema(
89
90
  return {
90
91
  type: 'object',
91
92
  properties: {
93
+ intent: { ...INTENT_PROPERTY },
92
94
  code: {
93
95
  type: 'string',
94
96
  minLength: 1,
@@ -1,4 +1,5 @@
1
1
  // src/tools/ReadFile.ts
2
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
2
3
  import { Constants } from '@/common';
3
4
 
4
5
  export const ReadFileToolName = Constants.READ_FILE;
@@ -22,6 +23,7 @@ CONSTRAINTS:
22
23
  export const ReadFileToolSchema = {
23
24
  type: 'object',
24
25
  properties: {
26
+ intent: { ...INTENT_PROPERTY },
25
27
  path: {
26
28
  type: 'string',
27
29
  description:
@@ -1,4 +1,5 @@
1
1
  // src/tools/SkillTool.ts
2
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
2
3
  import { Constants } from '@/common';
3
4
 
4
5
  export const SkillToolName = Constants.SKILL_TOOL;
@@ -26,6 +27,7 @@ CONSTRAINTS:
26
27
  export const SkillToolSchema = {
27
28
  type: 'object',
28
29
  properties: {
30
+ intent: { ...INTENT_PROPERTY },
29
31
  skillName: {
30
32
  type: 'string',
31
33
  description:
@@ -1,5 +1,6 @@
1
1
  import type { JsonSchemaType, LCTool } from '@/types/tools';
2
2
  import type { SubagentConfig } from '@/types';
3
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
3
4
  import { Constants } from '@/common';
4
5
 
5
6
  export const SubagentToolName = Constants.SUBAGENT;
@@ -29,6 +30,7 @@ const SUBAGENT_TYPE_PROP_DESCRIPTION =
29
30
  export const SubagentToolSchema = {
30
31
  type: 'object',
31
32
  properties: {
33
+ intent: { ...INTENT_PROPERTY },
32
34
  description: {
33
35
  type: 'string',
34
36
  description: DESCRIPTION_PROP_DESCRIPTION,
@@ -67,6 +69,7 @@ export function buildSubagentToolParams(configs: SubagentConfig[]): {
67
69
  schema: {
68
70
  type: 'object',
69
71
  properties: {
72
+ intent: { ...INTENT_PROPERTY },
70
73
  description: {
71
74
  type: 'string',
72
75
  description: DESCRIPTION_PROP_DESCRIPTION,