@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
@@ -1063,7 +1063,19 @@ export function convertToConverseMessages(messages: BaseMessage[]): {
1063
1063
  }
1064
1064
  });
1065
1065
 
1066
- // Combine consecutive user tool result messages into a single message
1066
+ /**
1067
+ * Combine ALL consecutive user messages into one, not just tool-result
1068
+ * pairs. The Converse API documents strict role alternation; enforcement
1069
+ * varies by model family (Claude on Converse currently tolerates adjacent
1070
+ * user messages — verified live, 2026-07-28 — but the docs promise nothing
1071
+ * for the others), so the converter never emits the shape. The case that
1072
+ * matters: a `PostToolBatch`/`PreemptBoundary` hook injection lands a text
1073
+ * turn directly after tool results, and `ToolMessage` and `HumanMessage`
1074
+ * both convert to `role: 'user'` above. Block order is preserved
1075
+ * (toolResult blocks, then text) — verified live that Converse accepts the
1076
+ * mixed user message and answers both halves — and the toolUse/toolResult
1077
+ * pairing stays intact.
1078
+ */
1067
1079
  const combinedConverseMessages = converseMessages.reduce<BedrockMessage[]>(
1068
1080
  (acc, curr) => {
1069
1081
  if (acc.length === 0) {
@@ -1071,16 +1083,7 @@ export function convertToConverseMessages(messages: BaseMessage[]): {
1071
1083
  return acc;
1072
1084
  }
1073
1085
  const lastMessage = acc[acc.length - 1];
1074
- const lastHasToolResult =
1075
- lastMessage.content?.some((c) => 'toolResult' in c) === true;
1076
- const currHasToolResult =
1077
- curr.content?.some((c) => 'toolResult' in c) === true;
1078
- if (
1079
- lastMessage.role === 'user' &&
1080
- lastHasToolResult &&
1081
- curr.role === 'user' &&
1082
- currHasToolResult
1083
- ) {
1086
+ if (lastMessage.role === 'user' && curr.role === 'user') {
1084
1087
  lastMessage.content = lastMessage.content?.concat(curr.content ?? []);
1085
1088
  } else {
1086
1089
  acc.push(curr);
@@ -47,6 +47,10 @@ describe('convertToConverseMessages — tool-result cachePoint hoisting', () =>
47
47
 
48
48
  const { converseMessages } = convertToConverseMessages([
49
49
  new HumanMessage('go'),
50
+ new AIMessage({
51
+ content: '',
52
+ tool_calls: [{ id: 't1', name: 'calc', args: {}, type: 'tool_call' }],
53
+ }),
50
54
  toolMsg,
51
55
  ]);
52
56
 
@@ -66,6 +70,17 @@ describe('convertToConverseMessages — tool-result cachePoint hoisting', () =>
66
70
  it('leaves tool results without a cachePoint untouched', () => {
67
71
  const { converseMessages } = convertToConverseMessages([
68
72
  new HumanMessage('go'),
73
+ /**
74
+ * The calling assistant turn matters: without it the human turn and
75
+ * the tool result are adjacent user-role messages, which the converter
76
+ * now merges (Converse rejects consecutive user messages), and this
77
+ * test would assert against the merged artifact instead of the
78
+ * tool-result body it is about.
79
+ */
80
+ new AIMessage({
81
+ content: '',
82
+ tool_calls: [{ id: 't1', name: 'calc', args: {}, type: 'tool_call' }],
83
+ }),
69
84
  new ToolMessage({ tool_call_id: 't1', content: 'plain result' }),
70
85
  ]);
71
86
 
@@ -0,0 +1,87 @@
1
+ // src/llm/invoke.alternation.test.ts
2
+ /**
3
+ * `attemptInvoke` is the single funnel for primary, fallback and
4
+ * summarization model calls, and it keys the alternation pass on the provider
5
+ * ACTUALLY serving the call. This is the seam that protects a fallback: an
6
+ * OpenAI primary that failed after a boundary injected two human turns hands
7
+ * the same array to a Bedrock fallback, which rejects consecutive user turns.
8
+ */
9
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
10
+ import type { BaseMessage } from '@langchain/core/messages';
11
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
12
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
13
+ import { Providers } from '@/common';
14
+ import { FakeChatModel } from '@/llm/fake';
15
+ import { attemptInvoke } from './invoke';
16
+ import type * as t from '@/types';
17
+
18
+ class CapturingChatModel extends FakeChatModel {
19
+ readonly invocations: BaseMessage[][] = [];
20
+
21
+ constructor() {
22
+ super({ responses: ['ok'] });
23
+ }
24
+
25
+ override async *_streamResponseChunks(
26
+ messages: BaseMessage[],
27
+ options: this['ParsedCallOptions'],
28
+ runManager?: CallbackManagerForLLMRun
29
+ ): AsyncGenerator<ChatGenerationChunk> {
30
+ this.invocations.push(messages);
31
+ yield* super._streamResponseChunks(messages, options, runManager);
32
+ }
33
+ }
34
+
35
+ const adjacentUserPayload = (): BaseMessage[] => [
36
+ new HumanMessage({ content: 'question' }),
37
+ new AIMessage({ content: 'partial answer' }),
38
+ new HumanMessage({ content: 'hook context' }),
39
+ new HumanMessage({ content: 'steer' }),
40
+ ];
41
+
42
+ async function invokeAs(provider: Providers): Promise<BaseMessage[]> {
43
+ const model = new CapturingChatModel();
44
+ await attemptInvoke({
45
+ model: model as unknown as t.ChatModel,
46
+ messages: adjacentUserPayload(),
47
+ provider,
48
+ onChunk: async () => undefined,
49
+ });
50
+ expect(model.invocations).toHaveLength(1);
51
+ return model.invocations[0];
52
+ }
53
+
54
+ describe('attemptInvoke alternation funnel', () => {
55
+ it('coalesces adjacent user turns when the serving provider is strict', async () => {
56
+ const sent = await invokeAs(Providers.BEDROCK);
57
+ expect(sent.map((m) => m.getType())).toEqual(['human', 'ai', 'human']);
58
+ expect(sent[2].content).toBe('hook context\n\nsteer');
59
+ });
60
+
61
+ it('leaves the payload alone for a tolerant provider', async () => {
62
+ const sent = await invokeAs(Providers.OPENAI);
63
+ expect(sent.map((m) => m.getType())).toEqual([
64
+ 'human',
65
+ 'ai',
66
+ 'human',
67
+ 'human',
68
+ ]);
69
+ });
70
+
71
+ it('is idempotent when the graph already coalesced for the primary', async () => {
72
+ const model = new CapturingChatModel();
73
+ const once = await invokeAs(Providers.BEDROCK);
74
+ await attemptInvoke({
75
+ model: model as unknown as t.ChatModel,
76
+ messages: once,
77
+ provider: Providers.BEDROCK,
78
+ onChunk: async () => undefined,
79
+ });
80
+ expect(model.invocations[0].map((m) => m.getType())).toEqual([
81
+ 'human',
82
+ 'ai',
83
+ 'human',
84
+ ]);
85
+ expect(model.invocations[0][2].content).toBe('hook context\n\nsteer');
86
+ });
87
+ });
@@ -0,0 +1,112 @@
1
+ // src/llm/invoke.handoffCue.test.ts
2
+ /**
3
+ * The handoff cue is re-keyed on the provider ACTUALLY serving the call at
4
+ * the `attemptInvoke` funnel — the seam every fallback send passes through.
5
+ * A tolerant primary falling back to a Claude surface must gain the cue; an
6
+ * Anthropic primary falling back to a tolerant provider must have the baked
7
+ * cue stripped; and the serving model id must be read through the wrapper
8
+ * stack, or a wrapped Bedrock-Nova model would default to Claude.
9
+ */
10
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
11
+ import type { BaseMessage } from '@langchain/core/messages';
12
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
13
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
14
+ import { RunnableBinding } from '@langchain/core/runnables';
15
+ import { Providers } from '@/common';
16
+ import { PREDECESSOR_HANDOFF_CUE } from '@/messages/handoffCue';
17
+ import { FakeChatModel } from '@/llm/fake';
18
+ import { attemptInvoke, type InvokeContext } from './invoke';
19
+
20
+ class CapturingChatModel extends FakeChatModel {
21
+ readonly invocations: BaseMessage[][] = [];
22
+ /** Serving model id, as a real provider client would expose it. */
23
+ model?: string;
24
+
25
+ constructor(modelId?: string) {
26
+ super({ responses: ['ok'] });
27
+ this.model = modelId;
28
+ }
29
+
30
+ override async *_streamResponseChunks(
31
+ messages: BaseMessage[],
32
+ options: this['ParsedCallOptions'],
33
+ runManager?: CallbackManagerForLLMRun
34
+ ): AsyncGenerator<ChatGenerationChunk> {
35
+ this.invocations.push(messages);
36
+ yield* super._streamResponseChunks(messages, options, runManager);
37
+ }
38
+ }
39
+
40
+ const runTail = new AIMessage({ content: 'predecessor output', id: 'run-1' });
41
+ const context = {
42
+ isRunProducedMessage: (message: BaseMessage): boolean =>
43
+ message.id === 'run-1',
44
+ getOrCreateToolOutputRegistry: (): undefined => undefined,
45
+ } as unknown as InvokeContext;
46
+
47
+ const bakedCue = (): HumanMessage =>
48
+ new HumanMessage({
49
+ content: PREDECESSOR_HANDOFF_CUE,
50
+ additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
51
+ });
52
+
53
+ async function sentBy(
54
+ provider: Providers,
55
+ options?: { modelId?: string; wrap?: boolean; messages?: BaseMessage[] }
56
+ ): Promise<BaseMessage[]> {
57
+ const model = new CapturingChatModel(options?.modelId);
58
+ const served =
59
+ options?.wrap === true
60
+ ? new RunnableBinding({ bound: model, kwargs: {}, config: {} })
61
+ : model;
62
+ await attemptInvoke({
63
+ model: served as never,
64
+ messages: options?.messages ?? [new HumanMessage('go'), runTail],
65
+ provider,
66
+ context,
67
+ onChunk: async () => undefined,
68
+ });
69
+ return model.invocations[0];
70
+ }
71
+
72
+ describe('attemptInvoke handoff-cue funnel', () => {
73
+ it('applies the cue when the serving provider is Anthropic', async () => {
74
+ const sent = await sentBy(Providers.ANTHROPIC);
75
+ expect(sent.at(-1)?.content).toBe(PREDECESSOR_HANDOFF_CUE);
76
+ });
77
+
78
+ it('applies the cue for Bedrock serving a WRAPPED Claude model', async () => {
79
+ const sent = await sentBy(Providers.BEDROCK, {
80
+ modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
81
+ wrap: true,
82
+ });
83
+ expect(sent.at(-1)?.content).toBe(PREDECESSOR_HANDOFF_CUE);
84
+ });
85
+
86
+ it('does not apply the cue for Bedrock serving a WRAPPED Nova model', async () => {
87
+ const sent = await sentBy(Providers.BEDROCK, {
88
+ modelId: 'us.amazon.nova-pro-v1:0',
89
+ wrap: true,
90
+ });
91
+ expect(sent.at(-1)?.getType()).toBe('ai');
92
+ });
93
+
94
+ it('strips a baked cue for a tolerant serving provider', async () => {
95
+ const sent = await sentBy(Providers.OPENAI, {
96
+ messages: [new HumanMessage('go'), runTail, bakedCue()],
97
+ });
98
+ expect(sent.at(-1)?.getType()).toBe('ai');
99
+ expect(sent.some((m) => m.content === PREDECESSOR_HANDOFF_CUE)).toBe(
100
+ false
101
+ );
102
+ });
103
+
104
+ it('is idempotent when the primary already baked the cue', async () => {
105
+ const sent = await sentBy(Providers.ANTHROPIC, {
106
+ messages: [new HumanMessage('go'), runTail, bakedCue()],
107
+ });
108
+ expect(
109
+ sent.filter((m) => m.content === PREDECESSOR_HANDOFF_CUE)
110
+ ).toHaveLength(1);
111
+ });
112
+ });