@librechat/agents 3.2.61 → 3.2.63

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 (125) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +6 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/HookRegistry.cjs +32 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/executeHooks.cjs +6 -0
  8. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  9. package/dist/cjs/hooks/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -0
  11. package/dist/cjs/hooks/types.cjs.map +1 -1
  12. package/dist/cjs/langfuse.cjs +62 -1
  13. package/dist/cjs/langfuse.cjs.map +1 -1
  14. package/dist/cjs/llm/bedrock/index.cjs +266 -43
  15. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  16. package/dist/cjs/llm/openai/index.cjs +274 -7
  17. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  18. package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
  19. package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
  20. package/dist/cjs/llm/openrouter/index.cjs +5 -6
  21. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  22. package/dist/cjs/main.cjs +6 -1
  23. package/dist/cjs/messages/format.cjs +61 -0
  24. package/dist/cjs/messages/format.cjs.map +1 -1
  25. package/dist/cjs/messages/prune.cjs +31 -19
  26. package/dist/cjs/messages/prune.cjs.map +1 -1
  27. package/dist/cjs/stream.cjs +13 -3
  28. package/dist/cjs/stream.cjs.map +1 -1
  29. package/dist/cjs/tools/ToolNode.cjs +76 -11
  30. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +2 -0
  32. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  33. package/dist/cjs/utils/tokens.cjs +111 -0
  34. package/dist/cjs/utils/tokens.cjs.map +1 -1
  35. package/dist/esm/common/enum.mjs +2 -0
  36. package/dist/esm/common/enum.mjs.map +1 -1
  37. package/dist/esm/graphs/Graph.mjs +6 -2
  38. package/dist/esm/graphs/Graph.mjs.map +1 -1
  39. package/dist/esm/hooks/HookRegistry.mjs +32 -0
  40. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  41. package/dist/esm/hooks/executeHooks.mjs +6 -0
  42. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  43. package/dist/esm/hooks/index.mjs +12 -1
  44. package/dist/esm/hooks/index.mjs.map +1 -0
  45. package/dist/esm/hooks/types.mjs.map +1 -1
  46. package/dist/esm/langfuse.mjs +62 -1
  47. package/dist/esm/langfuse.mjs.map +1 -1
  48. package/dist/esm/llm/bedrock/index.mjs +266 -43
  49. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  50. package/dist/esm/llm/openai/index.mjs +274 -7
  51. package/dist/esm/llm/openai/index.mjs.map +1 -1
  52. package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
  53. package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
  54. package/dist/esm/llm/openrouter/index.mjs +5 -6
  55. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  56. package/dist/esm/main.mjs +3 -3
  57. package/dist/esm/messages/format.mjs +61 -0
  58. package/dist/esm/messages/format.mjs.map +1 -1
  59. package/dist/esm/messages/prune.mjs +31 -19
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/stream.mjs +13 -3
  62. package/dist/esm/stream.mjs.map +1 -1
  63. package/dist/esm/tools/ToolNode.mjs +76 -11
  64. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  65. package/dist/esm/tools/subagent/SubagentExecutor.mjs +2 -0
  66. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  67. package/dist/esm/utils/tokens.mjs +108 -1
  68. package/dist/esm/utils/tokens.mjs.map +1 -1
  69. package/dist/types/common/enum.d.ts +3 -1
  70. package/dist/types/graphs/Graph.d.ts +3 -1
  71. package/dist/types/hooks/HookRegistry.d.ts +10 -0
  72. package/dist/types/hooks/index.d.ts +7 -0
  73. package/dist/types/hooks/types.d.ts +18 -1
  74. package/dist/types/llm/bedrock/index.d.ts +5 -0
  75. package/dist/types/llm/openai/index.d.ts +19 -0
  76. package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
  77. package/dist/types/messages/prune.d.ts +4 -3
  78. package/dist/types/tools/ToolNode.d.ts +1 -0
  79. package/dist/types/types/graph.d.ts +10 -0
  80. package/dist/types/types/llm.d.ts +12 -2
  81. package/dist/types/types/tools.d.ts +1 -1
  82. package/dist/types/utils/tokens.d.ts +30 -0
  83. package/package.json +6 -6
  84. package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
  85. package/src/common/enum.ts +2 -0
  86. package/src/graphs/Graph.ts +12 -4
  87. package/src/hooks/HookRegistry.ts +45 -0
  88. package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
  89. package/src/hooks/__tests__/executeHooks.test.ts +85 -2
  90. package/src/hooks/executeHooks.ts +15 -0
  91. package/src/hooks/index.ts +7 -0
  92. package/src/hooks/types.ts +18 -1
  93. package/src/langfuse.ts +134 -1
  94. package/src/llm/bedrock/index.ts +434 -83
  95. package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
  96. package/src/llm/custom-chat-models.smoke.test.ts +7 -0
  97. package/src/llm/openai/index.ts +604 -6
  98. package/src/llm/openai/managedRequests.test.ts +182 -0
  99. package/src/llm/openai/streamMetadata.spec.ts +86 -0
  100. package/src/llm/openai/streamMetadata.ts +95 -0
  101. package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
  102. package/src/llm/openrouter/index.ts +9 -5
  103. package/src/messages/format.ts +96 -3
  104. package/src/messages/formatAgentMessages.steer.test.ts +326 -0
  105. package/src/messages/labelContentByAgent.test.ts +75 -0
  106. package/src/messages/prune.ts +56 -30
  107. package/src/specs/anthropic.simple.test.ts +4 -2
  108. package/src/specs/cache.simple.test.ts +17 -4
  109. package/src/specs/langfuse-callbacks.test.ts +61 -0
  110. package/src/specs/openai.simple.test.ts +4 -2
  111. package/src/specs/spec.utils.ts +12 -0
  112. package/src/specs/summarization.test.ts +9 -13
  113. package/src/specs/token-accounting-pipeline.test.ts +130 -4
  114. package/src/specs/tokens.test.ts +214 -0
  115. package/src/stream.ts +22 -3
  116. package/src/tools/ToolNode.ts +112 -13
  117. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
  118. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
  119. package/src/tools/__tests__/hitl.test.ts +112 -0
  120. package/src/tools/__tests__/subagentHooks.test.ts +124 -0
  121. package/src/tools/subagent/SubagentExecutor.ts +3 -0
  122. package/src/types/graph.ts +10 -0
  123. package/src/types/llm.ts +12 -2
  124. package/src/types/tools.ts +1 -1
  125. package/src/utils/tokens.ts +181 -3
