@librechat/agents 3.2.34 → 3.2.36

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 (128) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +119 -9
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/agents/projection.cjs +25 -0
  4. package/dist/cjs/agents/projection.cjs.map +1 -0
  5. package/dist/cjs/common/enum.cjs +13 -0
  6. package/dist/cjs/common/enum.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +106 -3
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +26 -4
  10. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +20 -0
  12. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/invoke.cjs +49 -8
  14. package/dist/cjs/llm/invoke.cjs.map +1 -1
  15. package/dist/cjs/main.cjs +7 -0
  16. package/dist/cjs/messages/budget.cjs +23 -0
  17. package/dist/cjs/messages/budget.cjs.map +1 -0
  18. package/dist/cjs/messages/cache.cjs +1 -0
  19. package/dist/cjs/messages/cache.cjs.map +1 -1
  20. package/dist/cjs/messages/content.cjs +12 -14
  21. package/dist/cjs/messages/content.cjs.map +1 -1
  22. package/dist/cjs/messages/index.cjs +1 -0
  23. package/dist/cjs/messages/prune.cjs +31 -13
  24. package/dist/cjs/messages/prune.cjs.map +1 -1
  25. package/dist/cjs/run.cjs +7 -2
  26. package/dist/cjs/run.cjs.map +1 -1
  27. package/dist/cjs/summarization/node.cjs +12 -1
  28. package/dist/cjs/summarization/node.cjs.map +1 -1
  29. package/dist/cjs/tools/search/format.cjs +91 -2
  30. package/dist/cjs/tools/search/format.cjs.map +1 -1
  31. package/dist/cjs/tools/search/tool.cjs +4 -3
  32. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  33. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
  34. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  35. package/dist/cjs/utils/tokens.cjs +30 -0
  36. package/dist/cjs/utils/tokens.cjs.map +1 -1
  37. package/dist/esm/agents/AgentContext.mjs +121 -11
  38. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  39. package/dist/esm/agents/projection.mjs +25 -0
  40. package/dist/esm/agents/projection.mjs.map +1 -0
  41. package/dist/esm/common/enum.mjs +13 -0
  42. package/dist/esm/common/enum.mjs.map +1 -1
  43. package/dist/esm/graphs/Graph.mjs +107 -4
  44. package/dist/esm/graphs/Graph.mjs.map +1 -1
  45. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +26 -4
  46. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  47. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +20 -0
  48. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  49. package/dist/esm/llm/invoke.mjs +49 -8
  50. package/dist/esm/llm/invoke.mjs.map +1 -1
  51. package/dist/esm/main.mjs +6 -4
  52. package/dist/esm/messages/budget.mjs +23 -0
  53. package/dist/esm/messages/budget.mjs.map +1 -0
  54. package/dist/esm/messages/cache.mjs +1 -1
  55. package/dist/esm/messages/cache.mjs.map +1 -1
  56. package/dist/esm/messages/content.mjs +12 -15
  57. package/dist/esm/messages/content.mjs.map +1 -1
  58. package/dist/esm/messages/index.mjs +1 -0
  59. package/dist/esm/messages/prune.mjs +31 -13
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/run.mjs +7 -2
  62. package/dist/esm/run.mjs.map +1 -1
  63. package/dist/esm/summarization/node.mjs +12 -1
  64. package/dist/esm/summarization/node.mjs.map +1 -1
  65. package/dist/esm/tools/search/format.mjs +91 -2
  66. package/dist/esm/tools/search/format.mjs.map +1 -1
  67. package/dist/esm/tools/search/tool.mjs +4 -3
  68. package/dist/esm/tools/search/tool.mjs.map +1 -1
  69. package/dist/esm/tools/subagent/SubagentExecutor.mjs +138 -2
  70. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  71. package/dist/esm/utils/tokens.mjs +30 -1
  72. package/dist/esm/utils/tokens.mjs.map +1 -1
  73. package/dist/types/agents/AgentContext.d.ts +37 -4
  74. package/dist/types/agents/projection.d.ts +26 -0
  75. package/dist/types/common/enum.d.ts +13 -0
  76. package/dist/types/graphs/Graph.d.ts +8 -1
  77. package/dist/types/index.d.ts +1 -0
  78. package/dist/types/llm/invoke.d.ts +1 -1
  79. package/dist/types/messages/budget.d.ts +11 -0
  80. package/dist/types/messages/cache.d.ts +7 -0
  81. package/dist/types/messages/content.d.ts +5 -0
  82. package/dist/types/messages/index.d.ts +1 -0
  83. package/dist/types/messages/prune.d.ts +4 -0
  84. package/dist/types/run.d.ts +1 -0
  85. package/dist/types/tools/search/format.d.ts +4 -1
  86. package/dist/types/tools/search/types.d.ts +7 -0
  87. package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
  88. package/dist/types/types/graph.d.ts +89 -3
  89. package/dist/types/types/run.d.ts +13 -0
  90. package/dist/types/utils/tokens.d.ts +7 -0
  91. package/package.json +1 -1
  92. package/src/agents/AgentContext.ts +172 -8
  93. package/src/agents/__tests__/AgentContext.test.ts +235 -2
  94. package/src/agents/__tests__/projection.test.ts +73 -0
  95. package/src/agents/projection.ts +46 -0
  96. package/src/common/enum.ts +13 -0
  97. package/src/graphs/Graph.ts +168 -0
  98. package/src/index.ts +3 -0
  99. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
  100. package/src/llm/anthropic/utils/message_inputs.ts +78 -16
  101. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
  102. package/src/llm/bedrock/utils/message_inputs.ts +35 -0
  103. package/src/llm/invoke.test.ts +79 -1
  104. package/src/llm/invoke.ts +58 -4
  105. package/src/messages/budget.ts +32 -0
  106. package/src/messages/cache.ts +1 -1
  107. package/src/messages/content.ts +24 -32
  108. package/src/messages/index.ts +1 -0
  109. package/src/messages/prune.ts +39 -2
  110. package/src/run.ts +5 -0
  111. package/src/scripts/subagent-usage-sink.ts +176 -0
  112. package/src/specs/context-accuracy.live.test.ts +409 -0
  113. package/src/specs/context-usage-event.test.ts +117 -0
  114. package/src/specs/context-usage.live.test.ts +297 -0
  115. package/src/specs/prune.test.ts +51 -1
  116. package/src/specs/subagent.test.ts +124 -1
  117. package/src/summarization/__tests__/node.test.ts +60 -1
  118. package/src/summarization/node.ts +20 -1
  119. package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
  120. package/src/tools/search/format.test.ts +242 -0
  121. package/src/tools/search/format.ts +122 -5
  122. package/src/tools/search/tool.ts +5 -1
  123. package/src/tools/search/types.ts +7 -0
  124. package/src/tools/subagent/SubagentExecutor.ts +221 -3
  125. package/src/types/graph.ts +94 -1
  126. package/src/types/run.ts +13 -0
  127. package/src/utils/__tests__/apportion.test.ts +32 -0
  128. package/src/utils/tokens.ts +33 -0
