@librechat/agents 3.3.3 → 3.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/agents/AgentContext.cjs +2 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- 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 +793 -111
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
- package/dist/cjs/graphs/MultiAgentGraph.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/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/anthropic/utils/message_inputs.cjs +79 -6
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +38 -13
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +19 -7
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +331 -5
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +41 -5
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +25 -4
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +4 -1
- package/dist/cjs/llm/openrouter/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 +36 -5
- package/dist/cjs/messages/alternation.cjs +92 -0
- package/dist/cjs/messages/alternation.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +54 -33
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/contextPruning.cjs +17 -43
- package/dist/cjs/messages/contextPruning.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +315 -23
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +290 -60
- 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 +4 -1
- package/dist/cjs/messages/injected.cjs +60 -0
- package/dist/cjs/messages/injected.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +503 -127
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +80 -7
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +35 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +28 -14
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +56 -63
- package/dist/cjs/tools/ToolNode.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/tool.cjs +9 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +1 -1
- package/dist/cjs/utils/llm.cjs +1 -1
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +307 -20
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/toolContent.cjs +1514 -0
- package/dist/cjs/utils/toolContent.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +2 -1
- package/dist/esm/agents/AgentContext.mjs.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 +798 -116
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
- package/dist/esm/graphs/MultiAgentGraph.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/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/anthropic/utils/message_inputs.mjs +79 -6
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +38 -13
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +19 -7
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +332 -8
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +41 -5
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +25 -4
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +4 -1
- package/dist/esm/llm/openrouter/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 +14 -11
- package/dist/esm/messages/alternation.mjs +91 -0
- package/dist/esm/messages/alternation.mjs.map +1 -0
- package/dist/esm/messages/cache.mjs +55 -34
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/contextPruning.mjs +17 -43
- package/dist/esm/messages/contextPruning.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +305 -24
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +290 -61
- 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 +4 -1
- package/dist/esm/messages/injected.mjs +60 -0
- package/dist/esm/messages/injected.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +500 -128
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +80 -7
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +34 -11
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +28 -14
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +56 -63
- package/dist/esm/tools/ToolNode.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/tool.mjs +9 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +6 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +1 -1
- package/dist/esm/utils/llm.mjs +1 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +307 -21
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/toolContent.mjs +1503 -0
- package/dist/esm/utils/toolContent.mjs.map +1 -0
- package/dist/types/common/constants.d.ts +18 -0
- package/dist/types/graphs/Graph.d.ts +139 -2
- 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 +1 -1
- package/dist/types/llm/invoke.d.ts +34 -1
- package/dist/types/llm/openai/index.d.ts +4 -0
- package/dist/types/llm/openai/utils/index.d.ts +2 -0
- package/dist/types/llm/openrouter/index.d.ts +5 -0
- package/dist/types/llm/preempt.d.ts +40 -0
- package/dist/types/messages/alternation.d.ts +23 -0
- package/dist/types/messages/contextPruning.d.ts +2 -1
- package/dist/types/messages/core.d.ts +36 -0
- package/dist/types/messages/format.d.ts +6 -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/messages/prune.d.ts +16 -12
- package/dist/types/run.d.ts +7 -0
- package/dist/types/stream.d.ts +14 -0
- package/dist/types/tools/ToolNode.d.ts +0 -7
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/types.d.ts +31 -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 +1 -26
- package/dist/types/utils/tokens.d.ts +7 -0
- package/dist/types/utils/toolContent.d.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
- package/src/agents/AgentContext.ts +1 -0
- 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 +1261 -198
- package/src/graphs/MultiAgentGraph.ts +6 -1
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1139 -4
- 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 +1 -1
- package/src/langfuse.ts +26 -1
- package/src/llm/anthropic/utils/message_inputs.ts +158 -12
- package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +131 -1
- package/src/llm/bedrock/utils/message_inputs.ts +100 -24
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
- package/src/llm/google/utils/common.test.ts +75 -9
- package/src/llm/google/utils/common.ts +34 -3
- package/src/llm/invoke.alternation.test.ts +87 -0
- package/src/llm/invoke.handoffCue.test.ts +112 -0
- package/src/llm/invoke.test.ts +488 -0
- package/src/llm/invoke.ts +611 -8
- package/src/llm/openai/index.ts +106 -6
- package/src/llm/openai/utils/index.ts +109 -53
- package/src/llm/openai/utils/messages.test.ts +330 -1
- package/src/llm/openrouter/index.ts +12 -2
- package/src/llm/openrouter/reasoning.test.ts +314 -0
- package/src/llm/preempt.test.ts +323 -0
- package/src/llm/preempt.ts +178 -0
- package/src/messages/__tests__/observationMasking.test.ts +93 -2
- package/src/messages/alternation.test.ts +212 -0
- package/src/messages/alternation.ts +112 -0
- package/src/messages/cache.tail.test.ts +193 -0
- package/src/messages/cache.test.ts +113 -0
- package/src/messages/cache.ts +92 -60
- package/src/messages/contextPruning.test.ts +184 -0
- package/src/messages/contextPruning.ts +49 -42
- package/src/messages/core.ts +653 -40
- package/src/messages/ensureThinkingBlock.test.ts +49 -1
- package/src/messages/foldToollessToolBlocks.test.ts +163 -5
- package/src/messages/format.ts +454 -91
- package/src/messages/formatAgentMessages.steer.test.ts +267 -0
- package/src/messages/formatAgentMessages.test.ts +604 -14
- package/src/messages/formatAgentMessages.tools.test.ts +103 -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/messages/prune.ts +996 -183
- 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/specs/prune.test.ts +1083 -6
- package/src/specs/summarization-unit.test.ts +105 -0
- package/src/specs/tokens.test.ts +609 -32
- package/src/stream.dispatch.test.ts +63 -0
- package/src/stream.ts +59 -23
- package/src/summarization/__tests__/node.test.ts +77 -0
- package/src/summarization/node.ts +46 -18
- package/src/tools/ToolNode.ts +150 -99
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
- package/src/tools/__tests__/directToolHooks.test.ts +255 -7
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/tool.ts +13 -2
- package/src/tools/search/types.ts +50 -3
- package/src/tools/toolOutputReferences.ts +6 -0
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +1 -42
- package/src/utils/llm.test.ts +18 -0
- package/src/utils/llm.ts +4 -1
- package/src/utils/tokens.ts +586 -31
- package/src/utils/toolContent.test.ts +1432 -0
- package/src/utils/toolContent.ts +2707 -0
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
2
|
import type { TPayload } from '@/types';
|
|
3
|
+
import { HARD_MAX_TOOL_RESULT_CHARS } from '@/utils/truncation';
|
|
3
4
|
import { formatAgentMessages } from './format';
|
|
4
5
|
import { ContentTypes } from '@/common';
|
|
5
6
|
|
|
@@ -38,6 +39,108 @@ describe('formatAgentMessages with tools parameter', () => {
|
|
|
38
39
|
expect((result.messages[2] as ToolMessage).tool_call_id).toBe('123');
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
it('restores persisted structured tool output for harness accounting', () => {
|
|
43
|
+
const output = [
|
|
44
|
+
{
|
|
45
|
+
type: ContentTypes.TEXT,
|
|
46
|
+
text: JSON.stringify([
|
|
47
|
+
{ id: 1, value: 'first' },
|
|
48
|
+
{ id: 2, value: 'second' },
|
|
49
|
+
]),
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
const payload: TPayload = [
|
|
53
|
+
{ role: 'user', content: 'Query the table' },
|
|
54
|
+
{
|
|
55
|
+
role: 'assistant',
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: ContentTypes.TOOL_CALL,
|
|
59
|
+
tool_call: {
|
|
60
|
+
id: 'query-1',
|
|
61
|
+
name: 'run_select_query',
|
|
62
|
+
args: '{}',
|
|
63
|
+
output,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const result = formatAgentMessages(payload, { 0: 5, 1: 0 });
|
|
71
|
+
|
|
72
|
+
const toolMessage = result.messages[2] as ToolMessage;
|
|
73
|
+
expect(toolMessage).toBeInstanceOf(ToolMessage);
|
|
74
|
+
expect(toolMessage.content).toEqual(output);
|
|
75
|
+
expect(toolMessage.tool_call_id).toBe('query-1');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('normalizes opaque persisted tool output before provider dispatch', () => {
|
|
79
|
+
const output = [{ type: 'json', rows: [{ id: 1, value: 'first' }] }];
|
|
80
|
+
const payload: TPayload = [
|
|
81
|
+
{ role: 'user', content: 'Query the table' },
|
|
82
|
+
{
|
|
83
|
+
role: 'assistant',
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: ContentTypes.TOOL_CALL,
|
|
87
|
+
tool_call: {
|
|
88
|
+
id: 'query-opaque',
|
|
89
|
+
name: 'run_select_query',
|
|
90
|
+
args: '{}',
|
|
91
|
+
output,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const result = formatAgentMessages(payload);
|
|
99
|
+
const toolMessage = result.messages[2] as ToolMessage;
|
|
100
|
+
|
|
101
|
+
expect(typeof toolMessage.content).toBe('string');
|
|
102
|
+
expect(toolMessage.content).toBe(JSON.stringify(output));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('hard-caps persisted structured output without invoking toJSON', () => {
|
|
106
|
+
let toJSONCalls = 0;
|
|
107
|
+
const output = {
|
|
108
|
+
rows: [{ value: 'x'.repeat(HARD_MAX_TOOL_RESULT_CHARS + 1_000) }],
|
|
109
|
+
toJSON() {
|
|
110
|
+
toJSONCalls++;
|
|
111
|
+
return 'y'.repeat(HARD_MAX_TOOL_RESULT_CHARS * 2);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const payload: TPayload = [
|
|
115
|
+
{ role: 'user', content: 'Query the table' },
|
|
116
|
+
{
|
|
117
|
+
role: 'assistant',
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: ContentTypes.TOOL_CALL,
|
|
121
|
+
tool_call: {
|
|
122
|
+
id: 'query-bounded',
|
|
123
|
+
name: 'run_select_query',
|
|
124
|
+
args: '{}',
|
|
125
|
+
output,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const result = formatAgentMessages(payload);
|
|
133
|
+
const content = (result.messages[2] as ToolMessage).content;
|
|
134
|
+
|
|
135
|
+
expect(toJSONCalls).toBe(0);
|
|
136
|
+
expect(typeof content).toBe('string');
|
|
137
|
+
expect((content as string).length).toBeLessThanOrEqual(
|
|
138
|
+
HARD_MAX_TOOL_RESULT_CHARS
|
|
139
|
+
);
|
|
140
|
+
expect(content).toContain('truncated');
|
|
141
|
+
expect(content).not.toContain('y'.repeat(1_000));
|
|
142
|
+
});
|
|
143
|
+
|
|
41
144
|
it('should filter out all tool calls when tools set is empty', () => {
|
|
42
145
|
const payload: TPayload = [
|
|
43
146
|
{ role: 'user', content: 'What\'s the weather?' },
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import {
|
|
4
|
+
appendPredecessorHandoffCue,
|
|
5
|
+
removePredecessorHandoffCue,
|
|
6
|
+
PREDECESSOR_HANDOFF_CUE,
|
|
7
|
+
} from './handoffCue';
|
|
8
|
+
|
|
9
|
+
const runAi = new AIMessage({ content: 'predecessor output', id: 'run-ai-1' });
|
|
10
|
+
const producedIds = new Set(['run-ai-1']);
|
|
11
|
+
const isRunProduced = (message: BaseMessage): boolean =>
|
|
12
|
+
message.id != null && producedIds.has(message.id);
|
|
13
|
+
|
|
14
|
+
describe('appendPredecessorHandoffCue', () => {
|
|
15
|
+
it('appends the cue when the payload ends with a run-produced assistant turn', () => {
|
|
16
|
+
const payload = [new HumanMessage('ask'), runAi];
|
|
17
|
+
const result = appendPredecessorHandoffCue(payload, isRunProduced);
|
|
18
|
+
expect(result).toHaveLength(3);
|
|
19
|
+
expect(result[2].content).toBe(PREDECESSOR_HANDOFF_CUE);
|
|
20
|
+
expect(result[2].additional_kwargs).toEqual({
|
|
21
|
+
role: 'user',
|
|
22
|
+
isMeta: true,
|
|
23
|
+
source: 'handoff',
|
|
24
|
+
});
|
|
25
|
+
/** Non-mutating: the input array is untouched. */
|
|
26
|
+
expect(payload).toHaveLength(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('matches a transformed clone of the run tail by id', () => {
|
|
30
|
+
const clone = new AIMessage({ content: 'projected copy', id: 'run-ai-1' });
|
|
31
|
+
expect(appendPredecessorHandoffCue([clone], isRunProduced)).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Host-supplied trailing assistant turns are deliberate prefill (continue
|
|
36
|
+
* generation flows). The run has not produced them, so provenance never
|
|
37
|
+
* matches and the cue must stay off.
|
|
38
|
+
*/
|
|
39
|
+
it('leaves host-history prefill payloads alone', () => {
|
|
40
|
+
const hostAi = new AIMessage({ content: 'host prefill', id: 'host-ai-9' });
|
|
41
|
+
expect(appendPredecessorHandoffCue([hostAi], isRunProduced)).toHaveLength(
|
|
42
|
+
1
|
|
43
|
+
);
|
|
44
|
+
expect(appendPredecessorHandoffCue([hostAi], undefined)).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is off when the payload does not end on an assistant turn', () => {
|
|
48
|
+
const tool = new ToolMessage({ content: 'r', tool_call_id: 't1' });
|
|
49
|
+
expect(
|
|
50
|
+
appendPredecessorHandoffCue([runAi, tool], isRunProduced)
|
|
51
|
+
).toHaveLength(2);
|
|
52
|
+
expect(
|
|
53
|
+
appendPredecessorHandoffCue([new HumanMessage('hi')], isRunProduced)
|
|
54
|
+
).toHaveLength(1);
|
|
55
|
+
expect(appendPredecessorHandoffCue([], isRunProduced)).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('fails safe when the tail carries no id', () => {
|
|
59
|
+
const noId = new AIMessage({ content: 'no id' });
|
|
60
|
+
expect(appendPredecessorHandoffCue([noId], isRunProduced)).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('removePredecessorHandoffCue', () => {
|
|
65
|
+
const cue = new HumanMessage({
|
|
66
|
+
content: PREDECESSOR_HANDOFF_CUE,
|
|
67
|
+
additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('strips a trailing cue and only a trailing cue', () => {
|
|
71
|
+
const stripped = removePredecessorHandoffCue([runAi, cue]);
|
|
72
|
+
expect(stripped).toHaveLength(1);
|
|
73
|
+
expect(stripped[0]).toBe(runAi);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('is identity when no cue trails', () => {
|
|
77
|
+
const messages = [runAi];
|
|
78
|
+
expect(removePredecessorHandoffCue(messages)).toBe(messages);
|
|
79
|
+
const userTail = [runAi, new HumanMessage('real user turn')];
|
|
80
|
+
expect(removePredecessorHandoffCue(userTail)).toBe(userTail);
|
|
81
|
+
const empty: BaseMessage[] = [];
|
|
82
|
+
expect(removePredecessorHandoffCue(empty)).toBe(empty);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not strip a user turn that merely repeats the cue text', () => {
|
|
86
|
+
const impostor = new HumanMessage({ content: PREDECESSOR_HANDOFF_CUE });
|
|
87
|
+
const messages = [runAi, impostor];
|
|
88
|
+
expect(removePredecessorHandoffCue(messages)).toBe(messages);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('round-trips with append', () => {
|
|
92
|
+
const payload = [new HumanMessage('ask'), runAi];
|
|
93
|
+
const appended = appendPredecessorHandoffCue(payload, isRunProduced);
|
|
94
|
+
expect(removePredecessorHandoffCue(appended)).toEqual(payload);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/messages/handoffCue.ts
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bracketed-meta convention, like the handoff path's
|
|
7
|
+
* `[Processed tool result and transferring to …]` bridge. The wording makes
|
|
8
|
+
* two things unambiguous to the model: the assistant turn above is FINISHED,
|
|
9
|
+
* and it belongs to a previous stage — so the successor answers as itself
|
|
10
|
+
* instead of continuing someone else's sentence.
|
|
11
|
+
*/
|
|
12
|
+
export const PREDECESSOR_HANDOFF_CUE =
|
|
13
|
+
'[The assistant message above is the completed output of a previous ' +
|
|
14
|
+
'agent. Respond now according to your own role and instructions.]';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Appends a user-turn handoff cue when a payload ends with an assistant turn
|
|
18
|
+
* that THIS RUN produced — which only happens when a different agent in a
|
|
19
|
+
* multi-agent workflow produced it (an agent's own self-loops always re-enter
|
|
20
|
+
* on a tool result or an injected user turn).
|
|
21
|
+
*
|
|
22
|
+
* Why: providers with prefill semantics (Anthropic, Bedrock-Claude) treat a
|
|
23
|
+
* trailing assistant message as a prefill and CONTINUE it. A bare direct-edge
|
|
24
|
+
* successor therefore speaks in its predecessor's voice — or, when the
|
|
25
|
+
* trailing turn reads complete (a preemption steer's short resume, say),
|
|
26
|
+
* returns empty content (danny-avila/agents#345, reproduced live 3/3).
|
|
27
|
+
* Handoff edges with instructions and prompt-instruction edges already break
|
|
28
|
+
* the prefill with a user turn; this closes the same gap for bare edges.
|
|
29
|
+
*
|
|
30
|
+
* Fail-safe OFF by provenance: the trailing payload message must be one the
|
|
31
|
+
* run itself produced (`isRunProduced`, backed by the graph's run-produced id
|
|
32
|
+
* set — immune to summarization compaction, which rewrites the live array
|
|
33
|
+
* and stales index-based boundaries). Host-supplied trailing assistant
|
|
34
|
+
* turns (deliberate prefill flows) never match — the run has not produced
|
|
35
|
+
* them — so single-agent prefill behavior is untouched. Wire-only: the cue is
|
|
36
|
+
* appended to the provider projection, never to graph state or host history.
|
|
37
|
+
*/
|
|
38
|
+
export function appendPredecessorHandoffCue(
|
|
39
|
+
messages: BaseMessage[],
|
|
40
|
+
isRunProduced: ((message: BaseMessage) => boolean) | undefined
|
|
41
|
+
): BaseMessage[] {
|
|
42
|
+
const last = messages.at(-1);
|
|
43
|
+
if (last == null || last.getType() !== 'ai') {
|
|
44
|
+
return messages;
|
|
45
|
+
}
|
|
46
|
+
if (isRunProduced == null || !isRunProduced(last)) {
|
|
47
|
+
return messages;
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
...messages,
|
|
51
|
+
new HumanMessage({
|
|
52
|
+
content: PREDECESSOR_HANDOFF_CUE,
|
|
53
|
+
additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
|
|
54
|
+
}),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strips a trailing handoff cue. The counterpart for the serving-provider
|
|
60
|
+
* funnel: an Anthropic-like PRIMARY bakes the cue into its measured payload,
|
|
61
|
+
* and a tolerant fallback (OpenAI, Mistral, Bedrock-Nova) re-sending that
|
|
62
|
+
* payload must not ship the Claude-only synthetic turn. Identity on the
|
|
63
|
+
* no-op path.
|
|
64
|
+
*/
|
|
65
|
+
export function removePredecessorHandoffCue(
|
|
66
|
+
messages: BaseMessage[]
|
|
67
|
+
): BaseMessage[] {
|
|
68
|
+
const last = messages.at(-1);
|
|
69
|
+
if (
|
|
70
|
+
last == null ||
|
|
71
|
+
last.getType() !== 'human' ||
|
|
72
|
+
last.additional_kwargs.source !== 'handoff' ||
|
|
73
|
+
last.content !== PREDECESSOR_HANDOFF_CUE
|
|
74
|
+
) {
|
|
75
|
+
return messages;
|
|
76
|
+
}
|
|
77
|
+
return messages.slice(0, -1);
|
|
78
|
+
}
|
package/src/messages/index.ts
CHANGED
|
@@ -7,6 +7,9 @@ export * from './cache';
|
|
|
7
7
|
export * from './anthropicToolCache';
|
|
8
8
|
export * from './content';
|
|
9
9
|
export * from './tools';
|
|
10
|
+
export * from './injected';
|
|
11
|
+
export * from './alternation';
|
|
12
|
+
export * from './handoffCue';
|
|
10
13
|
export * from './contextPruning';
|
|
11
14
|
export * from './contextPruningSettings';
|
|
12
15
|
export * from './reducer';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { InjectedMessage } from '@/types/tools';
|
|
3
|
+
import { convertInjectedMessages } from './injected';
|
|
4
|
+
|
|
5
|
+
describe('convertInjectedMessages', () => {
|
|
6
|
+
it('returns an empty array for no entries', () => {
|
|
7
|
+
expect(convertInjectedMessages([])).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('emits one HumanMessage per entry, in order', () => {
|
|
11
|
+
const messages: InjectedMessage[] = [
|
|
12
|
+
{ role: 'user', content: 'first' },
|
|
13
|
+
{ role: 'user', content: 'second' },
|
|
14
|
+
];
|
|
15
|
+
const converted = convertInjectedMessages(messages);
|
|
16
|
+
expect(converted).toHaveLength(2);
|
|
17
|
+
expect(converted[0]).toBeInstanceOf(HumanMessage);
|
|
18
|
+
expect(converted[0].content).toBe('first');
|
|
19
|
+
expect(converted[1].content).toBe('second');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Both 'user' and 'system' become `HumanMessage` — Anthropic and Google
|
|
24
|
+
* reject a non-leading SystemMessage — with the original role preserved for
|
|
25
|
+
* downstream consumers.
|
|
26
|
+
*/
|
|
27
|
+
it('converts a system role to a HumanMessage carrying the original role', () => {
|
|
28
|
+
const [converted] = convertInjectedMessages([
|
|
29
|
+
{ role: 'system', content: 'convention reminder' },
|
|
30
|
+
]);
|
|
31
|
+
expect(converted).toBeInstanceOf(HumanMessage);
|
|
32
|
+
expect(converted.additional_kwargs.role).toBe('system');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('carries isMeta, source and skillName only when set', () => {
|
|
36
|
+
const [bare] = convertInjectedMessages([{ role: 'user', content: 'x' }]);
|
|
37
|
+
expect(bare.additional_kwargs).toEqual({ role: 'user' });
|
|
38
|
+
|
|
39
|
+
const [full] = convertInjectedMessages([
|
|
40
|
+
{
|
|
41
|
+
role: 'user',
|
|
42
|
+
content: 'x',
|
|
43
|
+
isMeta: true,
|
|
44
|
+
source: 'steer',
|
|
45
|
+
skillName: 'writing',
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
expect(full.additional_kwargs).toEqual({
|
|
49
|
+
role: 'user',
|
|
50
|
+
isMeta: true,
|
|
51
|
+
source: 'steer',
|
|
52
|
+
skillName: 'writing',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('passes multimodal content through as a content array', () => {
|
|
57
|
+
const [converted] = convertInjectedMessages([
|
|
58
|
+
{
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: [
|
|
61
|
+
{ type: 'text', text: 'look at this' },
|
|
62
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA' } },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
expect(Array.isArray(converted.content)).toBe(true);
|
|
67
|
+
expect(converted.content).toHaveLength(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The provider-safety argument for sealing a stream mid-generation rests on
|
|
72
|
+
* the preempt boundary emitting exactly what the already-shipped tool
|
|
73
|
+
* boundary emits. Both call this function, so shape parity is structural —
|
|
74
|
+
* this pins it against a future divergence.
|
|
75
|
+
*/
|
|
76
|
+
it('produces identical shapes for the same entry across calls', () => {
|
|
77
|
+
const entry: InjectedMessage = {
|
|
78
|
+
role: 'user',
|
|
79
|
+
content: 'Skip phase two.',
|
|
80
|
+
source: 'steer',
|
|
81
|
+
};
|
|
82
|
+
const [atToolBoundary] = convertInjectedMessages([entry]);
|
|
83
|
+
const [atPreemptBoundary] = convertInjectedMessages([entry]);
|
|
84
|
+
expect(atPreemptBoundary.content).toEqual(atToolBoundary.content);
|
|
85
|
+
expect(atPreemptBoundary.additional_kwargs).toEqual(
|
|
86
|
+
atToolBoundary.additional_kwargs
|
|
87
|
+
);
|
|
88
|
+
expect(atPreemptBoundary.getType()).toBe(atToolBoundary.getType());
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// src/messages/injected.ts
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
|
+
import type { InjectedMessage } from '@/types/tools';
|
|
5
|
+
import { ContentTypes } from '@/common';
|
|
6
|
+
import { toLangChainContent } from './langchain';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Converts `InjectedMessage` instances to LangChain `HumanMessage` objects.
|
|
10
|
+
* Both 'user' and 'system' roles become `HumanMessage` to avoid provider
|
|
11
|
+
* rejections (Anthropic/Google reject non-leading SystemMessages). The
|
|
12
|
+
* original role is preserved in `additional_kwargs` for downstream consumers.
|
|
13
|
+
*
|
|
14
|
+
* Shared by both injection boundaries — `ToolNode`'s tool-batch dispatch and
|
|
15
|
+
* `StandardGraph`'s preempt-boundary dispatch. Keeping one implementation is
|
|
16
|
+
* load-bearing rather than tidy: the provider-safety argument for sealing a
|
|
17
|
+
* stream mid-generation rests on the injected turn having byte-identical
|
|
18
|
+
* shape to the one the already-shipped tool boundary emits, so the two sites
|
|
19
|
+
* must not be able to drift.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* True when an entry carries nothing a provider will accept as a turn.
|
|
23
|
+
*
|
|
24
|
+
* The public `InjectedMessage` type admits `content: ''` and `content: []`,
|
|
25
|
+
* and a host hook is free to return one. Converting it anyway produces a
|
|
26
|
+
* `HumanMessage` that passes the caller's `length > 0` test, so the graph
|
|
27
|
+
* resumes and sends a trailing EMPTY user turn — which Anthropic and other
|
|
28
|
+
* strict providers reject outright, turning a cooperative seal into a failed
|
|
29
|
+
* run. Permissive providers merely burn a model call.
|
|
30
|
+
*
|
|
31
|
+
* Whitespace counts as empty, matching the standard `canSealPreempt` applies
|
|
32
|
+
* to the assistant side of the same pair. Non-text blocks count as content: a
|
|
33
|
+
* media-only steer is a real turn and must survive.
|
|
34
|
+
*/
|
|
35
|
+
function isEmptyInjectedContent(content: InjectedMessage['content']): boolean {
|
|
36
|
+
if (typeof content === 'string') {
|
|
37
|
+
return content.trim() === '';
|
|
38
|
+
}
|
|
39
|
+
if (content.length === 0) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return content.every((block) => {
|
|
43
|
+
if (block.type !== ContentTypes.TEXT) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const text = block[ContentTypes.TEXT];
|
|
47
|
+
return typeof text !== 'string' || text.trim() === '';
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function convertInjectedMessages(
|
|
52
|
+
messages: InjectedMessage[]
|
|
53
|
+
): BaseMessage[] {
|
|
54
|
+
const converted: BaseMessage[] = [];
|
|
55
|
+
for (const msg of messages) {
|
|
56
|
+
if (isEmptyInjectedContent(msg.content)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const additional_kwargs: Record<string, unknown> = {
|
|
60
|
+
role: msg.role,
|
|
61
|
+
};
|
|
62
|
+
if (msg.isMeta != null) additional_kwargs.isMeta = msg.isMeta;
|
|
63
|
+
if (msg.source != null) additional_kwargs.source = msg.source;
|
|
64
|
+
if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
|
|
65
|
+
|
|
66
|
+
converted.push(
|
|
67
|
+
new HumanMessage({
|
|
68
|
+
content: toLangChainContent(msg.content),
|
|
69
|
+
additional_kwargs,
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return converted;
|
|
74
|
+
}
|