@@ -466,9 +466,15 @@ function hasToolCallOutput(part: MessageContentComplex): boolean {
466
466
  function formatAssistantMessage(
467
467
  message: Partial<TMessage>,
468
468
  options?: FormatAssistantMessageOptions
469
- ): Array<RoleBearingMessage<AIMessage> | RoleBearingMessage<ToolMessage>> {
469
+ ): Array<
470
+ | RoleBearingMessage<AIMessage>
471
+ | RoleBearingMessage<ToolMessage>
472
+ | RoleBearingMessage<HumanMessage>
473
+ > {
470
474
  const formattedMessages: Array<
471
- RoleBearingMessage<AIMessage> | RoleBearingMessage<ToolMessage>
475
+ | RoleBearingMessage<AIMessage>
476
+ | RoleBearingMessage<ToolMessage>
477
+ | RoleBearingMessage<HumanMessage>
472
478
  > = [];
473
479
  let currentContent: MessageContentComplex[] = [];
474
480
  let lastAIMessage: RoleBearingMessage<AIMessage> | null = null;
@@ -725,6 +731,70 @@ function formatAssistantMessage(
725
731
  hasReasoning = true;
726
732
  pendingReasoningContent += extractReasoningContent(part);
727
733
  continue;
734
+ } else if (part.type === ContentTypes.STEER) {
735
+ /*
736
+ A mid-run steer: user speech persisted inline in the assistant message
737
+ at the tool-batch boundary it was injected. Flush accumulated
738
+ assistant content first so ordering is preserved, then replay the
739
+ steer as a standalone user message — multimodal when the host stamped
740
+ a pre-encoded `media` content array (attachment refs are re-encoded
741
+ per turn host-side, like any other user media). `lastAIMessage` is
742
+ reset AFTER the HumanMessage: a post-steer tool_call must mint a
743
+ FRESH assistant anchor (the heal path) so its AIMessage lands after
744
+ the user turn — attaching it to the pre-steer anchor would emit its
745
+ ToolMessage after the HumanMessage while the call itself sat before
746
+ it, an invalid provider ordering.
747
+ */
748
+ if (currentContent.length > 0) {
749
+ if (
750
+ currentContent.some((content) => content.type !== ContentTypes.TEXT)
751
+ ) {
752
+ lastAIMessage = createAIMessage(toLangChainContent(currentContent));
753
+ formattedMessages.push(lastAIMessage);
754
+ } else {
755
+ const flushed = currentContent
756
+ .reduce((acc, curr) => `${acc}${getTextContent(curr)}\n`, '')
757
+ .trim();
758
+ if (flushed.length > 0) {
759
+ lastAIMessage = createAIMessage(flushed);
760
+ formattedMessages.push(lastAIMessage);
761
+ }
762
+ }
763
+ currentContent = [];
764
+ } else if (shouldPreserveReasoningContent && pendingReasoningContent) {
765
+ /**
766
+ * Reasoning directly preceding a steer has no `currentContent`
767
+ * flush to consume it and the anchor resets below — emit an anchor
768
+ * AIMessage now (createAIMessage folds the pending reasoning into
769
+ * `additional_kwargs.reasoning_content`) or the persisted
770
+ * assistant reasoning silently vanishes on replay.
771
+ */
772
+ lastAIMessage = createAIMessage('');
773
+ formattedMessages.push(lastAIMessage);
774
+ }
775
+ const steerPart = part as {
776
+ steer?: string;
777
+ media?: MessageContentComplex[];
778
+ };
779
+ const steerContent =
780
+ Array.isArray(steerPart.media) && steerPart.media.length > 0
781
+ ? toLangChainContent(steerPart.media)
782
+ : (steerPart.steer ?? '');
783
+ formattedMessages.push(
784
+ withMessageRole(
785
+ new HumanMessage({
786
+ content: steerContent as MessageContent,
787
+ additional_kwargs: { role: 'user', source: 'steer' },
788
+ }),
789
+ 'user'
790
+ )
791
+ );
792
+ lastAIMessage = null;
793
+ /** The steer splits the assistant message: the post-steer segment
794
+ * starts with fresh reasoning state (pre-steer reasoning was either
795
+ * flushed above or intentionally dropped when not preserving). */
796
+ hasReasoning = false;
797
+ pendingReasoningContent = '';
728
798
  } else if (
729
799
  part.type === ContentTypes.ERROR ||
730
800
  part.type === ContentTypes.AGENT_UPDATE ||
@@ -852,6 +922,12 @@ function labelAllAgentContent(
852
922
  }
853
923
 
854
924
  currentAgentId = agentId;
925
+ if (part.type === ContentTypes.STEER) {
926
+ /** User speech is never agent content — see the transfer path above. */
927
+ flushAgentBuffer();
928
+ result.push(part);
929
+ continue;
930
+ }
855
931
  agentContentBuffer.push(part);
856
932
  }
857
933
 
@@ -987,6 +1063,22 @@ export const labelContentByAgent = (
987
1063
  transferToolCallIndex = result.length - 1;
988
1064
  transferToolCallId = (part as ToolCallContent).tool_call?.id;
989
1065
  currentAgentId = undefined; // Reset to capture the next agent
1066
+ } else if (part.type === ContentTypes.STEER) {
1067
+ /**
1068
+ * User speech is never agent content: flush the buffer in place and
1069
+ * pass the steer through verbatim so `formatAssistantMessage` replays
1070
+ * it as a user turn. Folding it into a labeled transfer summary would
1071
+ * both drop the user's words and misattribute them to the agent.
1072
+ * The steer also CLOSES any open transfer capture — `flushAgentBuffer`
1073
+ * no-ops on an empty buffer, and leaving the capture live would fold
1074
+ * post-steer agent content into the pre-steer transfer output,
1075
+ * replaying it BEFORE the user's redirect.
1076
+ */
1077
+ flushAgentBuffer();
1078
+ transferToolCallIndex = undefined;
1079
+ transferToolCallId = undefined;
1080
+ currentAgentId = undefined;
1081
+ result.push(part);
990
1082
  } else {
991
1083
  agentContentBuffer.push(part);
992
1084
  }
@@ -1440,7 +1532,8 @@ export const formatAgentMessages = (
1440
1532
  const formattedMessages = formatAssistantMessage(processedMessage, {
1441
1533
  preserveUnpairedServerToolUses: i === payload.length - 1,
1442
1534
  preserveReasoningContent:
1443
- options?.preserveReasoningContent ?? options?.provider === Providers.DEEPSEEK,
1535
+ options?.preserveReasoningContent ??
1536
+ options?.provider === Providers.DEEPSEEK,
1444
1537
  provider: options?.provider,
1445
1538
  });
1446
1539
  if (sourceMessageId != null && sourceMessageId !== '') {
@@ -0,0 +1,326 @@
1
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
+ import type { TPayload } from '@/types';
3
+ import { ContentTypes, Constants, Providers } from '@/common';
4
+ import { formatAgentMessages } from './format';
5
+
6
+ function toolCallPart(
7
+ id: string,
8
+ name = 'search',
9
+ output = 'result'
10
+ ): Record<string, unknown> {
11
+ return {
12
+ type: ContentTypes.TOOL_CALL,
13
+ tool_call: { id, name, args: '{}', output },
14
+ };
15
+ }
16
+
17
+ describe('formatAgentMessages steer replay', () => {
18
+ it('replays a steer part as a user message after preceding tool messages', () => {
19
+ const payload: TPayload = [
20
+ { role: 'user', content: 'Original request' },
21
+ {
22
+ role: 'assistant',
23
+ content: [
24
+ {
25
+ type: ContentTypes.TEXT,
26
+ [ContentTypes.TEXT]: 'Working on it.',
27
+ tool_call_ids: ['call_1'],
28
+ },
29
+ toolCallPart('call_1'),
30
+ {
31
+ type: ContentTypes.STEER,
32
+ [ContentTypes.STEER]: 'Actually, focus on the second item.',
33
+ steerId: 's1',
34
+ } as unknown as Record<string, unknown>,
35
+ {
36
+ type: ContentTypes.TEXT,
37
+ [ContentTypes.TEXT]: 'Focusing on item two.',
38
+ },
39
+ ],
40
+ },
41
+ ];
42
+
43
+ const { messages } = formatAgentMessages(payload);
44
+
45
+ const steerIndex = messages.findIndex(
46
+ (message) =>
47
+ message instanceof HumanMessage &&
48
+ message.additional_kwargs.source === 'steer'
49
+ );
50
+ expect(steerIndex).toBeGreaterThan(0);
51
+ expect(messages[steerIndex].content).toBe(
52
+ 'Actually, focus on the second item.'
53
+ );
54
+ expect(messages[steerIndex - 1]).toBeInstanceOf(ToolMessage);
55
+ const trailing = messages[steerIndex + 1];
56
+ expect(trailing).toBeInstanceOf(AIMessage);
57
+ });
58
+
59
+ it('mints a fresh assistant anchor for a tool call after a steer', () => {
60
+ const payload: TPayload = [
61
+ {
62
+ role: 'assistant',
63
+ content: [
64
+ {
65
+ type: ContentTypes.TEXT,
66
+ [ContentTypes.TEXT]: 'First step.',
67
+ tool_call_ids: ['call_1'],
68
+ },
69
+ toolCallPart('call_1'),
70
+ {
71
+ type: ContentTypes.STEER,
72
+ [ContentTypes.STEER]: 'Do the second thing instead.',
73
+ } as unknown as Record<string, unknown>,
74
+ toolCallPart('call_2', 'search', 'second result'),
75
+ ],
76
+ },
77
+ ];
78
+
79
+ const { messages } = formatAgentMessages(payload);
80
+
81
+ // AI(call_1), Tool(call_1), Human(steer), AI(call_2), Tool(call_2) —
82
+ // the post-steer tool call must NOT attach to the pre-steer anchor, or
83
+ // its ToolMessage would trail the user turn while the call precedes it.
84
+ expect(messages.map((message) => message.constructor.name)).toEqual([
85
+ 'AIMessage',
86
+ 'ToolMessage',
87
+ 'HumanMessage',
88
+ 'AIMessage',
89
+ 'ToolMessage',
90
+ ]);
91
+ const postSteerAnchor = messages[3] as AIMessage;
92
+ expect(postSteerAnchor.tool_calls?.map((call) => call.id)).toEqual([
93
+ 'call_2',
94
+ ]);
95
+ const preSteerAnchor = messages[0] as AIMessage;
96
+ expect(preSteerAnchor.tool_calls?.map((call) => call.id)).toEqual([
97
+ 'call_1',
98
+ ]);
99
+ });
100
+
101
+ it('flushes accumulated assistant text before the steer user message', () => {
102
+ const payload: TPayload = [
103
+ {
104
+ role: 'assistant',
105
+ content: [
106
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
107
+ {
108
+ type: ContentTypes.STEER,
109
+ [ContentTypes.STEER]: 'Change of plans.',
110
+ } as unknown as Record<string, unknown>,
111
+ ],
112
+ },
113
+ ];
114
+
115
+ const { messages } = formatAgentMessages(payload);
116
+
117
+ expect(messages).toHaveLength(2);
118
+ expect(messages[0]).toBeInstanceOf(AIMessage);
119
+ expect(messages[0].content).toBe('Partial answer.');
120
+ expect(messages[1]).toBeInstanceOf(HumanMessage);
121
+ expect(messages[1].content).toBe('Change of plans.');
122
+ });
123
+
124
+ it('preserves pending reasoning that directly precedes a steer', () => {
125
+ const payload: TPayload = [
126
+ {
127
+ role: 'assistant',
128
+ content: [
129
+ {
130
+ type: ContentTypes.THINK,
131
+ [ContentTypes.THINK]: 'chain of thought',
132
+ },
133
+ {
134
+ type: ContentTypes.STEER,
135
+ [ContentTypes.STEER]: 'Stop and reconsider.',
136
+ } as unknown as Record<string, unknown>,
137
+ ],
138
+ },
139
+ ];
140
+
141
+ const { messages } = formatAgentMessages(
142
+ payload,
143
+ undefined,
144
+ undefined,
145
+ undefined,
146
+ {
147
+ preserveReasoningContent: true,
148
+ }
149
+ );
150
+
151
+ expect(messages).toHaveLength(2);
152
+ expect(messages[0]).toBeInstanceOf(AIMessage);
153
+ expect(messages[0].additional_kwargs.reasoning_content).toBe(
154
+ 'chain of thought'
155
+ );
156
+ expect(messages[1]).toBeInstanceOf(HumanMessage);
157
+ expect(messages[1].content).toBe('Stop and reconsider.');
158
+ });
159
+
160
+ it('uses the host-stamped media array for multimodal steers', () => {
161
+ const media = [
162
+ { type: 'text', text: 'Look at this screenshot.' },
163
+ {
164
+ type: 'image_url',
165
+ image_url: { url: 'data:image/png;base64,abc123', detail: 'auto' },
166
+ },
167
+ ];
168
+ const payload: TPayload = [
169
+ {
170
+ role: 'assistant',
171
+ content: [
172
+ {
173
+ type: ContentTypes.STEER,
174
+ [ContentTypes.STEER]: 'Look at this screenshot.',
175
+ media,
176
+ } as unknown as Record<string, unknown>,
177
+ ],
178
+ },
179
+ ];
180
+
181
+ const { messages } = formatAgentMessages(payload);
182
+
183
+ expect(messages).toHaveLength(1);
184
+ expect(messages[0]).toBeInstanceOf(HumanMessage);
185
+ expect(messages[0].content).toEqual(media);
186
+ expect((messages[0] as HumanMessage).additional_kwargs.source).toBe(
187
+ 'steer'
188
+ );
189
+ });
190
+
191
+ it('replays [THINK, STEER, TEXT] without duplicating the trailing text', () => {
192
+ const payload: TPayload = [
193
+ {
194
+ role: 'assistant',
195
+ content: [
196
+ {
197
+ type: ContentTypes.THINK,
198
+ [ContentTypes.THINK]: 'chain of thought',
199
+ },
200
+ {
201
+ type: ContentTypes.STEER,
202
+ [ContentTypes.STEER]: 'Redirect now.',
203
+ } as unknown as Record<string, unknown>,
204
+ {
205
+ type: ContentTypes.TEXT,
206
+ [ContentTypes.TEXT]: 'Post-steer answer.',
207
+ },
208
+ ],
209
+ },
210
+ ];
211
+
212
+ const { messages } = formatAgentMessages(
213
+ payload,
214
+ undefined,
215
+ undefined,
216
+ undefined,
217
+ {
218
+ preserveReasoningContent: true,
219
+ }
220
+ );
221
+
222
+ expect(messages.map((message) => message.constructor.name)).toEqual([
223
+ 'AIMessage',
224
+ 'HumanMessage',
225
+ 'AIMessage',
226
+ ]);
227
+ expect(messages[0].additional_kwargs.reasoning_content).toBe(
228
+ 'chain of thought'
229
+ );
230
+ // Post-steer segment starts with fresh reasoning state: the trailing
231
+ // text appears exactly once (end-of-loop array form) and carries no
232
+ // pre-steer reasoning.
233
+ expect(messages[2].content).toEqual([
234
+ { type: 'text', text: 'Post-steer answer.' },
235
+ ]);
236
+ expect(messages[2].additional_kwargs.reasoning_content).toBeUndefined();
237
+ });
238
+
239
+ it('keeps a paired Anthropic server-tool use/result together after the steer', () => {
240
+ const useId = `${Constants.ANTHROPIC_SERVER_TOOL_PREFIX}search1`;
241
+ const payload: TPayload = [
242
+ {
243
+ role: 'assistant',
244
+ content: [
245
+ {
246
+ type: ContentTypes.TOOL_CALL,
247
+ tool_call: { id: useId, name: 'web_search', args: '{"query":"x"}' },
248
+ },
249
+ {
250
+ type: ContentTypes.STEER,
251
+ [ContentTypes.STEER]: 'Also check the docs.',
252
+ } as unknown as Record<string, unknown>,
253
+ {
254
+ type: 'web_search_tool_result',
255
+ tool_use_id: useId,
256
+ content: [
257
+ { type: 'web_search_result', url: 'https://example.com' },
258
+ ],
259
+ } as unknown as Record<string, unknown>,
260
+ ],
261
+ },
262
+ ];
263
+
264
+ const { messages } = formatAgentMessages(
265
+ payload,
266
+ undefined,
267
+ new Set(['web_search']),
268
+ undefined,
269
+ { provider: Providers.ANTHROPIC }
270
+ );
271
+
272
+ // The pair may not split across the user turn (invalid for Anthropic);
273
+ // it emits together in the post-steer assistant segment instead.
274
+ expect(messages[0]).toBeInstanceOf(HumanMessage);
275
+ expect(messages[0].content).toBe('Also check the docs.');
276
+ const assistant = messages[1];
277
+ expect(assistant).toBeInstanceOf(AIMessage);
278
+ const parts = assistant.content as Array<{
279
+ type?: string;
280
+ tool_use_id?: string;
281
+ id?: string;
282
+ }>;
283
+ const useIndex = parts.findIndex((part) => part.type === 'server_tool_use');
284
+ const resultIndex = parts.findIndex(
285
+ (part) => part.type === 'web_search_tool_result'
286
+ );
287
+ expect(useIndex).toBeGreaterThanOrEqual(0);
288
+ expect(resultIndex).toBe(useIndex + 1);
289
+ });
290
+
291
+ it('never leaks a steer part into assistant content sent to the provider', () => {
292
+ const payload: TPayload = [
293
+ {
294
+ role: 'assistant',
295
+ content: [
296
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Before.' },
297
+ {
298
+ type: ContentTypes.STEER,
299
+ [ContentTypes.STEER]: 'Steer text.',
300
+ } as unknown as Record<string, unknown>,
301
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'After.' },
302
+ ],
303
+ },
304
+ ];
305
+
306
+ const { messages } = formatAgentMessages(payload);
307
+
308
+ for (const message of messages) {
309
+ if (message instanceof HumanMessage) {
310
+ continue;
311
+ }
312
+ const content = message.content;
313
+ if (Array.isArray(content)) {
314
+ expect(
315
+ content.some((part) => (part as { type?: string }).type === 'steer')
316
+ ).toBe(false);
317
+ }
318
+ }
319
+ // Trailing content after the steer keeps the end-of-loop array form.
320
+ expect(messages.map((message) => message.content)).toEqual([
321
+ 'Before.',
322
+ 'Steer text.',
323
+ [{ type: 'text', text: 'After.' }],
324
+ ]);
325
+ });
326
+ });
@@ -54,6 +54,81 @@ describe('labelContentByAgent', () => {
54
54
  });
55
55
  });
56
56
 
57
+ describe('steer pass-through', () => {
58
+ it('passes steer parts through the parallel labeler verbatim', () => {
59
+ const steerPart = {
60
+ type: ContentTypes.STEER,
61
+ steer: 'User redirect mid-run',
62
+ steerId: 's1',
63
+ } as unknown as MessageContentComplex;
64
+ const contentParts: MessageContentComplex[] = [
65
+ { type: ContentTypes.TEXT, text: 'agent output' },
66
+ steerPart,
67
+ { type: ContentTypes.TEXT, text: 'post-steer output' },
68
+ ];
69
+
70
+ const result = labelContentByAgent(
71
+ contentParts,
72
+ { 0: 'agent_a', 1: 'agent_a', 2: 'agent_a' },
73
+ { agent_a: 'Alpha' },
74
+ { labelNonTransferContent: true }
75
+ );
76
+
77
+ // The user's words survive verbatim (not folded into a labeled
78
+ // summary) so formatAssistantMessage can replay them as a user turn.
79
+ expect(result).toContain(steerPart);
80
+ });
81
+ });
82
+
83
+ describe('steer vs transfer capture', () => {
84
+ it('closes an open transfer capture at a steer boundary', () => {
85
+ const transferPart: MessageContentComplex = {
86
+ type: ContentTypes.TOOL_CALL,
87
+ tool_call: {
88
+ id: 'call_transfer',
89
+ name: 'lc_transfer_to_beta',
90
+ args: '{}',
91
+ output: 'original output',
92
+ },
93
+ } as MessageContentComplex;
94
+ const steerPart = {
95
+ type: ContentTypes.STEER,
96
+ steer: 'Actually, stop and summarize.',
97
+ } as unknown as MessageContentComplex;
98
+ const contentParts: MessageContentComplex[] = [
99
+ transferPart,
100
+ steerPart,
101
+ { type: ContentTypes.TEXT, text: 'post-steer agent output' },
102
+ ];
103
+
104
+ const result = labelContentByAgent(
105
+ contentParts,
106
+ { 0: 'agent_a', 1: 'agent_a', 2: 'agent_b' },
107
+ { agent_b: 'Beta' }
108
+ );
109
+
110
+ // The steer passes through and post-steer content stays AFTER it —
111
+ // never folded into the pre-steer transfer output, which would replay
112
+ // it ahead of the user's redirect.
113
+ expect(result).toContain(steerPart);
114
+ const transferInResult = result.find(
115
+ (part) =>
116
+ isToolCallContent(part) && part.tool_call?.id === 'call_transfer'
117
+ );
118
+ expect(
119
+ isToolCallContent(transferInResult!)
120
+ ? transferInResult.tool_call?.output
121
+ : undefined
122
+ ).toBe('original output');
123
+ const steerIndex = result.indexOf(steerPart);
124
+ const textIndex = result.findIndex(
125
+ (part) =>
126
+ hasTextProperty(part) && part.text === 'post-steer agent output'
127
+ );
128
+ expect(textIndex).toBeGreaterThan(steerIndex);
129
+ });
130
+ });
131
+
57
132
  describe('Transfer-based labeling (default)', () => {
58
133
  it('should consolidate transferred agent content into transfer tool output', () => {
59
134
  const contentParts: MessageContentComplex[] = [
@@ -135,9 +135,10 @@ export type PruneMessagesParams = {
135
135
  usageMetadata?: Partial<UsageMetadata>;
136
136
  startType?: ReturnType<BaseMessage['getType']>;
137
137
  /**
138
- * Usage from the most recent LLM call only (not accumulated).
139
- * When provided, calibration uses this instead of usageMetadata
140
- * to avoid inflated ratios from N×cacheRead accumulation.
138
+ * Fallback usage from the most recent LLM call only (not accumulated).
139
+ * Calibration prefers the provider's raw `usageMetadata.input_tokens`
140
+ * when available, because cache detail fields may use non-window
141
+ * accounting units.
141
142
  */
142
143
  lastCallUsage?: {
143
144
  totalTokens: number;
@@ -1436,8 +1437,10 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1436
1437
  // no per-turn oscillation, no map mutation.
1437
1438
  if (currentUsage && params.totalTokensFresh !== false) {
1438
1439
  const instructionOverhead = factoryParams.getInstructionTokens?.() ?? 0;
1439
- const providerInputTokens =
1440
- params.lastCallUsage?.inputTokens ?? currentUsage.input_tokens;
1440
+ const rawProviderInputTokens = Number(params.usageMetadata?.input_tokens);
1441
+ const providerInputTokens = checkValidNumber(rawProviderInputTokens)
1442
+ ? rawProviderInputTokens
1443
+ : (params.lastCallUsage?.inputTokens ?? currentUsage.input_tokens);
1441
1444
 
1442
1445
  // Sum raw tiktoken counts for messages the provider saw (excludes
1443
1446
  // new outputs from this turn — the provider hasn't seen them yet).
@@ -1458,8 +1461,21 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1458
1461
  0,
1459
1462
  providerInputTokens - instructionOverhead
1460
1463
  );
1464
+ const minimumComparableInputTokens =
1465
+ instructionOverhead + rawSentThisTurn * CALIBRATION_RATIO_MIN;
1466
+ let calibrationSkipReason: string | undefined;
1467
+ if (rawSentThisTurn <= 0) {
1468
+ calibrationSkipReason = 'no_sent_messages';
1469
+ } else if (providerMessageTokens <= 0) {
1470
+ calibrationSkipReason = 'input_below_instruction_overhead';
1471
+ } else if (providerInputTokens < minimumComparableInputTokens) {
1472
+ calibrationSkipReason = 'input_below_calibration_floor';
1473
+ }
1474
+ // No upper-bound rejection: maxTokens is an application budget, not the
1475
+ // provider's real context window. Usage above the budget is a genuine
1476
+ // measurement — and the one that must drive pruning/summarization.
1461
1477
 
1462
- if (rawSentThisTurn > 0 && providerMessageTokens > 0) {
1478
+ if (calibrationSkipReason == null) {
1463
1479
  cumulativeRawSent += rawSentThisTurn;
1464
1480
  cumulativeProviderReported += providerMessageTokens;
1465
1481
  const newRatio = cumulativeProviderReported / cumulativeRawSent;
@@ -1467,33 +1483,43 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1467
1483
  CALIBRATION_RATIO_MIN,
1468
1484
  Math.min(CALIBRATION_RATIO_MAX, newRatio)
1469
1485
  );
1470
- }
1471
1486
 
1472
- const calibratedOurTotal =
1473
- instructionOverhead + rawSentThisTurn * calibrationRatio;
1474
- const overallRatio =
1475
- calibratedOurTotal > 0 ? providerInputTokens / calibratedOurTotal : 0;
1476
- const variancePct = Math.round((overallRatio - 1) * 100);
1487
+ const calibratedOurTotal =
1488
+ instructionOverhead + rawSentThisTurn * calibrationRatio;
1489
+ const overallRatio =
1490
+ calibratedOurTotal > 0 ? providerInputTokens / calibratedOurTotal : 0;
1491
+ const variancePct = Math.round((overallRatio - 1) * 100);
1492
+
1493
+ const absVariance = Math.abs(overallRatio - 1);
1494
+ if (absVariance < bestVarianceAbs) {
1495
+ bestVarianceAbs = absVariance;
1496
+ bestInstructionOverhead = Math.max(
1497
+ 0,
1498
+ Math.round(providerInputTokens - rawSentThisTurn * calibrationRatio)
1499
+ );
1500
+ bestInstructionEstimate = factoryParams.getInstructionTokens?.() ?? 0;
1501
+ }
1477
1502
 
1478
- const absVariance = Math.abs(overallRatio - 1);
1479
- if (absVariance < bestVarianceAbs && rawSentThisTurn > 0) {
1480
- bestVarianceAbs = absVariance;
1481
- bestInstructionOverhead = Math.max(
1482
- 0,
1483
- Math.round(providerInputTokens - rawSentThisTurn * calibrationRatio)
1484
- );
1485
- bestInstructionEstimate = factoryParams.getInstructionTokens?.() ?? 0;
1503
+ factoryParams.log?.('debug', 'Calibration observed', {
1504
+ providerInputTokens,
1505
+ calibratedEstimate: Math.round(calibratedOurTotal),
1506
+ variance: `${variancePct > 0 ? '+' : ''}${variancePct}%`,
1507
+ calibrationRatio: Math.round(calibrationRatio * 100) / 100,
1508
+ instructionOverhead,
1509
+ cumulativeRawSent,
1510
+ cumulativeProviderReported,
1511
+ });
1512
+ } else {
1513
+ factoryParams.log?.('debug', 'Calibration skipped', {
1514
+ reason: calibrationSkipReason,
1515
+ providerInputTokens,
1516
+ minimumComparableInputTokens: Math.round(
1517
+ minimumComparableInputTokens
1518
+ ),
1519
+ rawSentThisTurn,
1520
+ instructionOverhead,
1521
+ });
1486
1522
  }
1487
-
1488
- factoryParams.log?.('debug', 'Calibration observed', {
1489
- providerInputTokens,
1490
- calibratedEstimate: Math.round(calibratedOurTotal),
1491
- variance: `${variancePct > 0 ? '+' : ''}${variancePct}%`,
1492
- calibrationRatio: Math.round(calibrationRatio * 100) / 100,
1493
- instructionOverhead,
1494
- cumulativeRawSent,
1495
- cumulativeProviderReported,
1496
- });
1497
1523
  }
1498
1524
 
1499
1525
  // Computed BEFORE pre-flight truncation so the effective budget can drive
@@ -16,14 +16,16 @@ import {
16
16
  createMetadataAggregator,
17
17
  } from '@/events';
18
18
  import { ContentTypes, GraphEvents, Providers, TitleMethod } from '@/common';
19
- import { capitalizeFirstLetter } from './spec.utils';
19
+ import { capitalizeFirstLetter, hasEnv } from './spec.utils';
20
20
  import { createContentAggregator } from '@/stream';
21
21
  import { getLLMConfig } from '@/utils/llmConfig';
22
22
  import { getArgs } from '@/scripts/args';
23
23
  import { Run } from '@/run';
24
24
 
25
25
  const provider = Providers.ANTHROPIC;
26
- describe(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
26
+ const describeIf = hasEnv('ANTHROPIC_API_KEY') ? describe : describe.skip;
27
+
28
+ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
27
29
  jest.setTimeout(90000);
28
30
  let run: Run<t.IState>;
29
31
  let runningHistory: BaseMessage[];