@librechat/agents 3.3.4 → 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 (159) 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/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/langfuse.cjs +8 -0
  14. package/dist/cjs/langfuse.cjs.map +1 -1
  15. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  17. package/dist/cjs/llm/invoke.cjs +268 -3
  18. package/dist/cjs/llm/invoke.cjs.map +1 -1
  19. package/dist/cjs/llm/preempt.cjs +132 -0
  20. package/dist/cjs/llm/preempt.cjs.map +1 -0
  21. package/dist/cjs/main.cjs +17 -3
  22. package/dist/cjs/messages/alternation.cjs +92 -0
  23. package/dist/cjs/messages/alternation.cjs.map +1 -0
  24. package/dist/cjs/messages/format.cjs +72 -0
  25. package/dist/cjs/messages/format.cjs.map +1 -1
  26. package/dist/cjs/messages/handoffCue.cjs +63 -0
  27. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  28. package/dist/cjs/messages/index.cjs +3 -0
  29. package/dist/cjs/messages/injected.cjs +60 -0
  30. package/dist/cjs/messages/injected.cjs.map +1 -0
  31. package/dist/cjs/run.cjs +80 -7
  32. package/dist/cjs/run.cjs.map +1 -1
  33. package/dist/cjs/session/handlers.cjs +18 -9
  34. package/dist/cjs/session/handlers.cjs.map +1 -1
  35. package/dist/cjs/stream.cjs +28 -7
  36. package/dist/cjs/stream.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +3 -23
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  40. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  41. package/dist/cjs/tools/search/tool.cjs +9 -1
  42. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  43. package/dist/esm/common/constants.mjs +19 -1
  44. package/dist/esm/common/constants.mjs.map +1 -1
  45. package/dist/esm/events.mjs +10 -1
  46. package/dist/esm/events.mjs.map +1 -1
  47. package/dist/esm/graphs/Graph.mjs +455 -6
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  50. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  51. package/dist/esm/hooks/index.mjs +12 -1
  52. package/dist/esm/hooks/index.mjs.map +1 -1
  53. package/dist/esm/hooks/types.mjs +1 -0
  54. package/dist/esm/hooks/types.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +9 -1
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  58. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  59. package/dist/esm/llm/invoke.mjs +270 -6
  60. package/dist/esm/llm/invoke.mjs.map +1 -1
  61. package/dist/esm/llm/preempt.mjs +131 -0
  62. package/dist/esm/llm/preempt.mjs.map +1 -0
  63. package/dist/esm/main.mjs +8 -5
  64. package/dist/esm/messages/alternation.mjs +91 -0
  65. package/dist/esm/messages/alternation.mjs.map +1 -0
  66. package/dist/esm/messages/format.mjs +72 -0
  67. package/dist/esm/messages/format.mjs.map +1 -1
  68. package/dist/esm/messages/handoffCue.mjs +61 -0
  69. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  70. package/dist/esm/messages/index.mjs +3 -0
  71. package/dist/esm/messages/injected.mjs +60 -0
  72. package/dist/esm/messages/injected.mjs.map +1 -0
  73. package/dist/esm/run.mjs +80 -7
  74. package/dist/esm/run.mjs.map +1 -1
  75. package/dist/esm/session/handlers.mjs +19 -10
  76. package/dist/esm/session/handlers.mjs.map +1 -1
  77. package/dist/esm/stream.mjs +27 -8
  78. package/dist/esm/stream.mjs.map +1 -1
  79. package/dist/esm/tools/ToolNode.mjs +3 -23
  80. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  81. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  82. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  83. package/dist/esm/tools/search/tool.mjs +9 -1
  84. package/dist/esm/tools/search/tool.mjs.map +1 -1
  85. package/dist/types/common/constants.d.ts +18 -0
  86. package/dist/types/graphs/Graph.d.ts +138 -1
  87. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  88. package/dist/types/hooks/index.d.ts +12 -1
  89. package/dist/types/hooks/types.d.ts +45 -6
  90. package/dist/types/index.d.ts +1 -1
  91. package/dist/types/llm/invoke.d.ts +7 -0
  92. package/dist/types/llm/preempt.d.ts +40 -0
  93. package/dist/types/messages/alternation.d.ts +23 -0
  94. package/dist/types/messages/handoffCue.d.ts +40 -0
  95. package/dist/types/messages/index.d.ts +3 -0
  96. package/dist/types/messages/injected.d.ts +3 -0
  97. package/dist/types/run.d.ts +7 -0
  98. package/dist/types/stream.d.ts +14 -0
  99. package/dist/types/tools/ToolNode.d.ts +0 -7
  100. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  101. package/dist/types/tools/search/types.d.ts +31 -2
  102. package/dist/types/types/graph.d.ts +8 -1
  103. package/dist/types/types/run.d.ts +65 -0
  104. package/dist/types/types/stream.d.ts +0 -25
  105. package/package.json +1 -1
  106. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  107. package/src/common/constants.ts +21 -0
  108. package/src/events.ts +15 -1
  109. package/src/graphs/Graph.ts +568 -3
  110. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  111. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  112. package/src/hooks/HookRegistry.ts +40 -0
  113. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  114. package/src/hooks/index.ts +16 -2
  115. package/src/hooks/types.ts +47 -3
  116. package/src/index.ts +1 -1
  117. package/src/langfuse.ts +26 -1
  118. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  119. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  120. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  121. package/src/llm/invoke.alternation.test.ts +87 -0
  122. package/src/llm/invoke.handoffCue.test.ts +112 -0
  123. package/src/llm/invoke.ts +417 -8
  124. package/src/llm/preempt.test.ts +323 -0
  125. package/src/llm/preempt.ts +178 -0
  126. package/src/messages/alternation.test.ts +212 -0
  127. package/src/messages/alternation.ts +112 -0
  128. package/src/messages/format.ts +91 -0
  129. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  130. package/src/messages/handoffCue.test.ts +96 -0
  131. package/src/messages/handoffCue.ts +78 -0
  132. package/src/messages/index.ts +3 -0
  133. package/src/messages/injected.test.ts +90 -0
  134. package/src/messages/injected.ts +74 -0
  135. package/src/run.ts +91 -6
  136. package/src/scripts/preempt-probe.ts +330 -0
  137. package/src/scripts/preempt-scenarios.ts +388 -0
  138. package/src/session/handlers.ts +32 -12
  139. package/src/specs/handoffCue.test.ts +165 -0
  140. package/src/specs/langfuse-callbacks.test.ts +352 -2
  141. package/src/specs/preemptSeal.test.ts +309 -0
  142. package/src/stream.dispatch.test.ts +63 -0
  143. package/src/stream.ts +36 -8
  144. package/src/tools/ToolNode.ts +3 -31
  145. package/src/tools/search/keenable-scraper.test.ts +153 -0
  146. package/src/tools/search/keenable-scraper.ts +137 -0
  147. package/src/tools/search/tool.ts +13 -2
  148. package/src/tools/search/types.ts +50 -3
  149. package/src/types/graph.ts +12 -1
  150. package/src/types/run.ts +67 -0
  151. package/src/types/stream.ts +0 -41
  152. package/dist/cjs/splitStream.cjs +0 -151
  153. package/dist/cjs/splitStream.cjs.map +0 -1
  154. package/dist/esm/splitStream.mjs +0 -150
  155. package/dist/esm/splitStream.mjs.map +0 -1
  156. package/dist/types/mockStream.d.ts +0 -32
  157. package/dist/types/splitStream.d.ts +0 -37
  158. package/src/mockStream.ts +0 -99
  159. package/src/splitStream.ts +0 -234
