@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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import { Providers } from '@/common';
|
|
3
|
+
import {
|
|
4
|
+
coalesceAdjacentUserTurns,
|
|
5
|
+
strictAlternationProviders,
|
|
6
|
+
} from './alternation';
|
|
7
|
+
|
|
8
|
+
describe('strictAlternationProviders', () => {
|
|
9
|
+
it('covers the providers that reject consecutive user turns', () => {
|
|
10
|
+
expect(strictAlternationProviders.has(Providers.BEDROCK)).toBe(true);
|
|
11
|
+
expect(strictAlternationProviders.has(Providers.MISTRAL)).toBe(true);
|
|
12
|
+
expect(strictAlternationProviders.has(Providers.MISTRALAI)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('leaves providers that accept them alone', () => {
|
|
16
|
+
expect(strictAlternationProviders.has(Providers.ANTHROPIC)).toBe(false);
|
|
17
|
+
expect(strictAlternationProviders.has(Providers.OPENAI)).toBe(false);
|
|
18
|
+
expect(strictAlternationProviders.has(Providers.GOOGLE)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('coalesceAdjacentUserTurns', () => {
|
|
23
|
+
it('leaves an already-alternating run untouched, returning the SAME array', () => {
|
|
24
|
+
const messages = [
|
|
25
|
+
new HumanMessage({ content: 'question' }),
|
|
26
|
+
new AIMessage({ content: 'answer' }),
|
|
27
|
+
new HumanMessage({ content: 'follow-up' }),
|
|
28
|
+
];
|
|
29
|
+
const result = coalesceAdjacentUserTurns(messages);
|
|
30
|
+
/**
|
|
31
|
+
* Identity, not just equality: the pass runs twice for a primary
|
|
32
|
+
* Bedrock/Mistral call (createCallModel, then the attemptInvoke funnel),
|
|
33
|
+
* so the normalized second pass must not reallocate a context-sized
|
|
34
|
+
* array — and origin tracking early-exits on `before === after`.
|
|
35
|
+
*/
|
|
36
|
+
expect(result).toBe(messages);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is idempotent by identity: re-coalescing merged output is a no-op', () => {
|
|
40
|
+
const once = coalesceAdjacentUserTurns([
|
|
41
|
+
new HumanMessage({ content: 'steer 1' }),
|
|
42
|
+
new HumanMessage({ content: 'steer 2' }),
|
|
43
|
+
]);
|
|
44
|
+
expect(once).toHaveLength(1);
|
|
45
|
+
expect(coalesceAdjacentUserTurns(once)).toBe(once);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** A boundary that drains two steers, which is the ordinary queue case. */
|
|
49
|
+
it('merges a run of consecutive human turns', () => {
|
|
50
|
+
const result = coalesceAdjacentUserTurns([
|
|
51
|
+
new HumanMessage({ content: 'question' }),
|
|
52
|
+
new AIMessage({ content: 'partial' }),
|
|
53
|
+
new HumanMessage({ content: 'steer 1' }),
|
|
54
|
+
new HumanMessage({ content: 'steer 2' }),
|
|
55
|
+
]);
|
|
56
|
+
expect(result.map((m) => m.getType())).toEqual(['human', 'ai', 'human']);
|
|
57
|
+
expect(result[2].content).toBe('steer 1\n\nsteer 2');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('merges context plus injected messages into one turn', () => {
|
|
61
|
+
const result = coalesceAdjacentUserTurns([
|
|
62
|
+
new AIMessage({ content: 'partial' }),
|
|
63
|
+
new HumanMessage({
|
|
64
|
+
content: 'hook context',
|
|
65
|
+
additional_kwargs: { role: 'system', source: 'hook' },
|
|
66
|
+
}),
|
|
67
|
+
new HumanMessage({
|
|
68
|
+
content: 'steer',
|
|
69
|
+
additional_kwargs: { role: 'user', source: 'steer' },
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
expect(result).toHaveLength(2);
|
|
73
|
+
expect(result[1].content).toBe('hook context\n\nsteer');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('concatenates block content rather than stringifying it', () => {
|
|
77
|
+
const result = coalesceAdjacentUserTurns([
|
|
78
|
+
new HumanMessage({ content: [{ type: 'text', text: 'look' }] }),
|
|
79
|
+
new HumanMessage({
|
|
80
|
+
content: [{ type: 'image_url', image_url: { url: 'data:,' } }],
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
expect(result).toHaveLength(1);
|
|
84
|
+
expect(result[0].content).toEqual([
|
|
85
|
+
{ type: 'text', text: 'look' },
|
|
86
|
+
{ type: 'image_url', image_url: { url: 'data:,' } },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('normalizes a mixed string and block pair', () => {
|
|
91
|
+
const result = coalesceAdjacentUserTurns([
|
|
92
|
+
new HumanMessage({ content: 'plain' }),
|
|
93
|
+
new HumanMessage({ content: [{ type: 'text', text: 'blocks' }] }),
|
|
94
|
+
]);
|
|
95
|
+
expect(result).toHaveLength(1);
|
|
96
|
+
expect(result[0].content).toEqual([
|
|
97
|
+
{ type: 'text', text: 'plain' },
|
|
98
|
+
{ type: 'text', text: 'blocks' },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Both vendored converters merge adjacent tool-result runs themselves, and
|
|
104
|
+
* folding one into a text turn would orphan the pairing.
|
|
105
|
+
*/
|
|
106
|
+
it('never merges tool-result turns', () => {
|
|
107
|
+
const result = coalesceAdjacentUserTurns([
|
|
108
|
+
new HumanMessage({
|
|
109
|
+
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }],
|
|
110
|
+
}),
|
|
111
|
+
new HumanMessage({
|
|
112
|
+
content: [{ type: 'tool_result', tool_use_id: 't2', content: 'ok' }],
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
expect(result).toHaveLength(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('never merges a tool-result turn into a text turn', () => {
|
|
119
|
+
const result = coalesceAdjacentUserTurns([
|
|
120
|
+
new HumanMessage({
|
|
121
|
+
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }],
|
|
122
|
+
}),
|
|
123
|
+
new HumanMessage({ content: 'steer' }),
|
|
124
|
+
]);
|
|
125
|
+
expect(result).toHaveLength(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The prompt-cache tail anchor reasons positionally — it inserts the
|
|
130
|
+
* breakpoint after the merged message's LAST text block — so the last
|
|
131
|
+
* part's provenance flags must survive the merge. A skill body absorbed
|
|
132
|
+
* into a real user turn stays anchorable; a real turn absorbed into a
|
|
133
|
+
* trailing skill body must not let the anchor pin the volatile body.
|
|
134
|
+
*/
|
|
135
|
+
it('keeps the last turn\'s additional_kwargs and the first turn\'s id', () => {
|
|
136
|
+
const result = coalesceAdjacentUserTurns([
|
|
137
|
+
new HumanMessage({
|
|
138
|
+
content: 'skill body',
|
|
139
|
+
id: 'first-id',
|
|
140
|
+
additional_kwargs: { isMeta: true, source: 'skill' },
|
|
141
|
+
}),
|
|
142
|
+
new HumanMessage({
|
|
143
|
+
content: 'real user turn',
|
|
144
|
+
id: 'second-id',
|
|
145
|
+
additional_kwargs: { role: 'user' },
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
expect(result).toHaveLength(1);
|
|
149
|
+
expect(result[0].additional_kwargs).toEqual({ role: 'user' });
|
|
150
|
+
expect(result[0].id).toBe('first-id');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('marks the merge meta when the trailing part is the volatile one', () => {
|
|
154
|
+
const result = coalesceAdjacentUserTurns([
|
|
155
|
+
new HumanMessage({
|
|
156
|
+
content: 'real steer',
|
|
157
|
+
additional_kwargs: { source: 'steer' },
|
|
158
|
+
}),
|
|
159
|
+
new HumanMessage({
|
|
160
|
+
content: 'skill body',
|
|
161
|
+
additional_kwargs: { isMeta: true, source: 'skill', skillName: 'x' },
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
expect(result).toHaveLength(1);
|
|
165
|
+
expect(result[0].additional_kwargs.source).toBe('skill');
|
|
166
|
+
expect(result[0].additional_kwargs.isMeta).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Only ALL-tool-result turns are excluded. A mixed turn is an ordinary
|
|
171
|
+
* user turn that happens to carry a result block — merging preserves block
|
|
172
|
+
* order, so the pairing survives, and excluding it would leave adjacent
|
|
173
|
+
* user turns on the wire for exactly the shape Bedrock rejects.
|
|
174
|
+
*/
|
|
175
|
+
it('merges a mixed text plus tool-result turn', () => {
|
|
176
|
+
const result = coalesceAdjacentUserTurns([
|
|
177
|
+
new HumanMessage({
|
|
178
|
+
content: [
|
|
179
|
+
{ type: 'tool_result', tool_use_id: 't1', content: 'ok' },
|
|
180
|
+
{ type: 'text', text: 'and my comment' },
|
|
181
|
+
],
|
|
182
|
+
}),
|
|
183
|
+
new HumanMessage({ content: 'next turn' }),
|
|
184
|
+
]);
|
|
185
|
+
expect(result).toHaveLength(1);
|
|
186
|
+
expect(result[0].content).toEqual([
|
|
187
|
+
{ type: 'tool_result', tool_use_id: 't1', content: 'ok' },
|
|
188
|
+
{ type: 'text', text: 'and my comment' },
|
|
189
|
+
{ type: 'text', text: 'next turn' },
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('excludes the camelCase toolResult variant too', () => {
|
|
194
|
+
const result = coalesceAdjacentUserTurns([
|
|
195
|
+
new HumanMessage({
|
|
196
|
+
content: [{ type: 'toolResult', toolResult: { content: 'ok' } }],
|
|
197
|
+
}),
|
|
198
|
+
new HumanMessage({ content: 'steer' }),
|
|
199
|
+
]);
|
|
200
|
+
expect(result).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('does not mutate the input array or its messages', () => {
|
|
204
|
+
const first = new HumanMessage({ content: 'a' });
|
|
205
|
+
const second = new HumanMessage({ content: 'b' });
|
|
206
|
+
const messages = [first, second];
|
|
207
|
+
coalesceAdjacentUserTurns(messages);
|
|
208
|
+
expect(messages).toHaveLength(2);
|
|
209
|
+
expect(first.content).toBe('a');
|
|
210
|
+
expect(second.content).toBe('b');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/messages/alternation.ts
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { BaseMessage, MessageContent } from '@langchain/core/messages';
|
|
4
|
+
import { Providers } from '@/common';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Providers whose APIs specify strict user/assistant alternation. Mistral
|
|
8
|
+
* rejects consecutive user turns outright. Bedrock's Converse API documents
|
|
9
|
+
* the alternation requirement across many model families; enforcement varies
|
|
10
|
+
* by family — Claude on Converse currently tolerates adjacent user turns
|
|
11
|
+
* (verified live, 2026-07-28) — so the payload is normalized for all of them
|
|
12
|
+
* rather than betting on per-family leniency. Anthropic's own Messages API,
|
|
13
|
+
* OpenAI and Gemini all accept consecutive user turns, so they are
|
|
14
|
+
* deliberately absent.
|
|
15
|
+
*/
|
|
16
|
+
export const strictAlternationProviders: ReadonlySet<Providers> = new Set([
|
|
17
|
+
Providers.BEDROCK,
|
|
18
|
+
Providers.MISTRAL,
|
|
19
|
+
Providers.MISTRALAI,
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const TOOL_RESULT_TYPES = new Set(['tool_result', 'toolResult']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True when every block is a tool result. Both vendored converters already
|
|
26
|
+
* merge adjacent runs of these, and folding one into a text turn would break
|
|
27
|
+
* the tool pairing they depend on — so they are left alone here.
|
|
28
|
+
*/
|
|
29
|
+
function isToolResultMessage(message: BaseMessage): boolean {
|
|
30
|
+
const { content } = message;
|
|
31
|
+
if (typeof content === 'string' || content.length === 0) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return content.every(
|
|
35
|
+
(block) =>
|
|
36
|
+
typeof block.type === 'string' && TOOL_RESULT_TYPES.has(block.type)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toBlocks(content: MessageContent): Exclude<MessageContent, string> {
|
|
41
|
+
if (typeof content === 'string') {
|
|
42
|
+
return content === '' ? [] : [{ type: 'text', text: content }];
|
|
43
|
+
}
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function joinContent(
|
|
48
|
+
left: MessageContent,
|
|
49
|
+
right: MessageContent
|
|
50
|
+
): MessageContent {
|
|
51
|
+
if (typeof left === 'string' && typeof right === 'string') {
|
|
52
|
+
return left === '' ? right : `${left}\n\n${right}`;
|
|
53
|
+
}
|
|
54
|
+
return [...toBlocks(left), ...toBlocks(right)];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Merges runs of consecutive human turns into one, for providers that reject
|
|
59
|
+
* them. Purely a wire-shaping pass: it returns a new array of new messages,
|
|
60
|
+
* so graph state and the host's persisted messages keep the per-message
|
|
61
|
+
* identity that steer rendering and the trailing-steer anchor rely on.
|
|
62
|
+
*
|
|
63
|
+
* Tool-result turns are excluded — the converters merge those themselves, and
|
|
64
|
+
* combining one with a text turn would orphan the pairing.
|
|
65
|
+
*/
|
|
66
|
+
export function coalesceAdjacentUserTurns(
|
|
67
|
+
messages: BaseMessage[]
|
|
68
|
+
): BaseMessage[] {
|
|
69
|
+
const result: BaseMessage[] = [];
|
|
70
|
+
let mergedAny = false;
|
|
71
|
+
for (const message of messages) {
|
|
72
|
+
const previous = result[result.length - 1];
|
|
73
|
+
const mergeable =
|
|
74
|
+
result.length > 0 &&
|
|
75
|
+
previous.getType() === 'human' &&
|
|
76
|
+
message.getType() === 'human' &&
|
|
77
|
+
!isToolResultMessage(previous) &&
|
|
78
|
+
!isToolResultMessage(message);
|
|
79
|
+
|
|
80
|
+
if (!mergeable) {
|
|
81
|
+
result.push(message);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
mergedAny = true;
|
|
85
|
+
|
|
86
|
+
result[result.length - 1] = new HumanMessage({
|
|
87
|
+
content: joinContent(previous.content, message.content),
|
|
88
|
+
/**
|
|
89
|
+
* The LATER turn's kwargs, deliberately. The one provider-path consumer
|
|
90
|
+
* of these flags is the prompt-cache tail anchor, and it reasons
|
|
91
|
+
* positionally: `isSyntheticMetaMessage` decides whether a breakpoint
|
|
92
|
+
* may be inserted after the message's LAST text block. Merging keeps
|
|
93
|
+
* the last block's provenance only if the last part's kwargs survive —
|
|
94
|
+
* a skill body absorbed into a real user turn must stay anchorable
|
|
95
|
+
* (the real turn ends it), while a real steer absorbed into a trailing
|
|
96
|
+
* skill body must not pin the cache to the volatile body. The first
|
|
97
|
+
* turn's id is kept so origin tracking can re-attach by key.
|
|
98
|
+
*/
|
|
99
|
+
additional_kwargs: message.additional_kwargs,
|
|
100
|
+
...(previous.id != null && { id: previous.id }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Identity on the no-merge path. The pass runs twice for a primary
|
|
105
|
+
* Bedrock/Mistral call — once in `createCallModel` (must precede the cache
|
|
106
|
+
* breakpoint) and once in the `attemptInvoke` funnel (must cover fallback
|
|
107
|
+
* and summarization sends) — so the already-normalized second pass returns
|
|
108
|
+
* the SAME array rather than reallocating a context-sized copy, and
|
|
109
|
+
* callers can cheaply detect "nothing changed" by identity.
|
|
110
|
+
*/
|
|
111
|
+
return mergedAny ? result : messages;
|
|
112
|
+
}
|
package/src/messages/format.ts
CHANGED
|
@@ -475,6 +475,25 @@ function formatToolCallOutput(
|
|
|
475
475
|
return compactToolContent(output, HARD_MAX_TOOL_RESULT_CHARS).content;
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Content for the synthetic assistant turn that separates a trailing steer
|
|
480
|
+
* from the next user turn. Non-empty by necessity — see the push site.
|
|
481
|
+
*/
|
|
482
|
+
const STEER_ANCHOR_PLACEHOLDER = '_';
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* True when an assistant message replayed as a steer and nothing followed it,
|
|
486
|
+
* so the emitted run ends on the steer's `HumanMessage`.
|
|
487
|
+
*/
|
|
488
|
+
function endsWithSteerMessage(
|
|
489
|
+
formatted: Array<RoleBearingMessage<BaseMessage>>
|
|
490
|
+
): boolean {
|
|
491
|
+
if (formatted.length === 0) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
return formatted[formatted.length - 1].additional_kwargs.source === 'steer';
|
|
495
|
+
}
|
|
496
|
+
|
|
478
497
|
/**
|
|
479
498
|
* Helper function to format an assistant message
|
|
480
499
|
* @param message The message to format
|
|
@@ -1336,6 +1355,36 @@ export const formatAgentMessages = (
|
|
|
1336
1355
|
| RoleBearingMessage<SystemMessage>
|
|
1337
1356
|
| RoleBearingMessage<ToolMessage>
|
|
1338
1357
|
> = [];
|
|
1358
|
+
/**
|
|
1359
|
+
* A steer ended the previous payload entry, so the next message emitted —
|
|
1360
|
+
* whichever entry finally produces one — must be separated from it by an
|
|
1361
|
+
* assistant turn. Held rather than emitted so an entry that produces
|
|
1362
|
+
* nothing cannot leave the anchor stranded as the final turn.
|
|
1363
|
+
*/
|
|
1364
|
+
let pendingSteerAnchor = false;
|
|
1365
|
+
/**
|
|
1366
|
+
* Emits the deferred anchor ahead of `next` — the message about to be
|
|
1367
|
+
* pushed. When that message is itself an assistant turn, it already IS the
|
|
1368
|
+
* separation the anchor exists to synthesize, so the intent is simply
|
|
1369
|
+
* discharged: emitting the placeholder anyway would put two assistant turns
|
|
1370
|
+
* back to back, which strict-alternation providers can reject and nothing downstream
|
|
1371
|
+
* repairs (`coalesceAdjacentUserTurns` merges user turns only).
|
|
1372
|
+
*/
|
|
1373
|
+
const flushSteerAnchor = (next: { role?: LangChainMessageRole }): void => {
|
|
1374
|
+
if (!pendingSteerAnchor) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
pendingSteerAnchor = false;
|
|
1378
|
+
if (next.role === 'assistant') {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
messages.push(
|
|
1382
|
+
withMessageRole(
|
|
1383
|
+
new AIMessage({ content: STEER_ANCHOR_PLACEHOLDER }),
|
|
1384
|
+
'assistant'
|
|
1385
|
+
)
|
|
1386
|
+
);
|
|
1387
|
+
};
|
|
1339
1388
|
// If indexTokenCountMap is provided, create a new map to track the updated indices
|
|
1340
1389
|
const updatedIndexTokenCountMap: Record<number, number> = {};
|
|
1341
1390
|
let boundaryTokenAdjustment:
|
|
@@ -1397,6 +1446,7 @@ export const formatAgentMessages = (
|
|
|
1397
1446
|
if (sourceMessageId != null && sourceMessageId !== '') {
|
|
1398
1447
|
formattedMessage.id = sourceMessageId;
|
|
1399
1448
|
}
|
|
1449
|
+
flushSteerAnchor(formattedMessage);
|
|
1400
1450
|
messages.push(formattedMessage);
|
|
1401
1451
|
|
|
1402
1452
|
// Update the index mapping for this message
|
|
@@ -1580,7 +1630,48 @@ export const formatAgentMessages = (
|
|
|
1580
1630
|
formattedMessage.id = sourceMessageId;
|
|
1581
1631
|
}
|
|
1582
1632
|
}
|
|
1633
|
+
/**
|
|
1634
|
+
* A steer that ends an assistant message leaves the replay on a
|
|
1635
|
+
* `HumanMessage`. The next payload message is itself a user turn, so the
|
|
1636
|
+
* sequence would reach the provider as two adjacent user turns — rejected
|
|
1637
|
+
* by strict-alternation providers. Anchor it with a placeholder assistant
|
|
1638
|
+
* turn.
|
|
1639
|
+
*
|
|
1640
|
+
* The placeholder must be NON-EMPTY. A string-content assistant message
|
|
1641
|
+
* with no tool calls passes through `_convertMessagesToAnthropicPayload`
|
|
1642
|
+
* verbatim — the empty-text repair there only covers array content and
|
|
1643
|
+
* tool-call turns — so an empty anchor would reach Anthropic as
|
|
1644
|
+
* `{role: 'assistant', content: ''}` and trade one invalid sequence for
|
|
1645
|
+
* another. Same single-underscore convention the Anthropic converter
|
|
1646
|
+
* already uses when it has to synthesize a non-empty block.
|
|
1647
|
+
*
|
|
1648
|
+
* Deferred rather than decided by lookahead. `i < payload.length - 1` only
|
|
1649
|
+
* proves a later ENTRY exists, not that it EMITS: entries with empty
|
|
1650
|
+
* content, and entries dropped by `applySummaryBoundary`, are skipped
|
|
1651
|
+
* silently. A trailing steer followed only by those would get the anchor
|
|
1652
|
+
* as the FINAL turn — an assistant prefill with no request after it, which
|
|
1653
|
+
* the model may simply never answer. So the intent is recorded and flushed
|
|
1654
|
+
* only when a message actually follows.
|
|
1655
|
+
*
|
|
1656
|
+
* Pushed AFTER the id stamping above, deliberately. `messagesStateReducer`
|
|
1657
|
+
* treats a repeated id as replace-in-place, so an anchor carrying the
|
|
1658
|
+
* shared `sourceMessageId` would overwrite the steer it exists to protect.
|
|
1659
|
+
* Left unstamped, it reaches the reducer with a null id and is assigned a
|
|
1660
|
+
* fresh one. `endsWithSteerMessage` reads only `additional_kwargs.source`,
|
|
1661
|
+
* so the deferral cannot change which messages get anchored.
|
|
1662
|
+
*/
|
|
1663
|
+
/**
|
|
1664
|
+
* Guarded on emission: an assistant entry whose blocks all filtered away
|
|
1665
|
+
* emits nothing, and flushing for it would strand the anchor as the final
|
|
1666
|
+
* turn — the pending flag stays set for whichever entry emits next.
|
|
1667
|
+
*/
|
|
1668
|
+
if (formattedMessages.length > 0) {
|
|
1669
|
+
flushSteerAnchor(formattedMessages[0]);
|
|
1670
|
+
}
|
|
1583
1671
|
messages.push(...formattedMessages);
|
|
1672
|
+
if (endsWithSteerMessage(formattedMessages)) {
|
|
1673
|
+
pendingSteerAnchor = true;
|
|
1674
|
+
}
|
|
1584
1675
|
|
|
1585
1676
|
// Capture index range BEFORE skill body injection so injected
|
|
1586
1677
|
// HumanMessages are excluded from the assistant's token distribution.
|