@@ -429,6 +429,14 @@ function _formatContent(message: BaseMessage) {
429
429
  'web_search_result',
430
430
  ];
431
431
  const textTypes = ['text', 'text_delta'];
432
+ /**
433
+ * Reasoning blocks emitted by other providers — Bedrock's `reasoning_content`,
434
+ * Google's `reasoning`, and LibreChat's `think`. Their signatures are
435
+ * provider-specific and cannot be validated by Anthropic, so on a
436
+ * cross-provider handoff (e.g. Bedrock → Anthropic) we drop them rather than
437
+ * forwarding an unusable block. The receiving model produces its own thinking.
438
+ */
439
+ const foreignReasoningTypes = ['reasoning_content', 'reasoning', 'think'];
432
440
  const { content } = message;
433
441
 
434
442
  if (typeof content === 'string') {
@@ -568,6 +576,15 @@ function _formatContent(message: BaseMessage) {
568
576
  };
569
577
  } else if (contentPart.type === 'thinking') {
570
578
  const thinkingPart = contentPart as AnthropicThinkingBlockParam;
579
+ // Google thinking-enabled output reuses `type: 'thinking'` but carries
580
+ // no Anthropic signature. Anthropic rejects an unsigned thinking block,
581
+ // so on an assistant turn treat it as foreign reasoning and drop it
582
+ // rather than forward an unusable block. Signed (Anthropic-native)
583
+ // thinking is forwarded as before.
584
+ const signature = (thinkingPart as { signature?: string }).signature;
585
+ if (isAIMessage(message) && (signature == null || signature === '')) {
586
+ return null;
587
+ }
571
588
  const block: AnthropicThinkingBlockParam = {
572
589
  type: 'thinking' as const, // Explicitly setting the type as "thinking"
573
590
  thinking: thinkingPart.thinking,
@@ -651,7 +668,9 @@ function _formatContent(message: BaseMessage) {
651
668
  (contentPartCopy.input === '' || contentPartCopy.input == null)
652
669
  ) {
653
670
  const matchingToolCall = isAIMessage(message)
654
- ? message.tool_calls?.find((toolCall) => toolCall.id === contentPartCopy.id)
671
+ ? message.tool_calls?.find(
672
+ (toolCall) => toolCall.id === contentPartCopy.id
673
+ )
655
674
  : undefined;
656
675
  if (matchingToolCall) {
657
676
  contentPartCopy.input = matchingToolCall.args;
@@ -666,7 +685,10 @@ function _formatContent(message: BaseMessage) {
666
685
  typeof p.input === 'string'
667
686
  );
668
687
  })
669
- .reduce((acc, part) => acc + (part as Record<string, unknown>).input, '');
688
+ .reduce(
689
+ (acc, part) => acc + (part as Record<string, unknown>).input,
690
+ ''
691
+ );
670
692
  if (merged !== '') {
671
693
  contentPartCopy.input = merged;
672
694
  }
@@ -720,6 +742,18 @@ function _formatContent(message: BaseMessage) {
720
742
  name: correspondingToolCall.name,
721
743
  input: functionCallPart.functionCall.args,
722
744
  };
745
+ } else if (
746
+ isAIMessage(message) &&
747
+ foreignReasoningTypes.some((t) => t === contentPart.type)
748
+ ) {
749
+ // Foreign reasoning on an ASSISTANT turn (Bedrock `reasoning_content`,
750
+ // Google `reasoning`, LibreChat `think`) carries provider-specific
751
+ // signatures Anthropic cannot validate; drop it so a cross-provider
752
+ // handoff doesn't crash. The same types on a user/tool turn are real
753
+ // input and fall through to the throw below rather than being silently
754
+ // dropped — as does any other unknown block (user media, Google
755
+ // code-execution), which must be surfaced, not discarded.
756
+ return null;
723
757
  } else {
724
758
  console.error(
725
759
  'Unsupported content part:',
@@ -808,25 +842,53 @@ export function _convertMessagesToAnthropicPayload(
808
842
  };
809
843
  }
810
844
  } else {
811
- const { content } = message;
812
- const hasMismatchedToolCalls = !toolCalls.every(
813
- (toolCall) =>
814
- !!content.find(
815
- (contentPart) =>
816
- (contentPart.type === 'tool_use' ||
817
- contentPart.type === 'input_json_delta' ||
818
- contentPart.type === 'server_tool_use') &&
819
- contentPart.id === toolCall.id
845
+ const formattedContent = _formatContent(message);
846
+ const formattedBlocks = Array.isArray(formattedContent)
847
+ ? formattedContent
848
+ : [];
849
+ // Tool calls already materialized as content blocks by `_formatContent`.
850
+ // Derived from the FORMATTED output (not the raw content by type) so
851
+ // that Google `functionCall` parts — which `_formatContent` converts
852
+ // into `tool_use` — count as represented and are not appended twice.
853
+ const representedToolIds = new Set(
854
+ formattedBlocks
855
+ .filter(
856
+ (block) =>
857
+ block != null &&
858
+ (block.type === 'tool_use' || block.type === 'server_tool_use')
820
859
  )
860
+ .map((block) => (block as { id?: string }).id)
821
861
  );
822
- if (hasMismatchedToolCalls) {
823
- console.warn(
824
- 'The "tool_calls" field on a message is only respected if content is a string.'
825
- );
862
+ // Client tool calls present in `tool_calls` but absent from the
863
+ // formatted content — e.g. a Bedrock extended-thinking turn records the
864
+ // tool only on `tool_calls` and leaves `content` as just the reasoning
865
+ // block. Without materializing them, dropping that reasoning block
866
+ // silently loses the (handoff) tool call instead of forwarding it.
867
+ const unrepresentedToolCalls = toolCalls.filter(
868
+ (toolCall) =>
869
+ !(
870
+ toolCall.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) ??
871
+ false
872
+ ) && !representedToolIds.has(toolCall.id)
873
+ );
874
+ if (unrepresentedToolCalls.length === 0) {
875
+ return { role, content: formattedContent };
826
876
  }
877
+ const existingBlocks = formattedBlocks.filter(
878
+ (block) =>
879
+ !(
880
+ block != null &&
881
+ block.type === 'text' &&
882
+ 'text' in block &&
883
+ block.text === ANTHROPIC_EMPTY_TEXT_PLACEHOLDER
884
+ )
885
+ );
827
886
  return {
828
887
  role,
829
- content: _formatContent(message),
888
+ content: [
889
+ ...existingBlocks,
890
+ ...unrepresentedToolCalls.map(_convertLangChainToolCallToAnthropic),
891
+ ],
830
892
  };
831
893
  }
832
894
  } else {
@@ -0,0 +1,131 @@
1
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import { convertToConverseMessages } from './message_inputs';
4
+
5
+ /**
6
+ * Mirror of the Anthropic-side cross-provider reasoning fix, for the reverse
7
+ * handoff (Anthropic → Bedrock). An Anthropic extended-thinking turn leaves
8
+ * `thinking`/`redacted_thinking` blocks in history; the Bedrock Converse
9
+ * converter has no branch for them and previously threw
10
+ * "Unsupported content block type: thinking", crashing the handoff. Bedrock's
11
+ * native reasoning is `reasoning_content` (still converted); foreign reasoning
12
+ * (`thinking`/`redacted_thinking`/`reasoning`/`think`) is dropped on assistant
13
+ * turns, while any other unknown block still throws rather than being silently
14
+ * omitted.
15
+ */
16
+ type ConverseResult = ReturnType<typeof convertToConverseMessages>;
17
+
18
+ /** Minimal view of a converted Bedrock Converse content block the assertions read. */
19
+ interface ConverseBlock {
20
+ text?: string;
21
+ reasoningContent?: { reasoningText?: { text?: string; signature?: string } };
22
+ toolUse?: {
23
+ toolUseId?: string;
24
+ name?: string;
25
+ input?: Record<string, string>;
26
+ };
27
+ }
28
+
29
+ const assistantContent = (result: ConverseResult): ConverseBlock[] => {
30
+ const msg = result.converseMessages.find((m) => m.role === 'assistant');
31
+ return (msg?.content ?? []) as ConverseBlock[];
32
+ };
33
+
34
+ describe('convertToConverseMessages — cross-provider reasoning (Anthropic → Bedrock)', () => {
35
+ it('drops Anthropic thinking/redacted_thinking on an assistant turn, keeping text and tool calls', () => {
36
+ const messages: BaseMessage[] = [
37
+ new HumanMessage('research Assort Health'),
38
+ new AIMessage({
39
+ content: [
40
+ {
41
+ type: 'thinking',
42
+ thinking: 'Let me hand off to the data agent.',
43
+ signature: 'anthropic-signature-not-valid-for-bedrock',
44
+ },
45
+ { type: 'redacted_thinking', data: 'redacted-blob' },
46
+ { type: 'text', text: 'Handing off now.' },
47
+ ],
48
+ tool_calls: [
49
+ {
50
+ id: 'tooluse_transfer',
51
+ name: 'lc_transfer_to_data_agent',
52
+ args: { reason: 'need consumption data' },
53
+ type: 'tool_call',
54
+ },
55
+ ],
56
+ }),
57
+ ];
58
+
59
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
60
+ const content = assistantContent(convertToConverseMessages(messages));
61
+
62
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
63
+ expect(JSON.stringify(content)).not.toContain(
64
+ 'anthropic-signature-not-valid-for-bedrock'
65
+ );
66
+ expect(JSON.stringify(content)).not.toContain('redacted-blob');
67
+
68
+ expect(content.some((b) => b.text === 'Handing off now.')).toBe(true);
69
+ const toolUse = content.find((b) => b.toolUse != null);
70
+ expect(toolUse?.toolUse).toMatchObject({
71
+ toolUseId: 'tooluse_transfer',
72
+ name: 'lc_transfer_to_data_agent',
73
+ input: { reason: 'need consumption data' },
74
+ });
75
+ });
76
+
77
+ it('emits a placeholder (not empty content) when a reasoning-only turn is fully dropped', () => {
78
+ const messages: BaseMessage[] = [
79
+ new HumanMessage('hi'),
80
+ new AIMessage({
81
+ content: [
82
+ { type: 'thinking', thinking: 'only thinking, no other content' },
83
+ ],
84
+ }),
85
+ ];
86
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
87
+ const content = assistantContent(convertToConverseMessages(messages));
88
+ expect(content.length).toBeGreaterThan(0);
89
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
90
+ expect(content.every((b) => typeof b.text === 'string')).toBe(true);
91
+ });
92
+
93
+ it('still throws on a genuinely unknown assistant block', () => {
94
+ const messages: BaseMessage[] = [
95
+ new HumanMessage('run code'),
96
+ new AIMessage({
97
+ content: [
98
+ { type: 'some_future_block_type', foo: 'bar' },
99
+ { type: 'text', text: 'done' },
100
+ ],
101
+ }),
102
+ ];
103
+ expect(() => convertToConverseMessages(messages)).toThrow(
104
+ 'Unsupported content block type'
105
+ );
106
+ });
107
+
108
+ it('still converts Bedrock-native reasoning_content (not dropped)', () => {
109
+ const messages: BaseMessage[] = [
110
+ new HumanMessage('hi'),
111
+ new AIMessage({
112
+ content: [
113
+ {
114
+ type: 'reasoning_content',
115
+ reasoningText: {
116
+ text: 'native bedrock reasoning',
117
+ signature: 'sig',
118
+ },
119
+ },
120
+ { type: 'text', text: 'answer' },
121
+ ],
122
+ }),
123
+ ];
124
+ const content = assistantContent(convertToConverseMessages(messages));
125
+ const reasoning = content.find((b) => b.reasoningContent != null);
126
+ expect(reasoning).toBeDefined();
127
+ expect(reasoning?.reasoningContent?.reasoningText?.text).toBe(
128
+ 'native bedrock reasoning'
129
+ );
130
+ });
131
+ });
@@ -28,6 +28,26 @@ import type {
28
28
  MessageContentReasoningBlock,
29
29
  } from '../types';
30
30
 
31
+ /**
32
+ * Reasoning blocks from other providers, relative to Bedrock. Bedrock's native
33
+ * reasoning format is `reasoning_content`; these carry provider-specific
34
+ * signatures Bedrock cannot validate, so they are dropped on a cross-provider
35
+ * handoff (e.g. Anthropic → Bedrock) rather than crashing the conversion.
36
+ */
37
+ const FOREIGN_REASONING_TYPES = [
38
+ 'thinking',
39
+ 'redacted_thinking',
40
+ 'reasoning',
41
+ 'think',
42
+ ];
43
+
44
+ /**
45
+ * Bedrock Converse rejects assistant messages with no content blocks. When
46
+ * filtering (e.g. dropping foreign reasoning) empties an assistant turn that
47
+ * also has no tool calls, fall back to this placeholder text.
48
+ */
49
+ const BEDROCK_EMPTY_TEXT_PLACEHOLDER = '_';
50
+
31
51
  /**
32
52
  * Convert a LangChain reasoning block to a Bedrock reasoning block.
33
53
  */
@@ -644,6 +664,15 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
644
664
  type: 'default',
645
665
  },
646
666
  } as BedrockContentBlock);