package/src/run.ts CHANGED
@@ -49,8 +49,14 @@ import {
49
49
  createTitleRunnable,
50
50
  } from '@/utils/title';
51
51
  import { createTokenCounter, encodingForModel } from '@/utils/tokens';
52
+ import { resolveMaxSeals } from '@/llm/preempt';
52
53
  import { initializeLangfuseTracing } from './instrumentation';
53
- import { GraphEvents, Callback, TitleMethod } from '@/common';
54
+ import {
55
+ Callback,
56
+ GraphEvents,
57
+ TitleMethod,
58
+ DEFAULT_RECURSION_LIMIT,
59
+ } from '@/common';
54
60
  import { MultiAgentGraph } from '@/graphs/MultiAgentGraph';
55
61
  import { getTraceIdSeed } from '@/langfuseRuntimeContext';
56
62
  import { StandardGraph } from '@/graphs/Graph';
@@ -140,6 +146,7 @@ export class Run<_T extends t.BaseGraphState> {
140
146
  private interruptingToolNames?: string[];
141
147
  private toolExecution?: t.ToolExecutionConfig;
142
148
  private subagentUsageSink?: t.SubagentUsageSink;
149
+ private preemption?: t.StreamPreemption;
143
150
  private indexTokenCountMap?: Record<string, number>;
144
151
  calibrationRatio: number = 1;
145
152
  graphRunnable?: t.CompiledStateWorkflow;
@@ -201,6 +208,7 @@ export class Run<_T extends t.BaseGraphState> {
201
208
  this.interruptingToolNames = config.interruptingToolNames;
202
209
  this.toolExecution = config.toolExecution;
203
210
  this.subagentUsageSink = config.subagentUsageSink;
211
+ this.preemption = config.preemption;
204
212
 
205
213
  if (!config.graphConfig) {
206
214
  throw new Error('Graph config not provided');
@@ -275,6 +283,7 @@ export class Run<_T extends t.BaseGraphState> {
275
283
  indexTokenCountMap: this.indexTokenCountMap,
276
284
  calibrationRatio: this.calibrationRatio,
277
285
  subagentUsageSink: this.subagentUsageSink,
286
+ preemption: this.preemption,
278
287
  });
279
288
  /** Propagate compile options from graph config */
280
289
  standardGraph.compileOptions = this.applyHITLCheckpointerFallback(
@@ -306,6 +315,7 @@ export class Run<_T extends t.BaseGraphState> {
306
315
  indexTokenCountMap: this.indexTokenCountMap,
307
316
  calibrationRatio: this.calibrationRatio,
308
317
  subagentUsageSink: this.subagentUsageSink,
318
+ preemption: this.preemption,
309
319
  });
310
320
 
311
321
  multiAgentGraph.compileOptions =
@@ -541,6 +551,15 @@ export class Run<_T extends t.BaseGraphState> {
541
551
  return this.Graph?.getResolvedInstructionOverhead();
542
552
  }
543
553
 
554
+ /**
555
+ * Cooperative-seal counters for this run. `emptyBoundaries` is the one to
556
+ * watch: it counts seals whose `PreemptBoundary` produced nothing to
557
+ * inject, which ends the turn early and leaves the answer unfinished.
558
+ */
559
+ getPreemptStats(): t.PreemptStats {
560
+ return this.Graph?.getPreemptStats() ?? { seals: 0, emptyBoundaries: 0 };
561
+ }
562
+
544
563
  getToolCount(): number {
545
564
  return this.Graph?.getToolCount() ?? 0;
546
565
  }
@@ -703,12 +722,38 @@ export class Run<_T extends t.BaseGraphState> {
703
722
  const isResume = inputs instanceof Command;
704
723
  const stateInputs = isResume ? undefined : (inputs as t.IState);
705
724
 
725
+ /**
726
+ * Every honored seal costs one extra superstep, so a preemption-enabled
727
+ * run reserves headroom for its whole seal budget. Without it, a
728
+ * tool-heavy agent that gets preempted could hit `GraphRecursionError` —
729
+ * which surfaces as a thrown stream, setting `streamThrew`, firing
730
+ * `StopFailure`, and wiping via `clearHeavyState()` exactly the partial
731
+ * content the seal existed to preserve.
732
+ */
733
+ const recursionLimit =
734
+ (callerConfig.recursionLimit ?? DEFAULT_RECURSION_LIMIT) +
735
+ (this.preemption != null ? resolveMaxSeals(this.preemption.maxSeals) : 0);
736
+
706
737
  const config: t.RunStreamConfig = {
707
- recursionLimit: 50,
708
738
  ...callerConfig,
739
+ recursionLimit,
709
740
  configurable: { ...callerConfig.configurable },
710
741
  };
711
742
 
743
+ /**
744
+ * Cancellation can arrive either at graph construction or per-call through
745
+ * `callerConfig.signal`, and boundary hooks need to observe both — for a
746
+ * multi-agent run the construction signal does not exist at all, since
747
+ * `MultiAgentGraphConfig` exposes none. Carried on its own field, assigned
748
+ * unconditionally: writing into `graph.signal` would leak this call's
749
+ * controller into later calls (model-call config and subagent
750
+ * parentSignal read that field, and `clearHeavyState()` is skipped on
751
+ * HITL interrupts), while a conditional write would keep observing a
752
+ * stale controller the host has since aborted. The boundary dispatch
753
+ * composes both channels; see `dispatchPreemptBoundary`.
754
+ */
755
+ graph.callerSignal = callerConfig.signal;
756
+
712
757
  /**
713
758
  * Skip `resetValues` on resume — we're continuing an in-flight
714
759
  * run, not starting a fresh one. Resetting would wipe the
@@ -923,10 +968,17 @@ export class Run<_T extends t.BaseGraphState> {
923
968
  * graph doesn't take another model turn after the halting
924
969
  * operation completes.
925
970
  *
926
- * Limitation: the current step (in-flight model call, ongoing
927
- * tool batch) is not aborted only the next step is skipped.
928
- * This matches Claude Code's `continue: false` semantic where
929
- * the active operation finishes before halting takes effect.
971
+ * This `break` is NOT graceful, despite what a `continue: false`
972
+ * reading suggests. Leaving the `for await` calls the iterator's
973
+ * `return()`, which cancels the reader
974
+ * (`@langchain/core/utils/stream`), and langgraph's stream wrapper
975
+ * turns that cancel into `_abortController.abort()`
976
+ * (`pregel/stream.js`). The in-flight model call or tool batch is
977
+ * torn down where it stands — it does not finish first.
978
+ *
979
+ * A halt is therefore the wrong tool for "stop generating but keep
980
+ * what you have". That is what `RunConfig.preemption` is for: it
981
+ * seals the stream at a provider-safe boundary and keeps the run.
930
982
  */
931
983
  const haltSignal = this.hookRegistry?.getHaltSignal(this.id);
932
984
  if (haltSignal != null) {
@@ -959,6 +1011,16 @@ export class Run<_T extends t.BaseGraphState> {
959
1011
  threadId,
960
1012
  agentId: graph.defaultAgentId,
961
1013
  messages: graph.getRunMessages() ?? stateInputs?.messages ?? [],
1014
+ /**
1015
+ * A seal whose boundary ended the turn early must say so. The
1016
+ * hook-supplied reason wins when a `PreemptBoundary` hook halted
1017
+ * with one — a persistence/audit `Stop` hook should record the
1018
+ * actual cause, not the generic label — and `preempt_incomplete`
1019
+ * is reserved for the boundary that simply had nothing to inject.
1020
+ */
1021
+ stopReason:
1022
+ graph.preemptHaltReason ??
1023
+ (graph.preemptIncomplete ? 'preempt_incomplete' : undefined),
962
1024
  stopHookActive: false, // will be true when stop is triggered by a hook (Phase 2)
963
1025
  },
964
1026
  sessionId: this.id,
@@ -966,6 +1028,29 @@ export class Run<_T extends t.BaseGraphState> {
966
1028
  /* Stop hook errors must not masquerade as stream failures */
967
1029
  });
968
1030
  }
1031
+
1032
+ /**
1033
+ * A `PreemptBoundary` hook that returned `preventContinuation` has its
1034
+ * registry halt cleared by the graph — that is what stops the halt from
1035
+ * cancelling the stream before the sealed turn commits — so the reason
1036
+ * is carried across on the graph instead. Surfaced here, AFTER the
1037
+ * `Stop` dispatch above, so the host still receives a completion signal
1038
+ * to persist the partial answer with while `getHaltReason()` correctly
1039
+ * reports that a hook stopped the run rather than the model finishing.
1040
+ *
1041
+ * An empty boundary — sealed, but nothing to inject because the host's
1042
+ * queue was drained or cancelled in the meantime — cut the answer short
1043
+ * just as surely, only without a hook-supplied reason. It surfaces
1044
+ * through the same channel under the same name the `Stop` dispatch
1045
+ * already used for its `stopReason`, so terminal consumers
1046
+ * (`AgentSession` emits `run.halted`, not `run.completed`) cannot
1047
+ * finalize a truncated answer as a natural finish.
1048
+ */
1049
+ if (this._haltedReason == null && graph.preemptHaltReason != null) {
1050
+ this._haltedReason = graph.preemptHaltReason;
1051
+ } else if (this._haltedReason == null && graph.preemptIncomplete) {
1052
+ this._haltedReason = 'preempt_incomplete';
1053
+ }
969
1054
  };
970
1055
 
971
1056
  try {
@@ -0,0 +1,330 @@
1
+ // src/scripts/preempt-probe.ts
2
+ //
3
+ // Live-provider probe for cooperative mid-generation preemption.
4
+ //
5
+ // npx tsx --env-file=.env src/scripts/preempt-probe.ts --provider anthropic
6
+ //
7
+ // (tsx rather than the repo's node-loader `script` runner: the import chain
8
+ // reaches @mistralai/mistralai, which ships ESM-only and defeats the loader.
9
+ // Keep live credentials OUT of the worktree's .env — jest loads it — and
10
+ // point --env-file at wherever they actually live.)
11
+ //
12
+ // Asks for a long, tool-free answer, arms `shouldPreempt` once text has
13
+ // started streaming, and asserts the full seal -> PreemptBoundary -> inject ->
14
+ // self-loop path against a real provider: the stream stops mid-answer, the
15
+ // injected user turn lands between two assistant turns, generation resumes in
16
+ // the same run, and CHAT_MODEL_END fires for the sealed turn so usage is still
17
+ // recorded. Prints a JSON verdict block as its last line.
18
+ import { config } from 'dotenv';
19
+ config();
20
+ import { v4 as uuidv4 } from 'uuid';
21
+ import { HumanMessage } from '@langchain/core/messages';
22
+ import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
23
+ import type * as t from '@/types';
24
+ import { GraphEvents, Providers } from '@/common';
25
+ import { createTokenCounter } from '@/utils/tokens';
26
+ import { getLLMConfig } from '@/utils/llmConfig';
27
+ import { HookRegistry } from '@/hooks';
28
+ import { Run } from '@/run';
29
+
30
+ /** Deltas to let through before arming the seal. */
31
+ const ARM_AFTER_DELTAS = 15;
32
+
33
+ const PROMPT =
34
+ 'Write a detailed history of the Byzantine Empire from Constantine to ' +
35
+ '1453. Cover the major emperors, the religious schisms, and the military ' +
36
+ 'campaigns. Aim for at least 800 words of flowing prose. Do not use ' +
37
+ 'headings or bullet points.';
38
+
39
+ const STEER =
40
+ 'Stop. Forget the essay. Answer only this, in one short sentence: what ' +
41
+ 'year did Constantinople fall?';
42
+
43
+ type Verdict = {
44
+ provider: string;
45
+ model: string;
46
+ ok: boolean;
47
+ sealed: boolean;
48
+ seals: number;
49
+ emptyBoundaries: number;
50
+ boundaryFired: boolean;
51
+ boundarySealCount: number | null;
52
+ modelEndEvents: number;
53
+ usageEventsWithTokens: number;
54
+ chatModelStarts: number;
55
+ llmEnds: number;
56
+ llmErrors: number;
57
+ runsLeftOpen: number;
58
+ sealedTextChars: number;
59
+ resumedTextChars: number;
60
+ sequence: string[];
61
+ injectedFound: boolean;
62
+ resumeAnswersSteer: boolean;
63
+ error: string | null;
64
+ };
65
+
66
+ /**
67
+ * Streamed assistant turns arrive as `AIMessageChunk`, which extends
68
+ * `BaseMessageChunk` rather than `AIMessage` — `instanceof AIMessage` is
69
+ * false for them. Type discrimination has to go through `getType()`.
70
+ */
71
+ function isAssistant(message: BaseMessage | undefined): boolean {
72
+ return message?.getType() === 'ai';
73
+ }
74
+
75
+ function messageKind(message: BaseMessage): string {
76
+ if (message.getType() === 'human') {
77
+ const source = message.additional_kwargs.source;
78
+ return typeof source === 'string' ? `human(${source})` : 'human';
79
+ }
80
+ return message.getType();
81
+ }
82
+
83
+ function textOf(message: BaseMessage | undefined): string {
84
+ if (message == null) {
85
+ return '';
86
+ }
87
+ if (typeof message.content === 'string') {
88
+ return message.content;
89
+ }
90
+ let text = '';
91
+ for (const block of message.content) {
92
+ if (block.type === 'text' && typeof block.text === 'string') {
93
+ text += block.text;
94
+ }
95
+ }
96
+ return text;
97
+ }
98
+
99
+ function parseProvider(): string {
100
+ const index = process.argv.indexOf('--provider');
101
+ if (index !== -1 && process.argv[index + 1] != null) {
102
+ return process.argv[index + 1];
103
+ }
104
+ return Providers.ANTHROPIC;
105
+ }
106
+
107
+ /** Control mode: identical run with no `preemption` config at all. */
108
+ function isControl(): boolean {
109
+ return process.argv.includes('--control');
110
+ }
111
+
112
+ async function probe(): Promise<Verdict> {
113
+ const providerKey = parseProvider();
114
+ const llmConfig = getLLMConfig(providerKey);
115
+ if (llmConfig == null) {
116
+ throw new Error(`No llmConfig entry for provider "${providerKey}"`);
117
+ }
118
+
119
+ let armed = false;
120
+ let armedOnce = false;
121
+ let deltaCount = 0;
122
+ let modelEndEvents = 0;
123
+ let usageEventsWithTokens = 0;
124
+ let chatModelStarts = 0;
125
+ let llmEnds = 0;
126
+ let llmErrors = 0;
127
+ let boundaryFired = false;
128
+ let boundarySealCount: number | null = null;
129
+ let injectedOnce = false;
130
+ const collectedUsage: UsageMetadata[] = [];
131
+
132
+ /**
133
+ * Deliberately NOT a `ChatModelStreamHandler` instance: that would make
134
+ * `getRegisteredDefaultChatStreamHandler` return a handler and route the run
135
+ * down the registered-handler loop, which never seals. Plain-object handlers
136
+ * mirror how LibreChat registers, which is the sealable path.
137
+ */
138
+ const customHandlers: Record<string, t.EventHandler> = {
139
+ [GraphEvents.ON_MESSAGE_DELTA]: {
140
+ handle: (): void => {
141
+ deltaCount += 1;
142
+ if (!armedOnce && deltaCount >= ARM_AFTER_DELTAS) {
143
+ armed = true;
144
+ armedOnce = true;
145
+ }
146
+ },
147
+ },
148
+ [GraphEvents.CHAT_MODEL_END]: {
149
+ handle: (_event: string, data: t.ModelEndData): void => {
150
+ modelEndEvents += 1;
151
+ const usage = data?.output?.usage_metadata;
152
+ if (usage != null) {
153
+ collectedUsage.push(usage);
154
+ if ((usage.output_tokens ?? 0) > 0) {
155
+ usageEventsWithTokens += 1;
156
+ }
157
+ }
158
+ },
159
+ },
160
+ };
161
+
162
+ const hooks = new HookRegistry();
163
+ hooks.register('PreemptBoundary', {
164
+ hooks: [
165
+ async (input) => {
166
+ boundaryFired = true;
167
+ boundarySealCount = input.sealCount;
168
+ /**
169
+ * Disarm on drain. The SDK polls `shouldPreempt` on every chunk and
170
+ * does not clear it for you — a host that leaves the request set
171
+ * armed re-seals the resumed turn immediately. LibreChat's real drain
172
+ * does this via `noteSteersRemoved`.
173
+ */
174
+ armed = false;
175
+ if (injectedOnce) {
176
+ return {};
177
+ }
178
+ injectedOnce = true;
179
+ return {
180
+ injectedMessages: [
181
+ { role: 'user', content: STEER, source: 'steer' },
182
+ ],
183
+ };
184
+ },
185
+ ],
186
+ });
187
+
188
+ /**
189
+ * A real host configures this. It is what `dispatchSealedModelEnd` falls
190
+ * back to when the provider never got to send its usage chunk, so running
191
+ * without one hides whether sealed-turn usage is recoverable. Pass
192
+ * `--no-token-counter` to probe the unconfigured case deliberately.
193
+ */
194
+ const tokenCounter = process.argv.includes('--no-token-counter')
195
+ ? undefined
196
+ : await createTokenCounter();
197
+
198
+ const run = await Run.create<t.IState>({
199
+ runId: uuidv4(),
200
+ graphConfig: {
201
+ type: 'standard',
202
+ llmConfig,
203
+ instructions: 'You are a knowledgeable history assistant.',
204
+ },
205
+ tokenCounter,
206
+ customHandlers,
207
+ hooks,
208
+ ...(isControl()
209
+ ? {}
210
+ : {
211
+ preemption: {
212
+ shouldPreempt: (): boolean => armed,
213
+ maxSeals: 2,
214
+ },
215
+ }),
216
+ returnContent: true,
217
+ skipCleanup: true,
218
+ });
219
+
220
+ /**
221
+ * Counts the RAW LangChain model-run lifecycle, which is what a tracer
222
+ * (LangSmith, Langfuse) sees. A seal that fails to close its run shows up
223
+ * here as starts > ends — an span that would hang open forever.
224
+ */
225
+ const streamConfig = {
226
+ runId: uuidv4(),
227
+ configurable: { user_id: 'probe-user', thread_id: 'preempt-probe' },
228
+ streamMode: 'values',
229
+ version: 'v2' as const,
230
+ callbacks: [
231
+ {
232
+ handleChatModelStart: (): void => {
233
+ chatModelStarts += 1;
234
+ },
235
+ handleLLMEnd: (): void => {
236
+ llmEnds += 1;
237
+ },
238
+ handleLLMError: (): void => {
239
+ llmErrors += 1;
240
+ },
241
+ },
242
+ ],
243
+ };
244
+
245
+ await run.processStream(
246
+ { messages: [new HumanMessage(PROMPT)] },
247
+ streamConfig
248
+ );
249
+
250
+ const messages = run.getRunMessages() ?? [];
251
+ const sequence = messages.map(messageKind);
252
+ const injectedIndex = messages.findIndex(
253
+ (message) =>
254
+ message instanceof HumanMessage &&
255
+ message.additional_kwargs.source === 'steer'
256
+ );
257
+ const stats = run.getPreemptStats();
258
+ const sealedText = textOf(messages[injectedIndex - 1]);
259
+ const resumedText = textOf(messages[injectedIndex + 1]);
260
+ const resumeAnswersSteer = /1453/.test(resumedText);
261
+
262
+ /**
263
+ * Control mode is the no-preemption baseline: one uninterrupted model run,
264
+ * nothing sealed, nothing injected. Its value is the contrast — the same
265
+ * lifecycle counters must balance WITHOUT the seal machinery — so it gets
266
+ * its own criteria instead of failing the seal-path ones by construction.
267
+ */
268
+ const ok = isControl()
269
+ ? stats.seals === 0 &&
270
+ !boundaryFired &&
271
+ injectedIndex === -1 &&
272
+ messages.length > 0 &&
273
+ textOf(messages[messages.length - 1]).trim().length > 0 &&
274
+ modelEndEvents >= 1 &&
275
+ usageEventsWithTokens >= 1 &&
276
+ llmErrors === 0 &&
277
+ chatModelStarts === llmEnds
278
+ : stats.seals === 1 &&
279
+ stats.emptyBoundaries === 0 &&
280
+ boundaryFired &&
281
+ injectedIndex > 0 &&
282
+ isAssistant(messages[injectedIndex - 1]) &&
283
+ isAssistant(messages[injectedIndex + 1]) &&
284
+ sealedText.trim().length > 0 &&
285
+ resumedText.trim().length > 0 &&
286
+ modelEndEvents >= 2 &&
287
+ usageEventsWithTokens >= 2 &&
288
+ chatModelStarts === llmEnds;
289
+
290
+ return {
291
+ provider: providerKey,
292
+ model: String((llmConfig as { model?: unknown }).model ?? 'unknown'),
293
+ ok,
294
+ sealed: stats.seals > 0,
295
+ seals: stats.seals,
296
+ emptyBoundaries: stats.emptyBoundaries,
297
+ boundaryFired,
298
+ boundarySealCount,
299
+ modelEndEvents,
300
+ usageEventsWithTokens,
301
+ chatModelStarts,
302
+ llmEnds,
303
+ llmErrors,
304
+ runsLeftOpen: chatModelStarts - llmEnds,
305
+ sealedTextChars: sealedText.length,
306
+ resumedTextChars: resumedText.length,
307
+ sequence,
308
+ injectedFound: injectedIndex >= 0,
309
+ resumeAnswersSteer,
310
+ error: null,
311
+ };
312
+ }
313
+
314
+ probe()
315
+ .then((verdict) => {
316
+ console.log('\n===== SEALED TAIL / RESUMED HEAD =====');
317
+ console.log(JSON.stringify(verdict, null, 2));
318
+ console.log(`\nVERDICT_JSON ${JSON.stringify(verdict)}`);
319
+ process.exit(verdict.ok ? 0 : 1);
320
+ })
321
+ .catch((error: unknown) => {
322
+ const verdict: Partial<Verdict> = {
323
+ provider: parseProvider(),
324
+ ok: false,
325
+ error: error instanceof Error ? error.message : String(error),
326
+ };
327
+ console.error(error);
328
+ console.log(`\nVERDICT_JSON ${JSON.stringify(verdict)}`);
329
+ process.exit(1);
330
+ });