@librechat/agents 3.2.60 → 3.2.62
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/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +32 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +6 -0
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +62 -1
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +266 -43
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +274 -7
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
- package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
- package/dist/cjs/llm/openrouter/index.cjs +5 -6
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +6 -1
- package/dist/cjs/messages/format.cjs +61 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +31 -19
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/stream.cjs +13 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +75 -10
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +111 -0
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +32 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/executeHooks.mjs +6 -0
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +62 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +266 -43
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +274 -7
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
- package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
- package/dist/esm/llm/openrouter/index.mjs +5 -6
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/format.mjs +61 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +31 -19
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/stream.mjs +13 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +75 -10
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +108 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +3 -1
- package/dist/types/hooks/HookRegistry.d.ts +10 -0
- package/dist/types/hooks/index.d.ts +7 -0
- package/dist/types/hooks/types.d.ts +18 -1
- package/dist/types/llm/bedrock/index.d.ts +5 -0
- package/dist/types/llm/openai/index.d.ts +19 -0
- package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
- package/dist/types/messages/prune.d.ts +4 -3
- package/dist/types/tools/ToolNode.d.ts +1 -0
- package/dist/types/types/llm.d.ts +12 -2
- package/dist/types/types/tools.d.ts +1 -1
- package/dist/types/utils/tokens.d.ts +30 -0
- package/package.json +9 -9
- package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
- package/src/common/enum.ts +2 -0
- package/src/hooks/HookRegistry.ts +45 -0
- package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
- package/src/hooks/__tests__/executeHooks.test.ts +85 -2
- package/src/hooks/executeHooks.ts +15 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/types.ts +18 -1
- package/src/langfuse.ts +134 -1
- package/src/llm/bedrock/index.ts +434 -83
- package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
- package/src/llm/custom-chat-models.smoke.test.ts +7 -0
- package/src/llm/openai/index.ts +604 -6
- package/src/llm/openai/managedRequests.test.ts +182 -0
- package/src/llm/openai/streamMetadata.spec.ts +86 -0
- package/src/llm/openai/streamMetadata.ts +95 -0
- package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
- package/src/llm/openrouter/index.ts +9 -5
- package/src/messages/format.ts +96 -3
- package/src/messages/formatAgentMessages.steer.test.ts +326 -0
- package/src/messages/labelContentByAgent.test.ts +75 -0
- package/src/messages/prune.ts +56 -30
- package/src/specs/anthropic.simple.test.ts +4 -2
- package/src/specs/cache.simple.test.ts +17 -4
- package/src/specs/langfuse-callbacks.test.ts +61 -0
- package/src/specs/openai.simple.test.ts +4 -2
- package/src/specs/spec.utils.ts +12 -0
- package/src/specs/summarization.test.ts +9 -13
- package/src/specs/token-accounting-pipeline.test.ts +130 -4
- package/src/specs/tokens.test.ts +214 -0
- package/src/stream.ts +22 -3
- package/src/tools/ToolNode.ts +107 -12
- package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
- package/src/tools/__tests__/hitl.test.ts +112 -0
- package/src/tools/subagent/SubagentExecutor.ts +2 -0
- package/src/types/llm.ts +12 -2
- package/src/types/tools.ts +1 -1
- package/src/utils/tokens.ts +181 -3
package/src/messages/format.ts
CHANGED
|
@@ -466,9 +466,15 @@ function hasToolCallOutput(part: MessageContentComplex): boolean {
|
|
|
466
466
|
function formatAssistantMessage(
|
|
467
467
|
message: Partial<TMessage>,
|
|
468
468
|
options?: FormatAssistantMessageOptions
|
|
469
|
-
): Array<
|
|
469
|
+
): Array<
|
|
470
|
+
| RoleBearingMessage<AIMessage>
|
|
471
|
+
| RoleBearingMessage<ToolMessage>
|
|
472
|
+
| RoleBearingMessage<HumanMessage>
|
|
473
|
+
> {
|
|
470
474
|
const formattedMessages: Array<
|
|
471
|
-
|
|
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 ??
|
|
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[] = [
|
package/src/messages/prune.ts
CHANGED
|
@@ -135,9 +135,10 @@ export type PruneMessagesParams = {
|
|
|
135
135
|
usageMetadata?: Partial<UsageMetadata>;
|
|
136
136
|
startType?: ReturnType<BaseMessage['getType']>;
|
|
137
137
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
1440
|
-
|
|
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 (
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
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[];
|