667
+ } else if (FOREIGN_REASONING_TYPES.some((t) => t === block.type)) {
668
+ // Reasoning from another provider (Anthropic `thinking`/
669
+ // `redacted_thinking`, Google `reasoning`, LibreChat `think`). Bedrock's
670
+ // native reasoning is `reasoning_content` (handled above); a foreign
671
+ // block carries a signature Bedrock cannot validate, so drop it on a
672
+ // cross-provider handoff (e.g. Anthropic → Bedrock) rather than crash.
673
+ // The Bedrock model produces its own reasoning. Anything else unknown
674
+ // still throws below — real content must be surfaced, not dropped.
675
+ return;
647
676
  } else {
648
677
  const blockValues = Object.fromEntries(
649
678
  Object.entries(block).filter(([key]) => key !== 'type')
@@ -672,6 +701,12 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
672
701
  ] as BedrockContentBlock[];
673
702
  }
674
703
 
704
+ // Bedrock rejects an assistant message with no content blocks; if filtering
705
+ // (e.g. dropping foreign reasoning) left it empty, emit a placeholder.
706
+ if (assistantMsg.content == null || assistantMsg.content.length === 0) {
707
+ assistantMsg.content = [{ text: BEDROCK_EMPTY_TEXT_PLACEHOLDER }];
708
+ }
709
+
675
710
  return assistantMsg;
