@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.
- package/dist/cjs/agents/AgentContext.cjs +119 -9
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/agents/projection.cjs +25 -0
- package/dist/cjs/agents/projection.cjs.map +1 -0
- package/dist/cjs/common/enum.cjs +13 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +106 -3
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +26 -4
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +20 -0
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +49 -8
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/main.cjs +7 -0
- package/dist/cjs/messages/budget.cjs +23 -0
- package/dist/cjs/messages/budget.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +1 -0
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/content.cjs +12 -14
- package/dist/cjs/messages/content.cjs.map +1 -1
- package/dist/cjs/messages/index.cjs +1 -0
- package/dist/cjs/messages/prune.cjs +31 -13
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +7 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +12 -1
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/search/format.cjs +91 -2
- package/dist/cjs/tools/search/format.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +4 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +30 -0
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +121 -11
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/agents/projection.mjs +25 -0
- package/dist/esm/agents/projection.mjs.map +1 -0
- package/dist/esm/common/enum.mjs +13 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +107 -4
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +26 -4
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +20 -0
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +49 -8
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/main.mjs +6 -4
- package/dist/esm/messages/budget.mjs +23 -0
- package/dist/esm/messages/budget.mjs.map +1 -0
- package/dist/esm/messages/cache.mjs +1 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/content.mjs +12 -15
- package/dist/esm/messages/content.mjs.map +1 -1
- package/dist/esm/messages/index.mjs +1 -0
- package/dist/esm/messages/prune.mjs +31 -13
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +7 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +12 -1
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/search/format.mjs +91 -2
- package/dist/esm/tools/search/format.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +4 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +138 -2
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +30 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +37 -4
- package/dist/types/agents/projection.d.ts +26 -0
- package/dist/types/common/enum.d.ts +13 -0
- package/dist/types/graphs/Graph.d.ts +8 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/llm/invoke.d.ts +1 -1
- package/dist/types/messages/budget.d.ts +11 -0
- package/dist/types/messages/cache.d.ts +7 -0
- package/dist/types/messages/content.d.ts +5 -0
- package/dist/types/messages/index.d.ts +1 -0
- package/dist/types/messages/prune.d.ts +4 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/search/format.d.ts +4 -1
- package/dist/types/tools/search/types.d.ts +7 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
- package/dist/types/types/graph.d.ts +89 -3
- package/dist/types/types/run.d.ts +13 -0
- package/dist/types/utils/tokens.d.ts +7 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +172 -8
- package/src/agents/__tests__/AgentContext.test.ts +235 -2
- package/src/agents/__tests__/projection.test.ts +73 -0
- package/src/agents/projection.ts +46 -0
- package/src/common/enum.ts +13 -0
- package/src/graphs/Graph.ts +168 -0
- package/src/index.ts +3 -0
- package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
- package/src/llm/anthropic/utils/message_inputs.ts +78 -16
- package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
- package/src/llm/bedrock/utils/message_inputs.ts +35 -0
- package/src/llm/invoke.test.ts +79 -1
- package/src/llm/invoke.ts +58 -4
- package/src/messages/budget.ts +32 -0
- package/src/messages/cache.ts +1 -1
- package/src/messages/content.ts +24 -32
- package/src/messages/index.ts +1 -0
- package/src/messages/prune.ts +39 -2
- package/src/run.ts +5 -0
- package/src/scripts/subagent-usage-sink.ts +176 -0
- package/src/specs/context-accuracy.live.test.ts +409 -0
- package/src/specs/context-usage-event.test.ts +117 -0
- package/src/specs/context-usage.live.test.ts +297 -0
- package/src/specs/prune.test.ts +51 -1
- package/src/specs/subagent.test.ts +124 -1
- package/src/summarization/__tests__/node.test.ts +60 -1
- package/src/summarization/node.ts +20 -1
- package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
- package/src/tools/search/format.test.ts +242 -0
- package/src/tools/search/format.ts +122 -5
- package/src/tools/search/tool.ts +5 -1
- package/src/tools/search/types.ts +7 -0
- package/src/tools/subagent/SubagentExecutor.ts +221 -3
- package/src/types/graph.ts +94 -1
- package/src/types/run.ts +13 -0
- package/src/utils/__tests__/apportion.test.ts +32 -0
- 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(
|
|
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(
|
|
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
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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:
|
|
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
|
|
package/src/llm/invoke.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/messages/cache.ts
CHANGED
|
@@ -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 {
|
package/src/messages/content.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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
|
|
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
|
}
|