@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
@@ -1059,13 +1059,24 @@ describe('context overflow recovery', () => {
1059
1059
  const humanMessages = model.calls[0].filter(
1060
1060
  (message) => message instanceof HumanMessage
1061
1061
  );
1062
- expect(humanMessages).toHaveLength(2);
1063
- expect(
1064
- JSON.stringify(humanMessages[humanMessages.length - 1].content).length
1065
- ).toBeLessThan(100);
1066
- expect(
1067
- JSON.stringify(humanMessages[humanMessages.length - 1].content)
1068
- ).not.toContain('x'.repeat(1_000));
1062
+ /**
1063
+ * Bedrock is a strict-alternation provider: after compaction shrinks the
1064
+ * synthetic context, `coalesceAdjacentUserTurns` merges it into the
1065
+ * adjacent user query — Converse rejects consecutive user turns, and the
1066
+ * `@langchain/aws` converter only merges toolResult-bearing ones. So one
1067
+ * user turn reaches the provider, carrying both the query and the
1068
+ * compacted note, and it must still be small.
1069
+ */
1070
+ expect(humanMessages).toHaveLength(1);
1071
+ const coalescedContent = JSON.stringify(humanMessages[0].content);
1072
+ expect(coalescedContent).toContain('query the table');
1073
+ /**
1074
+ * No closing bracket: compaction truncates at a character budget, and the
1075
+ * cut can land inside the `[Previous tool interaction]` header itself.
1076
+ */
1077
+ expect(coalescedContent).toContain('[Previous tool interaction');
1078
+ expect(coalescedContent.length).toBeLessThan(150);
1079
+ expect(coalescedContent).not.toContain('x'.repeat(1_000));
1069
1080
  });
1070
1081
 