676
711
  }
677
712
 
@@ -12,8 +12,8 @@ import type { BaseMessage } from '@langchain/core/messages';
12
12
  import type * as t from '@/types';
13
13
  import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
14
14
  import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
15
+ import { Constants, Providers } from '@/common';
15
16
  import { ToolNode } from '@/tools/ToolNode';
16
- import { Providers } from '@/common';
17
17
 
18
18
  /**
19
19
  * Minimal stub model shape `attemptInvoke` reads. Either `invoke` or
@@ -341,6 +341,84 @@ describe('tryFallbackProviders applies the same lazy annotation transform', () =
341
341
  });
342
342
  });
343
343
 
344
+ describe('invocation attribution metadata', () => {
345
+ it('stamps INVOKED_PROVIDER on the config passed to the model', async () => {
346
+ const capturedConfigs: unknown[] = [];
347
+ const model: StubModel = {
348
+ invoke: jest.fn(
349
+ async (_m: BaseMessage[], config?: unknown): Promise<AIMessage> => {
350
+ capturedConfigs.push(config);
351
+ return new AIMessage({ content: 'ok' });
352
+ }
353
+ ),
354
+ };
355
+
356
+ await attemptInvoke(
357
+ {
358
+ model: model as t.ChatModel,
359
+ messages: [new HumanMessage('hi')],
360
+ /** A ChatOpenAI-derived provider — `ls_provider` would lie here. */
361
+ provider: Providers.DEEPSEEK,
362
+ },
363
+ { configurable: { run_id: 'run-attr' }, metadata: { existing: true } }
364
+ );
365
+
366
+ const config = capturedConfigs[0] as {
367
+ metadata?: Record<string, unknown>;
368
+ };
369
+ expect(config.metadata?.[Constants.INVOKED_PROVIDER]).toBe(
370
+ Providers.DEEPSEEK
371
+ );
372
+ /** Pre-existing metadata is preserved, not replaced. */
373
+ expect(config.metadata?.existing).toBe(true);
374
+ });
375
+
376
+ it('stamps INVOKED_MODEL from the fallback clientOptions in tryFallbackProviders', async () => {
377
+ const capturedConfigs: unknown[] = [];
378
+ const model: StubModel = {
379
+ invoke: jest.fn(
380
+ async (_m: BaseMessage[], config?: unknown): Promise<AIMessage> => {
381
+ capturedConfigs.push(config);
382
+ return new AIMessage({ content: 'ok' });
383
+ }
384
+ ),
385
+ };
386
+
387
+ jest.doMock('@/llm/init', () => ({
388
+ initializeModel: (): unknown => model,
389
+ }));
390
+ jest.resetModules();
391
+ const { tryFallbackProviders: freshTry } = (await import(
392
+ '@/llm/invoke'
393
+ )) as { tryFallbackProviders: typeof tryFallbackProviders };
394
+
395
+ await freshTry({
396
+ fallbacks: [
397
+ {
398
+ provider: Providers.ANTHROPIC,
399
+ clientOptions: { model: 'claude-fallback-1' },
400
+ },
401
+ ],
402
+ messages: [new HumanMessage('hi')],
403
+ primaryError: new Error('primary failed'),
404
+ config: { configurable: { run_id: 'run-attr-fb' } },
405
+ });
406
+
407
+ const config = capturedConfigs[0] as {
408
+ metadata?: Record<string, unknown>;
409
+ };
410
+ expect(config.metadata?.[Constants.INVOKED_MODEL]).toBe(
411
+ 'claude-fallback-1'
412
+ );
413
+ expect(config.metadata?.[Constants.INVOKED_PROVIDER]).toBe(
414
+ Providers.ANTHROPIC
415
+ );
416
+
417
+ jest.dontMock('@/llm/init');
418
+ jest.resetModules();
419
+ });
420
+ });
421
+
344
422
  describe('cross-run hydration through ToolNode + attemptInvoke', () => {
345
423
  it('annotates run 2 refs but leaves hydrated run 1 ToolMessages untouched', async () => {
346
424
  /**
package/src/llm/invoke.ts CHANGED
@@ -6,10 +6,10 @@ import type { BaseMessage } from '@langchain/core/messages';
6
6
  import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
7
7
  import type * as t from '@/types';
8
8
  import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
9
+ import { Constants, GraphEvents, Providers } from '@/common';
9
10
  import { manualToolStreamProviders } from '@/llm/providers';
10
11
  import { modifyDeltaProperties } from '@/messages';
11
12
  import { ChatModelStreamHandler } from '@/stream';
12
- import { GraphEvents, Providers } from '@/common';
13
13
  import { initializeModel } from '@/llm/init';
14
14
 
15
15
  /**
@@ -208,6 +208,23 @@ export async function attemptInvoke(
208
208
  const runId = config?.configurable?.run_id as string | undefined;
209
209
  const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);
210
210
 
211
+ /**
212
+ * Stamp the provider that is ACTUALLY serving this invocation onto the
213
+ * callback metadata. `attemptInvoke` is the single funnel for primary,
214
+ * fallback, and summarization model calls, so consumers that need
215
+ * provider attribution per call (the subagent usage-capture handler)
216
+ * read this key instead of trusting static agent config — which is
217
+ * wrong for fallback-served calls — or `ls_provider` — which derived
218
+ * providers inherit from their base class.
219
+ */
220
+ config = {
221
+ ...config,
222
+ metadata: {
223
+ ...(config?.metadata ?? {}),
224
+ [Constants.INVOKED_PROVIDER]: provider,
225
+ },
226
+ };
227
+
211
228
  if (model.stream) {
212
229
  const stream = await model.stream(messagesForProvider, config);
213
230
  let finalChunk: AIMessageChunk | undefined;
@@ -224,7 +241,7 @@ export async function attemptInvoke(
224
241
  });
225
242
  }
226
243
  } else if (registeredStreamHandler == null) {
227
- const metadata = config?.metadata as Record<string, unknown> | undefined;
244
+ const metadata = config.metadata as Record<string, unknown> | undefined;
228
245
  const streamHandler = new ChatModelStreamHandler();
229
246
  for await (const chunk of stream) {
230
247
  const handlingChunk = getStreamHandlingChunk({
@@ -247,7 +264,7 @@ export async function attemptInvoke(
247
264
  });
248
265
  }
249
266
  } else {
250
- const metadata = config?.metadata as Record<string, unknown> | undefined;
267
+ const metadata = config.metadata as Record<string, unknown> | undefined;
251
268
  for await (const chunk of stream) {
252
269
  const handlingChunk = getStreamHandlingChunk({
253
270
  current: finalChunk,
@@ -292,6 +309,25 @@ export async function attemptInvoke(
292
309
  return { messages: [finalMessage] };
293
310
  }
294
311
 
312
+ /**
313
+ * Best-effort read of the configured model name from client options.
314
+ * Providers disagree on the key (`model` vs `modelName`).
315
+ */
316
+ function extractClientOptionsModel(
317
+ clientOptions: t.ClientOptions | undefined
318
+ ): string | undefined {
319
+ const options = clientOptions as
320
+ | { model?: unknown; modelName?: unknown }
321
+ | undefined;
322
+ if (typeof options?.model === 'string' && options.model !== '') {
323
+ return options.model;
324
+ }
325
+ if (typeof options?.modelName === 'string' && options.modelName !== '') {
326
+ return options.modelName;
327
+ }
328
+ return undefined;
329
+ }
330
+
295
331
  /**
296
332
  * Attempts each fallback provider in order until one succeeds.
297
333
  * Throws the last error if all fallbacks fail.
@@ -321,6 +357,24 @@ export async function tryFallbackProviders({
321
357
  clientOptions: fb.clientOptions,
322
358
  tools,
323
359
  });
360
+ /**
361
+ * Stamp the fallback's configured model onto callback metadata so
362
+ * per-call attribution (subagent usage capture) doesn't fall back to
363
+ * the PRIMARY config's model when the provider reports no
364
+ * `ls_model_name`. The serving provider is stamped uniformly by
365
+ * `attemptInvoke` (`INVOKED_PROVIDER`).
366
+ */
367
+ const fbModelName = extractClientOptionsModel(fb.clientOptions);
368
+ const fbConfig: RunnableConfig | undefined =
369
+ fbModelName == null
370
+ ? config
371
+ : {
372
+ ...config,
373
+ metadata: {
374
+ ...(config?.metadata ?? {}),
375
+ [Constants.INVOKED_MODEL]: fbModelName,
376
+ },
377
+ };
324
378
  const result = await attemptInvoke(
325
379
  {
326
380
  model: fbModel as t.ChatModel,
@@ -329,7 +383,7 @@ export async function tryFallbackProviders({
329
383
  context,
330
384
  onChunk,
331
385
  },
332
- config
386
+ fbConfig
333
387
  );
334
388
  return result;
335
389
  } catch (e) {
@@ -0,0 +1,32 @@
1
+ import type * as t from '@/types';
2
+
3
+ /**
4
+ * Reconciles a context-usage breakdown's instruction/available/message fields
5
+ * from the pruner's budget metrics. `messageTokens` and `availableForMessages`
6
+ * are DERIVED from `contextBudget` / `effectiveInstructionTokens` /
7
+ * `remainingContextTokens` rather than summed from the index map — that map is
8
+ * keyed by pre-prune indices, so summing it over the kept context would missum.
9
+ * Shared by the live snapshot path (`Graph.createCallModel`) and the pre-send
10
+ * projection (`AgentContext.projectContextUsage`) so both yield identical numbers.
11
+ */
12
+ export function syncBudgetDerivedFields(usage: t.ContextUsageEvent): void {
13
+ const { breakdown, contextBudget, effectiveInstructionTokens } = usage;
14
+ if (effectiveInstructionTokens == null) {
15
+ return;
16
+ }
17
+ breakdown.instructionTokens = effectiveInstructionTokens;
18
+ if (contextBudget == null) {
19
+ return;
20
+ }
21
+ breakdown.availableForMessages = Math.max(
22
+ 0,
23
+ contextBudget - effectiveInstructionTokens
24
+ );
25
+ if (usage.remainingContextTokens == null) {
26
+ return;
27
+ }
28
+ breakdown.messageTokens = Math.max(
29
+ 0,
30
+ contextBudget - effectiveInstructionTokens - usage.remainingContextTokens
31
+ );
32
+ }
@@ -41,7 +41,7 @@ function deepCloneContent<T extends string | MessageContentComplex[]>(
41
41
  * in downstream code (e.g., ensureThinkingBlockInMessages).
42
42
  * For plain objects (AnthropicMessage), uses object spread.
43
43
  */
44
- function cloneMessage<T extends MessageWithContent>(
44
+ export function cloneMessage<T extends MessageWithContent>(
45
45
  message: T,
46
46
  content: string | MessageContentComplex[]
47
47
  ): T {
@@ -1,6 +1,26 @@
1
- import type { BaseMessage } from '@langchain/core/messages';
1
+ import type {
2
+ BaseMessage,
3
+ MessageContentComplex,
4
+ } from '@langchain/core/messages';
2
5
  import { ContentTypes } from '@/common';
3
6
 
7
+ /**
8
+ * Whether {@link formatContentStrings} will flatten this message's content:
9
+ * a human/ai/system message whose content is an array of text-only blocks.
10
+ */
11
+ export const isLegacyConvertible = (message: BaseMessage): boolean => {
12
+ const messageType = message.getType();
13
+ const isValidMessage =
14
+ messageType === 'human' || messageType === 'ai' || messageType === 'system';
15
+ if (!isValidMessage) {
16
+ return false;
17
+ }
18
+ if (!Array.isArray(message.content)) {
19
+ return false;
20
+ }
21
+ return message.content.every((block) => block.type === ContentTypes.TEXT);
22
+ };
23
+
4
24
  /**
5
25
  * Formats an array of messages for LangChain, making sure all content fields are strings
6
26
  * @param {Array<HumanMessage | AIMessage | SystemMessage | ToolMessage>} payload - The array of messages to format.
@@ -13,42 +33,14 @@ export const formatContentStrings = (
13
33
  const result: Array<BaseMessage> = [];
14
34
 
15
35
  for (const message of payload) {
16
- const messageType = message.getType();
17
- const isValidMessage =
18
- messageType === 'human' ||
19
- messageType === 'ai' ||
20
- messageType === 'system';
21
-
22
- if (!isValidMessage) {
23
- result.push(message);
24
- continue;
25
- }
26
-
27
- // If content is already a string, add as-is
28
- if (typeof message.content === 'string') {
29
- result.push(message);
30
- continue;
31
- }
32
-
33
- // If content is not an array, add as-is
34
- if (!Array.isArray(message.content)) {
35
- result.push(message);
36
- continue;
37
- }
38
-
39
- // Check if all content blocks are text type
40
- const allTextBlocks = message.content.every(
41
- (block) => block.type === ContentTypes.TEXT
42
- );
43
-
44
- // Only convert to string if all blocks are text type
45
- if (!allTextBlocks) {
36
+ if (!isLegacyConvertible(message)) {
46
37
  result.push(message);
47
38
  continue;
48
39
  }
49
40
 
50
41
  // Reduce text types to a single string
51
- const content = message.content.reduce((acc, curr) => {
42
+ const blocks = message.content as MessageContentComplex[];
43
+ const content = blocks.reduce((acc, curr) => {
52
44
  if (curr.type === ContentTypes.TEXT) {
53
45
  return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
54
46
  }
@@ -1,6 +1,7 @@
1
1
  export * from './core';
2
2
  export * from './ids';
3
3
  export * from './prune';
4
+ export * from './budget';
4
5
  export * from './format';
5
6
  export * from './cache';
6
7
  export * from './anthropicToolCache';