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