1071
1082
  it('counts unresolved-reference annotations before invoking the provider', async () => {
@@ -0,0 +1,126 @@
1
+ // src/graphs/__tests__/Graph.preemptSignal.test.ts
2
+ import type * as t from '@/types';
3
+ import { Providers } from '@/common';
4
+ import { HookRegistry } from '@/hooks/HookRegistry';
5
+ import { StandardGraph } from '../Graph';
6
+
7
+ const makeAgent = (agentId: string): t.AgentInputs => ({
8
+ agentId,
9
+ provider: Providers.OPENAI,
10
+ instructions: `You are ${agentId}.`,
11
+ });
12
+
13
+ type DispatchResult = { messages: unknown[]; preventContinuation: boolean };
14
+
15
+ const dispatchBoundary = (
16
+ graph: StandardGraph,
17
+ agentId = 'agent'
18
+ ): Promise<DispatchResult> =>
19
+ (
20
+ graph as unknown as {
21
+ dispatchPreemptBoundary: (
22
+ agentId: string,
23
+ config: { configurable: { run_id: string } } | undefined
24
+ ) => Promise<DispatchResult>;
25
+ }
26
+ ).dispatchPreemptBoundary(agentId, {
27
+ configurable: { run_id: 'run_1' },
28
+ });
29
+
30
+ /**
31
+ * Registers a PreemptBoundary hook that aborts `controller` from inside its
32
+ * own body, then reports whether the signal `executeHooks` handed it observed
33
+ * that abort. Racing against a short timer keeps a broken composition from
34
+ * stalling the suite for the 120-second boundary hook timeout.
35
+ */
36
+ function registerAbortProbe(
37
+ registry: HookRegistry,
38
+ controller: AbortController
39
+ ): { outcome: () => string | undefined } {
40
+ let outcome: string | undefined;
41
+ registry.register('PreemptBoundary', {
42
+ hooks: [
43
+ async (_input, signal): Promise<Record<string, never>> => {
44
+ const aborted = new Promise<string>((resolve) => {
45
+ if (signal.aborted) {
46
+ resolve('aborted');
47
+ return;
48
+ }
49
+ signal.addEventListener('abort', () => resolve('aborted'), {
50
+ once: true,
51
+ });
52
+ });
53
+ const timer = new Promise<string>((resolve) =>
54
+ setTimeout(() => resolve('not-observed'), 200)
55
+ );
56
+ controller.abort();
57
+ outcome = await Promise.race([aborted, timer]);
58
+ return {};
59
+ },
60
+ ],
61
+ });
62
+ return { outcome: () => outcome };
63
+ }
64
+
65
+ describe('PreemptBoundary abort-signal composition', () => {
66
+ it('observes a per-call caller abort even when a construction signal exists', async () => {
67
+ const constructionController = new AbortController();
68
+ const callerController = new AbortController();
69
+ const graph = new StandardGraph({
70
+ runId: 'run_1',
71
+ signal: constructionController.signal,
72
+ agents: [makeAgent('agent')],
73
+ });
74
+ const registry = new HookRegistry();
75
+ const probe = registerAbortProbe(registry, callerController);
76
+ graph.hookRegistry = registry;
77
+ graph.callerSignal = callerController.signal;
78
+
79
+ await dispatchBoundary(graph);
80
+
81
+ expect(probe.outcome()).toBe('aborted');
82
+ expect(constructionController.signal.aborted).toBe(false);
83
+ });
84
+
85
+ it('observes the caller abort when no construction signal exists (multi-agent shape)', async () => {
86
+ const callerController = new AbortController();
87
+ const graph = new StandardGraph({
88
+ runId: 'run_1',
89
+ agents: [makeAgent('agent')],
90
+ });
91
+ const registry = new HookRegistry();
92
+ const probe = registerAbortProbe(registry, callerController);
93
+ graph.hookRegistry = registry;
94
+ graph.callerSignal = callerController.signal;
95
+
96
+ await dispatchBoundary(graph);
97
+
98
+ expect(probe.outcome()).toBe('aborted');
99
+ });
100
+
101
+ it('still observes the construction signal when no caller signal was supplied', async () => {
102
+ const constructionController = new AbortController();
103
+ const graph = new StandardGraph({
104
+ runId: 'run_1',
105
+ signal: constructionController.signal,
106
+ agents: [makeAgent('agent')],
107
+ });
108
+ const registry = new HookRegistry();
109
+ const probe = registerAbortProbe(registry, constructionController);
110
+ graph.hookRegistry = registry;
111
+
112
+ await dispatchBoundary(graph);
113
+
114
+ expect(probe.outcome()).toBe('aborted');
115
+ });
116
+
117
+ it('drops the caller signal reference on clearHeavyState', () => {
118
+ const graph = new StandardGraph({
119
+ runId: 'run_1',
120
+ agents: [makeAgent('agent')],
121
+ });
122
+ graph.callerSignal = new AbortController().signal;
123
+ graph.clearHeavyState();
124
+ expect(graph.callerSignal).toBeUndefined();
125
+ });
126
+ });
@@ -247,6 +247,34 @@ export class HookRegistry {
247
247
  return readList(bucket, event).length > 0;
248
248
  }
249
249
 
250
+ /**
251
+ * True when at least one matcher for `event` would actually DISPATCH on a
252
+ * query-less call — a wildcard pattern with a non-empty `hooks` array.
253
+ *
254
+ * `hasHookFor` answers "is one registered", which is not the same question:
255
+ * a matcher carrying a pattern is inert for events that supply no
256
+ * `matchQuery`, so a caller using registration as a proxy for "something
257
+ * will run" can act on a hook that never fires. Mirrors the two skips in
258
+ * `executeHooks` (pattern mismatch, empty `hooks`).
259
+ *
260
+ * Non-allocating on purpose — `StandardGraph.canClaimPreemptSeal` reads it
261
+ * once per streamed chunk, where `getMatchers`' defensive `slice()` would
262
+ * allocate on every delta.
263
+ */
264
+ hasDispatchableHookFor(event: HookEvent, sessionId?: string): boolean {
265
+ if (hasDispatchableInList(readList(this.global, event))) {
266
+ return true;
267
+ }
268
+ if (sessionId === undefined) {
269
+ return false;
270
+ }
271
+ const bucket = this.sessions.get(sessionId);
272
+ if (bucket === undefined) {
273
+ return false;
274
+ }
275
+ return hasDispatchableInList(readList(bucket, event));
276
+ }
277
+
250
278
  private ensureSessionBucket(sessionId: string): MatcherBucket {
251
279
  const existing = this.sessions.get(sessionId);
252
280
  if (existing !== undefined) {
@@ -278,6 +306,18 @@ function readList(
278
306
  return bucket[event] ?? [];
279
307
  }
280
308
 
309
+ function hasDispatchableInList(list: HookMatcher<HookEvent>[]): boolean {
310
+ for (const matcher of list) {
311
+ if (
312
+ (matcher.pattern === undefined || matcher.pattern === '') &&
313
+ matcher.hooks.length > 0
314
+ ) {
315
+ return true;
316
+ }
317
+ }
318
+ return false;
319
+ }
320
+
281
321
  function hasResultAlteringInBucket(bucket: MatcherBucket): boolean {
282
322
  for (const event of RESULT_ALTERING_HOOK_EVENTS) {
283
323
  if (readList(bucket, event).length > 0) {
@@ -0,0 +1,152 @@
1
+ // src/hooks/__tests__/preemptBoundary.test.ts
2
+ import type { HookMatcher, HookCallback, HookOutput } from '../types';
3
+ import { HOOK_EVENTS } from '../types';
4
+ import { HookRegistry } from '../HookRegistry';
5
+ import { executeHooks } from '../executeHooks';
6
+ import {
7
+ HOOK_PREEMPT_BOUNDARY_CAPABLE,
8
+ HOOK_INJECTED_MESSAGES_CAPABLE,
9
+ } from '../index';
10
+
11
+ const noop: HookCallback<
12
+ 'PreemptBoundary'
13
+ > = async (): Promise<HookOutput> => ({});
14
+
15
+ function makeMatcher(
16
+ hook: HookCallback<'PreemptBoundary'> = noop
17
+ ): HookMatcher<'PreemptBoundary'> {
18
+ return { hooks: [hook] };
19
+ }
20
+
21
+ describe('PreemptBoundary hook event', () => {
22
+ it('is part of the closed event set', () => {
23
+ expect(HOOK_EVENTS).toContain('PreemptBoundary');
24
+ });
25
+
26
+ it('advertises its own capability flag, separate from injectedMessages', () => {
27
+ expect(HOOK_PREEMPT_BOUNDARY_CAPABLE).toBe(true);
28
+ expect(HOOK_INJECTED_MESSAGES_CAPABLE).toBe(true);
29
+ });
30
+
31
+ /**
32
+ * Load-bearing: `hasResultAlteringHooks` gates eager tool execution. If
33
+ * `PreemptBoundary` ever counted as result-altering, registering a steering
34
+ * drain would silently disable eager dispatch for every steering run.
35
+ */
36
+ it('is not result-altering, so eager tool execution stays enabled', () => {
37
+ const registry = new HookRegistry();
38
+ registry.register('PreemptBoundary', makeMatcher());
39
+ expect(registry.hasResultAlteringHooks()).toBe(false);
40
+ });
41
+
42
+ it('still reports result-altering when a tool hook is also registered', () => {
43
+ const registry = new HookRegistry();
44
+ registry.register('PreemptBoundary', makeMatcher());
45
+ registry.register('PreToolUse', { hooks: [async () => ({})] });
46
+ expect(registry.hasResultAlteringHooks()).toBe(true);
47
+ });
48
+
49
+ /**
50
+ * `hasHookFor` answers "is one registered"; the seal gate needs "will one
51
+ * fire". A pattern-scoped matcher is inert for a query-less dispatch, so
52
+ * treating registration as a proxy would seal into a boundary that injects
53
+ * nothing and cut the answer short.
54
+ */
55
+ describe('hasDispatchableHookFor', () => {
56
+ it('is true for a wildcard matcher with a callback', () => {
57
+ const registry = new HookRegistry();
58
+ registry.register('PreemptBoundary', makeMatcher());
59
+ expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(true);
60
+ });
61
+
62
+ it('is false for a pattern-scoped matcher that can never match', () => {
63
+ const registry = new HookRegistry();
64
+ registry.register('PreemptBoundary', { pattern: 'Bash', hooks: [noop] });
65
+ expect(registry.hasHookFor('PreemptBoundary')).toBe(true);
66
+ expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
67
+ });
68
+
69
+ it('is false for a matcher carrying no callbacks', () => {
70
+ const registry = new HookRegistry();
71
+ registry.register('PreemptBoundary', { hooks: [] });
72
+ expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
73
+ });
74
+
75
+ it('is false when nothing is registered at all', () => {
76
+ const registry = new HookRegistry();
77
+ expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
78
+ });
79
+
80
+ it('finds a session-scoped dispatchable matcher', () => {
81
+ const registry = new HookRegistry();
82
+ registry.registerSession('run_1', 'PreemptBoundary', makeMatcher());
83
+ expect(registry.hasDispatchableHookFor('PreemptBoundary')).toBe(false);
84
+ expect(registry.hasDispatchableHookFor('PreemptBoundary', 'run_1')).toBe(
85
+ true
86
+ );
87
+ });
88
+ });
89
+
90
+ it('keeps registration isolated from the tool boundary event', () => {
91
+ const registry = new HookRegistry();
92
+ const matcher = makeMatcher();
93
+ registry.register('PreemptBoundary', matcher);
94
+ expect(registry.getMatchers('PreemptBoundary')).toEqual([matcher]);
95
+ expect(registry.getMatchers('PostToolBatch')).toEqual([]);
96
+ });
97
+
98
+ it('aggregates injectedMessages from the boundary dispatch', async () => {
99
+ const registry = new HookRegistry();
100
+ registry.register(
101
+ 'PreemptBoundary',
102
+ makeMatcher(async () => ({
103
+ injectedMessages: [
104
+ { role: 'user', content: 'Skip phase two.', source: 'steer' },
105
+ ],
106
+ }))
107
+ );
108
+
109
+ const result = await executeHooks({
110
+ registry,
111
+ input: {
112
+ hook_event_name: 'PreemptBoundary',
113
+ runId: 'run_1',
114
+ executingAgentId: 'agent_1',
115
+ sealCount: 1,
116
+ },
117
+ sessionId: 'run_1',
118
+ });
119
+
120
+ expect(result.injectedMessages).toEqual([
121
+ { role: 'user', content: 'Skip phase two.', source: 'steer' },
122
+ ]);
123
+ expect(result.errors).toEqual([]);
124
+ });
125
+
126
+ it('reports the 1-based seal index to the hook', async () => {
127
+ const registry = new HookRegistry();
128
+ const seen: number[] = [];
129
+ registry.register(
130
+ 'PreemptBoundary',
131
+ makeMatcher(async (input) => {
132
+ seen.push(input.sealCount);
133
+ return {};
134
+ })
135
+ );
136
+
137
+ for (const sealCount of [1, 2]) {
138
+ await executeHooks({
139
+ registry,
140
+ input: {
141
+ hook_event_name: 'PreemptBoundary',
142
+ runId: 'run_1',
143
+ executingAgentId: 'agent_1',
144
+ sealCount,
145
+ },
146
+ sessionId: 'run_1',
147
+ });
148
+ }
149
+
150
+ expect(seen).toEqual([1, 2]);
151
+ });
152
+ });
@@ -4,8 +4,9 @@
4
4
  // `src/index.ts` and consumed by `Run.processStream` (RunStart,
5
5
  // UserPromptSubmit, Stop, StopFailure), `ToolNode.dispatchToolEvents`
6
6
  // (PreToolUse, PostToolUse, PostToolUseFailure, PermissionDenied),
7
- // `createSummarizeNode` (PreCompact, PostCompact), and
8
- // `SubagentExecutor.execute` (SubagentStart, SubagentStop).
7
+ // `createSummarizeNode` (PreCompact, PostCompact),
8
+ // `SubagentExecutor.execute` (SubagentStart, SubagentStop), and
9
+ // `StandardGraph.createCallModel` (PreemptBoundary).
9
10
  export { HookRegistry } from './HookRegistry';
10
11
  export type { HookHaltSignal } from './HookRegistry';
11
12
  export { executeHooks, DEFAULT_HOOK_TIMEOUT_MS } from './executeHooks';
@@ -16,6 +17,17 @@ export { executeHooks, DEFAULT_HOOK_TIMEOUT_MS } from './executeHooks';
16
17
  * be consumed by an SDK version that would silently drop it.
17
18
  */
18
19
  export const HOOK_INJECTED_MESSAGES_CAPABLE = true;
20
+ /**
21
+ * Feature probe for hosts: this SDK dispatches `PreemptBoundary`, so a
22
+ * cooperative mid-generation seal can drain into the run.
23
+ *
24
+ * Deliberately separate from {@link HOOK_INJECTED_MESSAGES_CAPABLE} — an SDK
25
+ * version can support `injectedMessages` at the tool boundary and know
26
+ * nothing about preemption. A host that probed the wrong flag would arm an
27
+ * interrupt control whose seal request is silently ignored, which reads to
28
+ * the user as a dead button rather than as an unsupported feature.
29
+ */
30
+ export const HOOK_PREEMPT_BOUNDARY_CAPABLE = true;
19
31
  export {
20
32
  matchesQuery,
21
33
  hasNestedQuantifier,
@@ -52,6 +64,7 @@ export type {
52
64
  PostToolUseFailureHookInput,
53
65
  PostToolBatchHookInput,
54
66
  PostToolBatchEntry,
67
+ PreemptBoundaryHookInput,
55
68
  PermissionDeniedHookInput,
56
69
  SubagentStartHookInput,
57
70
  SubagentStopHookInput,
@@ -65,6 +78,7 @@ export type {
65
78
  PostToolUseHookOutput,
66
79
  PostToolUseFailureHookOutput,
67
80
  PostToolBatchHookOutput,
81
+ PreemptBoundaryHookOutput,
68
82
  PermissionDeniedHookOutput,
69
83
  SubagentStartHookOutput,
70
84
  SubagentStopHookOutput,
@@ -17,6 +17,7 @@ export const HOOK_EVENTS = [
17
17
  'PostToolUse',
18
18
  'PostToolUseFailure',
19
19
  'PostToolBatch',
20
+ 'PreemptBoundary',
20
21
  'PermissionDenied',
21
22
  'SubagentStart',
22
23
  'SubagentStop',
@@ -145,6 +146,39 @@ export interface PostToolBatchHookInput extends BaseHookInput {
145
146
  entries: PostToolBatchEntry[];
146
147
  }
147
148
 
149
+ /**
150
+ * Fires when a cooperative preemption seals the model stream mid-generation
151
+ * — the second injection boundary, and the only one that exists during a
152
+ * long text answer with no tool calls in it.
153
+ *
154
+ * Order: fires after the model stream is sealed and BEFORE the next model
155
+ * call. The `injectedMessages` a hook returns here are appended verbatim and
156
+ * the agent node self-loops. Returning nothing is a valid outcome (the host's
157
+ * queue was cancelled or already drained): the run stops honestly rather than
158
+ * self-looping into an empty turn.
159
+ *
160
+ * The sealed turn is NOT yet observable from graph state when this fires.
161
+ * Dispatch happens inside the agent node, and only the outer graph's reducer
162
+ * writes `StandardGraph.messages` — which cannot run until the node returns.
163
+ * A hook calling `Run.getRunMessages()` here sees the state as of the last
164
+ * completed superstep, so the sealed text is absent. `PostToolBatch` behaves
165
+ * the same way: a tool-boundary hook cannot see the assistant turn that
166
+ * requested the tool. This is a property of the single-node outer graph, not
167
+ * of preemption, and committing first would mean returning from the node and
168
+ * re-entering — precisely what the self-loop exists to avoid.
169
+ *
170
+ * A hook that needs the sealed text should therefore not go looking for it in
171
+ * graph state. Decide from the host's own queue, or read it after the run.
172
+ *
173
+ * Requires `RunConfig.preemption`. This event is deliberately NOT
174
+ * result-altering, so registering it never disables eager tool execution.
175
+ */
176
+ export interface PreemptBoundaryHookInput extends BaseHookInput {
177
+ hook_event_name: 'PreemptBoundary';
178
+ /** 1-based index of this seal within the run. */
179
+ sealCount: number;
180
+ }
181
+
148
182
  export interface PermissionDeniedHookInput extends BaseHookInput {
149
183
  hook_event_name: 'PermissionDenied';
150
184
  toolName: string;
@@ -217,6 +251,7 @@ export type HookInput =
217
251
  | PostToolUseHookInput
218
252
  | PostToolUseFailureHookInput
219
253
  | PostToolBatchHookInput
254
+ | PreemptBoundaryHookInput
220
255
  | PermissionDeniedHookInput
221
256
  | SubagentStartHookInput
222
257
  | SubagentStopHookInput
@@ -233,6 +268,7 @@ export type HookInputByEvent = {
233
268
  PostToolUse: PostToolUseHookInput;
234
269
  PostToolUseFailure: PostToolUseFailureHookInput;
235
270
  PostToolBatch: PostToolBatchHookInput;
271
+ PreemptBoundary: PreemptBoundaryHookInput;
236
272
  PermissionDenied: PermissionDeniedHookInput;
237
273
  SubagentStart: SubagentStartHookInput;
238
274
  SubagentStop: SubagentStopHookInput;
@@ -251,14 +287,18 @@ export interface BaseHookOutput {
251
287
  additionalContext?: string;
252
288
  /**
253
289
  * Messages to inject into graph state, one `HumanMessage` per entry
254
- * (converted via `ToolNode.convertInjectedMessages`, which preserves
290
+ * (converted via `convertInjectedMessages`, which preserves
255
291
  * `role`/`source`/`isMeta` in `additional_kwargs`). Unlike
256
292
  * `additionalContext` — which is consolidated across hooks into a single
257
293
  * system-flavored message — each entry keeps its own identity and role,
258
294
  * making this the channel for injecting verbatim user speech (e.g. a
259
295
  * mid-run steering message). Accumulated across hooks in registration
260
- * order. Currently consumed only at the `PostToolBatch` dispatch site;
261
- * other events ignore the field.
296
+ * order.
297
+ *
298
+ * Consumed at exactly two dispatch sites, both of which run the same
299
+ * converter so the emitted shapes cannot drift: `PostToolBatch` (the tool
300
+ * boundary) and `PreemptBoundary` (a cooperative mid-generation seal).
301
+ * Every other event ignores the field.
262
302
  */
263
303
  injectedMessages?: InjectedMessage[];
264
304
  /** True to prevent the next model turn. Any hook can set this. */
@@ -367,6 +407,8 @@ export type PostToolUseFailureHookOutput = BaseHookOutput;
367
407
 
368
408
  export type PostToolBatchHookOutput = BaseHookOutput;
369
409
 
410
+ export type PreemptBoundaryHookOutput = BaseHookOutput;
411
+
370
412
  export type PermissionDeniedHookOutput = BaseHookOutput;
371
413
 
372
414
  export interface SubagentStartHookOutput extends BaseHookOutput {
@@ -395,6 +437,7 @@ export type HookOutputByEvent = {
395
437
  PostToolUse: PostToolUseHookOutput;
396
438
  PostToolUseFailure: PostToolUseFailureHookOutput;
397
439
  PostToolBatch: PostToolBatchHookOutput;
440
+ PreemptBoundary: PreemptBoundaryHookOutput;
398
441
  PermissionDenied: PermissionDeniedHookOutput;
399
442
  SubagentStart: SubagentStartHookOutput;
400
443
  SubagentStop: SubagentStopHookOutput;
@@ -412,6 +455,7 @@ export type HookOutput =
412
455
  | PostToolUseHookOutput
413
456
  | PostToolUseFailureHookOutput
414
457
  | PostToolBatchHookOutput
458
+ | PreemptBoundaryHookOutput
415
459
  | PermissionDeniedHookOutput
416
460
  | SubagentStartHookOutput
417
461
  | SubagentStopHookOutput
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /* Main Operations */
2
2
  export * from './run';
3
3
  export * from './stream';
4
- export * from './splitStream';
5
4
  export * from './events';
6
5
  export * from './messages';
7
6
 
@@ -80,4 +79,5 @@ export { getChatModelClass } from './llm/providers';
80
79
  export { FakeChatModel, createFakeStreamingLLM } from './llm/fake';
81
80
  export { initializeModel } from './llm/init';
82
81
  export { attemptInvoke, tryFallbackProviders } from './llm/invoke';
82
+ export { canSealPreempt } from './llm/preempt';
83
83
  export { isThinkingEnabled, getMaxOutputTokensKey } from './llm/request';
package/src/langfuse.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CallbackHandler } from '@langfuse/langchain';
2
- import { isParentCommand } from '@langchain/langgraph';
3
2
  import { context as otelContext } from '@opentelemetry/api';
4
3
  import { AIMessage, AIMessageChunk } from '@langchain/core/messages';
4
+ import { isGraphInterrupt, isParentCommand } from '@langchain/langgraph';
5
5
  import {
6
6
  getLangfuseTracerProvider,
7
7
  propagateAttributes,
@@ -27,6 +27,10 @@ import { isPresent, parseBooleanEnv } from '@/utils/misc';
27
27
 
28
28
  const TRACE_METADATA_MAX_LENGTH = 200;
29
29
  const LANGFUSE_FORCE_FLUSH_ON_DISPOSE = 'LANGFUSE_FORCE_FLUSH_ON_DISPOSE';
30
+ const GRAPH_INTERRUPT_CONTROL_FLOW = { controlFlow: 'GraphInterrupt' } as const;
31
+ const GRAPH_INTERRUPT_TOOL_OUTPUT = JSON.stringify(
32
+ GRAPH_INTERRUPT_CONTROL_FLOW
33
+ );
30
34
 
31
35
  export type LangfuseTraceMetadata = Record<string, string>;
32
36
  export type LangfuseTraceAttributes = Record<string, string | number | boolean>;
@@ -209,6 +213,13 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
209
213
  ...args: Parameters<CallbackHandler['handleChainError']>
210
214
  ): ReturnType<CallbackHandler['handleChainError']> {
211
215
  const [error, runId, parentRunId] = args;
216
+ if (error != null && parentRunId != null && isGraphInterrupt(error)) {
217
+ return super.handleChainEnd(
218
+ GRAPH_INTERRUPT_CONTROL_FLOW,
219
+ runId,
220
+ parentRunId
221
+ );
222
+ }
212
223
  if (error != null && parentRunId != null && isParentCommand(error)) {
213
224
  return super.handleChainEnd(
214
225
  { controlFlow: 'ParentCommand' },
@@ -261,6 +272,20 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
261
272
  return this.withRuntimeContext(() => super.handleToolStart(...args));
262
273
  }
263
274
 
275
+ override handleToolError(
276
+ ...args: Parameters<CallbackHandler['handleToolError']>
277
+ ): ReturnType<CallbackHandler['handleToolError']> {
278
+ const [error, runId, parentRunId] = args;
279
+ if (error != null && parentRunId != null && isGraphInterrupt(error)) {
280
+ return super.handleToolEnd(
281
+ GRAPH_INTERRUPT_TOOL_OUTPUT,
282
+ runId,
283
+ parentRunId
284
+ );
285
+ }
286
+ return super.handleToolError(...args);
287
+ }
288
+
264
289
  override handleRetrieverStart(
265
290
  ...args: Parameters<CallbackHandler['handleRetrieverStart']>
266
291
  ): ReturnType<CallbackHandler['handleRetrieverStart']> {
@@ -450,3 +450,85 @@ describe('convertToConverseMessages — v1 reasoning serialization', () => {
450
450
  expect(content.some((b) => b.text === 'answer')).toBe(true);
451
451
  });
452
452
  });
453
+
454
+ /**
455
+ * Converse rejects consecutive same-role messages categorically, and both
456
+ * `ToolMessage` and `HumanMessage` convert to `role: 'user'` — so a hook
457
+ * injection landing a text turn directly after tool results (`PostToolBatch`
458
+ * / `PreemptBoundary` drains) must merge into one user message here, with
459
+ * block order preserved so the toolUse/toolResult pairing stays intact.
460
+ */
461
+ describe('convertToConverseMessages — user-role run merging', () => {
462
+ it('merges a tool result followed by an injected text turn into one user message', () => {
463
+ const messages: BaseMessage[] = [
464
+ new HumanMessage('run the search'),
465
+ new AIMessage({
466
+ content: '',
467
+ tool_calls: [
468
+ { id: 'call_1', name: 'search', args: {}, type: 'tool_call' },
469
+ ],
470
+ }),
471
+ new ToolMessage({
472
+ content: 'search output',
473
+ tool_call_id: 'call_1',
474
+ name: 'search',
475
+ }),
476
+ new HumanMessage({
477
+ content: 'Actually, focus on the second result.',
478
+ additional_kwargs: { source: 'steer' },
479
+ }),
480
+ ];
481
+
482
+ const { converseMessages } = convertToConverseMessages(messages);
483
+
484
+ expect(converseMessages.map((m) => m.role)).toEqual([
485
+ 'user',
486
+ 'assistant',
487
+ 'user',
488
+ ]);
489
+ const merged = converseMessages[2];
490
+ const blockKinds = (merged.content ?? []).map((block) =>
491
+ 'toolResult' in block ? 'toolResult' : 'text'
492
+ );
493
+ expect(blockKinds).toEqual(['toolResult', 'text']);
494
+ expect(
495
+ (merged.content ?? []).find((block) => 'text' in block)?.text
496
+ ).toBe('Actually, focus on the second result.');
497
+ });
498
+
499
+ it('still merges adjacent tool-result-only turns', () => {
500
+ const messages: BaseMessage[] = [
501
+ new AIMessage({
502
+ content: '',
503
+ tool_calls: [
504
+ { id: 'call_1', name: 'a', args: {}, type: 'tool_call' },
505
+ { id: 'call_2', name: 'b', args: {}, type: 'tool_call' },
506
+ ],
507
+ }),
508
+ new ToolMessage({ content: 'one', tool_call_id: 'call_1', name: 'a' }),
509
+ new ToolMessage({ content: 'two', tool_call_id: 'call_2', name: 'b' }),
510
+ ];
511
+
512
+ const { converseMessages } = convertToConverseMessages(messages);
513
+
514
+ expect(converseMessages.map((m) => m.role)).toEqual(['assistant', 'user']);
515
+ const toolResultIds = (converseMessages[1].content ?? [])
516
+ .map((block) => ('toolResult' in block ? block.toolResult?.toolUseId : undefined))
517
+ .filter(Boolean);
518
+ expect(toolResultIds).toEqual(['call_1', 'call_2']);
519
+ });
520
+
521
+ it('does not merge across an assistant turn', () => {
522
+ const messages: BaseMessage[] = [
523
+ new HumanMessage('first'),
524
+ new AIMessage('answer'),
525
+ new HumanMessage('second'),
526
+ ];
527
+ const { converseMessages } = convertToConverseMessages(messages);
528
+ expect(converseMessages.map((m) => m.role)).toEqual([
529
+ 'user',
530
+ 'assistant',
531
+ 'user',
532
+ ]);
533
+ });
534
+ });