@librechat/agents 3.1.57 → 3.1.61
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 +326 -62
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/enum.cjs +13 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/events.cjs +7 -27
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +303 -222
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +4 -4
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +6 -2
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +60 -0
- package/dist/cjs/llm/init.cjs.map +1 -0
- package/dist/cjs/llm/invoke.cjs +90 -0
- package/dist/cjs/llm/invoke.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +2 -0
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/request.cjs +41 -0
- package/dist/cjs/llm/request.cjs.map +1 -0
- package/dist/cjs/main.cjs +40 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/cache.cjs +76 -89
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/contextPruning.cjs +156 -0
- package/dist/cjs/messages/contextPruning.cjs.map +1 -0
- package/dist/cjs/messages/contextPruningSettings.cjs +53 -0
- package/dist/cjs/messages/contextPruningSettings.cjs.map +1 -0
- package/dist/cjs/messages/core.cjs +23 -37
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +156 -11
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +1161 -49
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/reducer.cjs +87 -0
- package/dist/cjs/messages/reducer.cjs.map +1 -0
- package/dist/cjs/run.cjs +81 -42
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +54 -7
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/index.cjs +75 -0
- package/dist/cjs/summarization/index.cjs.map +1 -0
- package/dist/cjs/summarization/node.cjs +663 -0
- package/dist/cjs/summarization/node.cjs.map +1 -0
- package/dist/cjs/tools/ToolNode.cjs +16 -8
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +2 -0
- package/dist/cjs/tools/handlers.cjs.map +1 -1
- package/dist/cjs/utils/errors.cjs +115 -0
- package/dist/cjs/utils/errors.cjs.map +1 -0
- package/dist/cjs/utils/events.cjs +17 -0
- package/dist/cjs/utils/events.cjs.map +1 -1
- package/dist/cjs/utils/handlers.cjs +16 -0
- package/dist/cjs/utils/handlers.cjs.map +1 -1
- package/dist/cjs/utils/llm.cjs +10 -0
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +247 -14
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/truncation.cjs +107 -0
- package/dist/cjs/utils/truncation.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +325 -61
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/enum.mjs +13 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/events.mjs +8 -28
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +307 -226
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +4 -4
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +6 -2
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +58 -0
- package/dist/esm/llm/init.mjs.map +1 -0
- package/dist/esm/llm/invoke.mjs +87 -0
- package/dist/esm/llm/invoke.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +2 -0
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/request.mjs +38 -0
- package/dist/esm/llm/request.mjs.map +1 -0
- package/dist/esm/main.mjs +13 -3
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/cache.mjs +76 -89
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/contextPruning.mjs +154 -0
- package/dist/esm/messages/contextPruning.mjs.map +1 -0
- package/dist/esm/messages/contextPruningSettings.mjs +50 -0
- package/dist/esm/messages/contextPruningSettings.mjs.map +1 -0
- package/dist/esm/messages/core.mjs +23 -37
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +156 -11
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +1158 -52
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/reducer.mjs +83 -0
- package/dist/esm/messages/reducer.mjs.map +1 -0
- package/dist/esm/run.mjs +82 -43
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +54 -7
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/index.mjs +73 -0
- package/dist/esm/summarization/index.mjs.map +1 -0
- package/dist/esm/summarization/node.mjs +659 -0
- package/dist/esm/summarization/node.mjs.map +1 -0
- package/dist/esm/tools/ToolNode.mjs +16 -8
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +2 -0
- package/dist/esm/tools/handlers.mjs.map +1 -1
- package/dist/esm/utils/errors.mjs +111 -0
- package/dist/esm/utils/errors.mjs.map +1 -0
- package/dist/esm/utils/events.mjs +17 -1
- package/dist/esm/utils/events.mjs.map +1 -1
- package/dist/esm/utils/handlers.mjs +16 -0
- package/dist/esm/utils/handlers.mjs.map +1 -1
- package/dist/esm/utils/llm.mjs +10 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +245 -15
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/truncation.mjs +102 -0
- package/dist/esm/utils/truncation.mjs.map +1 -0
- package/dist/types/agents/AgentContext.d.ts +124 -6
- package/dist/types/common/enum.d.ts +14 -1
- package/dist/types/graphs/Graph.d.ts +22 -27
- package/dist/types/index.d.ts +5 -0
- package/dist/types/llm/init.d.ts +18 -0
- package/dist/types/llm/invoke.d.ts +48 -0
- package/dist/types/llm/request.d.ts +14 -0
- package/dist/types/messages/contextPruning.d.ts +42 -0
- package/dist/types/messages/contextPruningSettings.d.ts +44 -0
- package/dist/types/messages/core.d.ts +1 -1
- package/dist/types/messages/format.d.ts +17 -1
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/prune.d.ts +162 -1
- package/dist/types/messages/reducer.d.ts +18 -0
- package/dist/types/run.d.ts +12 -1
- package/dist/types/summarization/index.d.ts +20 -0
- package/dist/types/summarization/node.d.ts +29 -0
- package/dist/types/tools/ToolNode.d.ts +3 -1
- package/dist/types/types/graph.d.ts +44 -6
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/run.d.ts +30 -0
- package/dist/types/types/stream.d.ts +31 -4
- package/dist/types/types/summarize.d.ts +47 -0
- package/dist/types/types/tools.d.ts +7 -0
- package/dist/types/utils/errors.d.ts +28 -0
- package/dist/types/utils/events.d.ts +13 -0
- package/dist/types/utils/index.d.ts +2 -0
- package/dist/types/utils/llm.d.ts +4 -0
- package/dist/types/utils/tokens.d.ts +14 -1
- package/dist/types/utils/truncation.d.ts +49 -0
- package/package.json +3 -3
- package/src/agents/AgentContext.ts +388 -58
- package/src/agents/__tests__/AgentContext.test.ts +265 -5
- package/src/common/enum.ts +13 -0
- package/src/events.ts +9 -39
- package/src/graphs/Graph.ts +468 -331
- package/src/index.ts +7 -0
- package/src/llm/anthropic/llm.spec.ts +3 -3
- package/src/llm/anthropic/utils/message_inputs.ts +6 -4
- package/src/llm/bedrock/llm.spec.ts +1 -1
- package/src/llm/bedrock/utils/message_inputs.ts +6 -2
- package/src/llm/init.ts +63 -0
- package/src/llm/invoke.ts +144 -0
- package/src/llm/request.ts +55 -0
- package/src/messages/__tests__/observationMasking.test.ts +221 -0
- package/src/messages/cache.ts +77 -102
- package/src/messages/contextPruning.ts +191 -0
- package/src/messages/contextPruningSettings.ts +90 -0
- package/src/messages/core.ts +32 -53
- package/src/messages/ensureThinkingBlock.test.ts +39 -39
- package/src/messages/format.ts +227 -15
- package/src/messages/formatAgentMessages.test.ts +511 -1
- package/src/messages/index.ts +3 -0
- package/src/messages/prune.ts +1548 -62
- package/src/messages/reducer.ts +22 -0
- package/src/run.ts +104 -51
- package/src/scripts/bedrock-merge-test.ts +1 -1
- package/src/scripts/test-thinking-handoff-bedrock.ts +1 -1
- package/src/scripts/test-thinking-handoff.ts +1 -1
- package/src/scripts/thinking-bedrock.ts +1 -1
- package/src/scripts/thinking.ts +1 -1
- package/src/specs/anthropic.simple.test.ts +1 -1
- package/src/specs/multi-agent-summarization.test.ts +396 -0
- package/src/specs/prune.test.ts +1196 -23
- package/src/specs/summarization-unit.test.ts +868 -0
- package/src/specs/summarization.test.ts +3827 -0
- package/src/specs/summarize-prune.test.ts +376 -0
- package/src/specs/thinking-handoff.test.ts +10 -10
- package/src/specs/thinking-prune.test.ts +7 -4
- package/src/specs/token-accounting-e2e.test.ts +1034 -0
- package/src/specs/token-accounting-pipeline.test.ts +882 -0
- package/src/specs/token-distribution-edge-case.test.ts +25 -26
- package/src/splitStream.test.ts +42 -33
- package/src/stream.ts +64 -11
- package/src/summarization/__tests__/aggregator.test.ts +153 -0
- package/src/summarization/__tests__/node.test.ts +708 -0
- package/src/summarization/__tests__/trigger.test.ts +50 -0
- package/src/summarization/index.ts +102 -0
- package/src/summarization/node.ts +982 -0
- package/src/tools/ToolNode.ts +25 -3
- package/src/types/graph.ts +62 -7
- package/src/types/index.ts +1 -0
- package/src/types/run.ts +32 -0
- package/src/types/stream.ts +45 -5
- package/src/types/summarize.ts +58 -0
- package/src/types/tools.ts +7 -0
- package/src/utils/errors.ts +117 -0
- package/src/utils/events.ts +31 -0
- package/src/utils/handlers.ts +18 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/llm.ts +12 -0
- package/src/utils/tokens.ts +336 -18
- package/src/utils/truncation.ts +124 -0
- package/src/scripts/image.ts +0 -180
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
3
|
+
import type * as t from '@/types';
|
|
4
|
+
import { GraphEvents, Providers } from '@/common';
|
|
5
|
+
import {
|
|
6
|
+
createSummarizeNode,
|
|
7
|
+
DEFAULT_SUMMARIZATION_PROMPT,
|
|
8
|
+
DEFAULT_UPDATE_SUMMARIZATION_PROMPT,
|
|
9
|
+
} from '@/summarization/node';
|
|
10
|
+
import * as providers from '@/llm/providers';
|
|
11
|
+
import * as eventUtils from '@/utils/events';
|
|
12
|
+
import { AgentContext } from '@/agents/AgentContext';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Creates a real AgentContext via fromConfig with sensible defaults.
|
|
19
|
+
* Extra properties are assigned directly for test-specific overrides. */
|
|
20
|
+
function createAgentContext(
|
|
21
|
+
overrides: Record<string, unknown> = {}
|
|
22
|
+
): AgentContext {
|
|
23
|
+
const {
|
|
24
|
+
// AgentInputs fields
|
|
25
|
+
agentId = 'agent_0',
|
|
26
|
+
provider = Providers.OPENAI,
|
|
27
|
+
instructions = 'Test agent',
|
|
28
|
+
summarizationEnabled = true,
|
|
29
|
+
summarizationConfig,
|
|
30
|
+
maxContextTokens,
|
|
31
|
+
tools,
|
|
32
|
+
...extra
|
|
33
|
+
} = overrides;
|
|
34
|
+
|
|
35
|
+
const ctx = AgentContext.fromConfig({
|
|
36
|
+
agentId: agentId as string,
|
|
37
|
+
provider: provider as Providers,
|
|
38
|
+
instructions: instructions as string,
|
|
39
|
+
summarizationEnabled: summarizationEnabled as boolean,
|
|
40
|
+
...(summarizationConfig != null ? { summarizationConfig } : {}),
|
|
41
|
+
...(maxContextTokens != null ? { maxContextTokens } : {}),
|
|
42
|
+
...(tools != null ? { tools } : {}),
|
|
43
|
+
} as import('@/types').AgentInputs);
|
|
44
|
+
|
|
45
|
+
// Apply direct property overrides for test-specific internal state
|
|
46
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
47
|
+
(ctx as unknown as Record<string, unknown>)[key] = value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return ctx;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Creates a mock graph container for createSummarizeNode. */
|
|
54
|
+
function mockGraph(
|
|
55
|
+
onStepCompleted?: (stepId: string, result: t.StepCompleted) => void
|
|
56
|
+
): {
|
|
57
|
+
contentData: t.RunStep[];
|
|
58
|
+
contentIndexMap: Map<string, number>;
|
|
59
|
+
config: RunnableConfig;
|
|
60
|
+
runId: string;
|
|
61
|
+
isMultiAgent: boolean;
|
|
62
|
+
dispatchRunStep: (
|
|
63
|
+
runStep: t.RunStep,
|
|
64
|
+
config?: RunnableConfig
|
|
65
|
+
) => Promise<void>;
|
|
66
|
+
dispatchRunStepCompleted: (
|
|
67
|
+
stepId: string,
|
|
68
|
+
result: t.StepCompleted,
|
|
69
|
+
config?: RunnableConfig
|
|
70
|
+
) => Promise<void>;
|
|
71
|
+
} {
|
|
72
|
+
const contentData: t.RunStep[] = [];
|
|
73
|
+
const contentIndexMap = new Map<string, number>();
|
|
74
|
+
return {
|
|
75
|
+
contentData,
|
|
76
|
+
contentIndexMap,
|
|
77
|
+
config: {} as RunnableConfig,
|
|
78
|
+
runId: 'run_1',
|
|
79
|
+
isMultiAgent: false,
|
|
80
|
+
dispatchRunStep: async (runStep: t.RunStep): Promise<void> => {
|
|
81
|
+
contentData.push(runStep);
|
|
82
|
+
contentIndexMap.set(runStep.id, runStep.index);
|
|
83
|
+
},
|
|
84
|
+
dispatchRunStepCompleted: async (
|
|
85
|
+
stepId: string,
|
|
86
|
+
result: t.StepCompleted
|
|
87
|
+
): Promise<void> => {
|
|
88
|
+
onStepCompleted?.(stepId, result);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let stepCounter = 0;
|
|
94
|
+
function generateStepId(_stepKey: string): [string, number] {
|
|
95
|
+
const id = `step_test_${stepCounter++}`;
|
|
96
|
+
return [id, 0];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Collects custom events dispatched during the node execution. */
|
|
100
|
+
function captureEvents(): Array<{ event: string; data: unknown }> {
|
|
101
|
+
const events: Array<{ event: string; data: unknown }> = [];
|
|
102
|
+
jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
|
|
103
|
+
...args: unknown[]
|
|
104
|
+
) => {
|
|
105
|
+
events.push({ event: args[0] as string, data: args[1] });
|
|
106
|
+
}) as never);
|
|
107
|
+
return events;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Creates a mock model that returns a canned response via invoke(). */
|
|
111
|
+
function mockInvokeModel(response: string): { invoke: jest.Mock } {
|
|
112
|
+
return {
|
|
113
|
+
invoke: jest.fn().mockResolvedValue({ content: response }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Creates a mock model that streams text chunk-by-chunk.
|
|
119
|
+
* invoke() returns the full text; stream() yields one chunk per word.
|
|
120
|
+
*/
|
|
121
|
+
function mockStreamingModel(response: string): {
|
|
122
|
+
invoke: jest.Mock;
|
|
123
|
+
stream: jest.Mock;
|
|
124
|
+
} {
|
|
125
|
+
const words = response.split(' ');
|
|
126
|
+
return {
|
|
127
|
+
invoke: jest.fn().mockResolvedValue({ content: response }),
|
|
128
|
+
stream: jest.fn().mockImplementation(async () => {
|
|
129
|
+
return (async function* (): AsyncGenerator<{ content: string }> {
|
|
130
|
+
for (const word of words) {
|
|
131
|
+
// Add space back except for first word
|
|
132
|
+
yield { content: word + ' ' };
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Tests
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
stepCounter = 0;
|
|
145
|
+
jest.restoreAllMocks();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('createSummarizeNode', () => {
|
|
149
|
+
it('emits ON_SUMMARIZE_START and ON_SUMMARIZE_COMPLETE on success', async () => {
|
|
150
|
+
const events = captureEvents();
|
|
151
|
+
|
|
152
|
+
// Mock getChatModelClass to return our mock model
|
|
153
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
154
|
+
class {
|
|
155
|
+
constructor() {
|
|
156
|
+
return mockInvokeModel('Test summary output');
|
|
157
|
+
}
|
|
158
|
+
} as never
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const agentContext = createAgentContext();
|
|
162
|
+
const graph = mockGraph((_stepId, result) => {
|
|
163
|
+
if (result.type === 'summary') {
|
|
164
|
+
events.push({
|
|
165
|
+
event: GraphEvents.ON_SUMMARIZE_COMPLETE,
|
|
166
|
+
data: { summary: result.summary },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const node = createSummarizeNode({
|
|
171
|
+
agentContext,
|
|
172
|
+
graph,
|
|
173
|
+
generateStepId,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await node(
|
|
177
|
+
{
|
|
178
|
+
messages: [new HumanMessage('Hello'), new HumanMessage('World')],
|
|
179
|
+
summarizationRequest: {
|
|
180
|
+
remainingContextTokens: 1000,
|
|
181
|
+
agentId: 'agent_0',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{} as RunnableConfig
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const eventNames = events.map((e) => e.event);
|
|
188
|
+
// ON_RUN_STEP now goes through graph.dispatchRunStep, not safeDispatchCustomEvent
|
|
189
|
+
expect(graph.contentData.length).toBeGreaterThan(0);
|
|
190
|
+
expect(eventNames).toContain(GraphEvents.ON_SUMMARIZE_START);
|
|
191
|
+
expect(eventNames).toContain(GraphEvents.ON_SUMMARIZE_COMPLETE);
|
|
192
|
+
|
|
193
|
+
// Complete event should have the summary text
|
|
194
|
+
const completeEvent = events.find(
|
|
195
|
+
(e) => e.event === GraphEvents.ON_SUMMARIZE_COMPLETE
|
|
196
|
+
);
|
|
197
|
+
expect(
|
|
198
|
+
(
|
|
199
|
+
(completeEvent?.data as t.SummarizeCompleteEvent).summary!
|
|
200
|
+
.content?.[0] as { text: string } | undefined
|
|
201
|
+
)?.text
|
|
202
|
+
).toBe('Test summary output');
|
|
203
|
+
expect(
|
|
204
|
+
(completeEvent?.data as t.SummarizeCompleteEvent).error
|
|
205
|
+
).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('collects streamed text when model supports stream()', async () => {
|
|
209
|
+
captureEvents();
|
|
210
|
+
|
|
211
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
212
|
+
class {
|
|
213
|
+
constructor() {
|
|
214
|
+
return mockStreamingModel('one two three');
|
|
215
|
+
}
|
|
216
|
+
} as never
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const setSummary = jest.fn();
|
|
220
|
+
const agentContext = createAgentContext({ setSummary } as never);
|
|
221
|
+
const graph = mockGraph();
|
|
222
|
+
const node = createSummarizeNode({
|
|
223
|
+
agentContext,
|
|
224
|
+
graph,
|
|
225
|
+
generateStepId,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await node(
|
|
229
|
+
{
|
|
230
|
+
messages: [new HumanMessage('Test message')],
|
|
231
|
+
summarizationRequest: {
|
|
232
|
+
remainingContextTokens: 1000,
|
|
233
|
+
agentId: 'agent_0',
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{} as RunnableConfig
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Node collects the full streamed text and calls setSummary.
|
|
240
|
+
// Delta events are dispatched by ChatModelStreamHandler, not the node.
|
|
241
|
+
expect(setSummary).toHaveBeenCalledWith(
|
|
242
|
+
'one two three',
|
|
243
|
+
expect.any(Number)
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('falls back to invoke when model has no stream()', async () => {
|
|
248
|
+
captureEvents();
|
|
249
|
+
|
|
250
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
251
|
+
class {
|
|
252
|
+
constructor() {
|
|
253
|
+
return mockInvokeModel('Full summary text');
|
|
254
|
+
}
|
|
255
|
+
} as never
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const setSummary = jest.fn();
|
|
259
|
+
const agentContext = createAgentContext({ setSummary } as never);
|
|
260
|
+
const graph = mockGraph();
|
|
261
|
+
const node = createSummarizeNode({
|
|
262
|
+
agentContext,
|
|
263
|
+
graph,
|
|
264
|
+
generateStepId,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await node(
|
|
268
|
+
{
|
|
269
|
+
messages: [new HumanMessage('Test message')],
|
|
270
|
+
summarizationRequest: {
|
|
271
|
+
remainingContextTokens: 1000,
|
|
272
|
+
agentId: 'agent_0',
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{} as RunnableConfig
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Falls back to invoke and still collects the text
|
|
279
|
+
expect(setSummary).toHaveBeenCalledWith(
|
|
280
|
+
'Full summary text',
|
|
281
|
+
expect.any(Number)
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('produces metadata stub when all LLM attempts fail', async () => {
|
|
286
|
+
const events = captureEvents();
|
|
287
|
+
|
|
288
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
289
|
+
class {
|
|
290
|
+
constructor() {
|
|
291
|
+
return {
|
|
292
|
+
invoke: jest.fn().mockRejectedValue(new Error('Model error')),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
} as never
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const setSummary = jest.fn();
|
|
299
|
+
const agentContext = createAgentContext({ setSummary } as never);
|
|
300
|
+
const graph = mockGraph((_stepId, result) => {
|
|
301
|
+
if (result.type === 'summary') {
|
|
302
|
+
events.push({
|
|
303
|
+
event: GraphEvents.ON_SUMMARIZE_COMPLETE,
|
|
304
|
+
data: { summary: result.summary },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
const node = createSummarizeNode({
|
|
309
|
+
agentContext,
|
|
310
|
+
graph,
|
|
311
|
+
generateStepId,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const result = await node(
|
|
315
|
+
{
|
|
316
|
+
messages: [new HumanMessage('Test')],
|
|
317
|
+
summarizationRequest: {
|
|
318
|
+
remainingContextTokens: 1000,
|
|
319
|
+
agentId: 'agent_0',
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{} as RunnableConfig
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
expect(result.summarizationRequest).toBeUndefined();
|
|
326
|
+
// After summarization, REMOVE_ALL + surviving context is returned
|
|
327
|
+
expect(result.messages).toBeDefined();
|
|
328
|
+
expect(result.messages!.length).toBeGreaterThanOrEqual(1);
|
|
329
|
+
expect(result.messages![0]._getType()).toBe('remove');
|
|
330
|
+
|
|
331
|
+
// Tier 3 fallback: metadata stub is used as summary text
|
|
332
|
+
const completeEvent = events.find(
|
|
333
|
+
(e) => e.event === GraphEvents.ON_SUMMARIZE_COMPLETE
|
|
334
|
+
);
|
|
335
|
+
expect(
|
|
336
|
+
(
|
|
337
|
+
(completeEvent?.data as t.SummarizeCompleteEvent).summary!
|
|
338
|
+
.content?.[0] as { text: string } | undefined
|
|
339
|
+
)?.text
|
|
340
|
+
).toMatch(/^\[Metadata summary:/);
|
|
341
|
+
expect(
|
|
342
|
+
(completeEvent?.data as t.SummarizeCompleteEvent).error
|
|
343
|
+
).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('falls back to metadata stub when primary LLM call fails', async () => {
|
|
347
|
+
captureEvents();
|
|
348
|
+
|
|
349
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
350
|
+
class {
|
|
351
|
+
constructor() {
|
|
352
|
+
return {
|
|
353
|
+
invoke: jest.fn().mockRejectedValue(new Error('LLM unavailable')),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
} as never
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const setSummary = jest.fn();
|
|
360
|
+
const agentContext = createAgentContext({ setSummary } as never);
|
|
361
|
+
const graph = mockGraph();
|
|
362
|
+
const node = createSummarizeNode({
|
|
363
|
+
agentContext,
|
|
364
|
+
graph,
|
|
365
|
+
generateStepId,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await node(
|
|
369
|
+
{
|
|
370
|
+
messages: [new HumanMessage('Test message')],
|
|
371
|
+
summarizationRequest: {
|
|
372
|
+
remainingContextTokens: 1000,
|
|
373
|
+
agentId: 'agent_0',
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{} as RunnableConfig
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
expect(setSummary).toHaveBeenCalledWith(
|
|
380
|
+
expect.stringContaining('[Metadata summary:'),
|
|
381
|
+
expect.any(Number)
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('calls setSummary with the final text', async () => {
|
|
386
|
+
captureEvents();
|
|
387
|
+
|
|
388
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
389
|
+
class {
|
|
390
|
+
constructor() {
|
|
391
|
+
return mockInvokeModel('Final summary');
|
|
392
|
+
}
|
|
393
|
+
} as never
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const setSummary = jest.fn();
|
|
397
|
+
const agentContext = createAgentContext({ setSummary } as never);
|
|
398
|
+
const graph = mockGraph();
|
|
399
|
+
const node = createSummarizeNode({
|
|
400
|
+
agentContext,
|
|
401
|
+
graph,
|
|
402
|
+
generateStepId,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await node(
|
|
406
|
+
{
|
|
407
|
+
messages: [new HumanMessage('Test')],
|
|
408
|
+
summarizationRequest: {
|
|
409
|
+
remainingContextTokens: 1000,
|
|
410
|
+
agentId: 'agent_0',
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{} as RunnableConfig
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
expect(setSummary).toHaveBeenCalledWith(
|
|
417
|
+
'Final summary',
|
|
418
|
+
expect.any(Number)
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('cache-hit path sends raw messages with instruction appended as final HumanMessage', async () => {
|
|
423
|
+
captureEvents();
|
|
424
|
+
|
|
425
|
+
const capturedMessages: Array<{ type: string; content: string }> = [];
|
|
426
|
+
|
|
427
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
428
|
+
class {
|
|
429
|
+
constructor() {
|
|
430
|
+
return {
|
|
431
|
+
invoke: jest
|
|
432
|
+
.fn()
|
|
433
|
+
.mockImplementation(async (messages: unknown[]) => {
|
|
434
|
+
for (const msg of messages as {
|
|
435
|
+
getType: () => string;
|
|
436
|
+
content: string | unknown[];
|
|
437
|
+
}[]) {
|
|
438
|
+
capturedMessages.push({
|
|
439
|
+
type: msg.getType(),
|
|
440
|
+
content:
|
|
441
|
+
typeof msg.content === 'string'
|
|
442
|
+
? msg.content
|
|
443
|
+
: JSON.stringify(msg.content),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
content:
|
|
448
|
+
'## Goal\nTest goal\n\n<events>\n<event key="test" turn="0">value</event>\n</events>',
|
|
449
|
+
};
|
|
450
|
+
}),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
} as never
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const agentContext = createAgentContext();
|
|
457
|
+
const graph = mockGraph();
|
|
458
|
+
const node = createSummarizeNode({
|
|
459
|
+
agentContext,
|
|
460
|
+
graph: graph as never,
|
|
461
|
+
generateStepId,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await node(
|
|
465
|
+
{
|
|
466
|
+
messages: [
|
|
467
|
+
new HumanMessage('Message 1'),
|
|
468
|
+
new HumanMessage('Message 2'),
|
|
469
|
+
new HumanMessage('Message 3'),
|
|
470
|
+
],
|
|
471
|
+
summarizationRequest: {
|
|
472
|
+
remainingContextTokens: 1000,
|
|
473
|
+
agentId: 'agent_0',
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
{} as RunnableConfig
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// The raw messages should be sent + instruction appended as the last HumanMessage
|
|
480
|
+
// messagesToRefine has 3 HumanMessages, instruction adds 1 more
|
|
481
|
+
expect(capturedMessages.length).toBe(4);
|
|
482
|
+
expect(capturedMessages[0].type).toBe('human');
|
|
483
|
+
expect(capturedMessages[0].content).toBe('Message 1');
|
|
484
|
+
expect(capturedMessages[3].type).toBe('human');
|
|
485
|
+
// The last message should contain the summarization prompt
|
|
486
|
+
expect(capturedMessages[3].content).toContain(
|
|
487
|
+
'context window is filling up'
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('cache-hit path includes prior summary in the instruction message', async () => {
|
|
492
|
+
captureEvents();
|
|
493
|
+
|
|
494
|
+
const capturedMessages: Array<{ type: string; content: string }> = [];
|
|
495
|
+
|
|
496
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
497
|
+
class {
|
|
498
|
+
constructor() {
|
|
499
|
+
return {
|
|
500
|
+
invoke: jest
|
|
501
|
+
.fn()
|
|
502
|
+
.mockImplementation(async (messages: unknown[]) => {
|
|
503
|
+
for (const msg of messages as {
|
|
504
|
+
getType: () => string;
|
|
505
|
+
content: string | unknown[];
|
|
506
|
+
}[]) {
|
|
507
|
+
capturedMessages.push({
|
|
508
|
+
type: msg.getType(),
|
|
509
|
+
content:
|
|
510
|
+
typeof msg.content === 'string'
|
|
511
|
+
? msg.content
|
|
512
|
+
: JSON.stringify(msg.content),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return { content: '## Goal\nUpdated summary' };
|
|
516
|
+
}),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
} as never
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
// Create context with a prior summary
|
|
523
|
+
const agentContext = createAgentContext();
|
|
524
|
+
agentContext.setSummary('## Goal\nPrior summary content.', 50);
|
|
525
|
+
|
|
526
|
+
const graph = mockGraph();
|
|
527
|
+
const node = createSummarizeNode({
|
|
528
|
+
agentContext,
|
|
529
|
+
graph: graph as never,
|
|
530
|
+
generateStepId,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
await node(
|
|
534
|
+
{
|
|
535
|
+
messages: [new HumanMessage('New message')],
|
|
536
|
+
summarizationRequest: {
|
|
537
|
+
remainingContextTokens: 1000,
|
|
538
|
+
agentId: 'agent_0',
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{} as RunnableConfig
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
// The last message should contain the update prompt (prior summary exists)
|
|
545
|
+
const lastMsg = capturedMessages[capturedMessages.length - 1];
|
|
546
|
+
expect(lastMsg.type).toBe('human');
|
|
547
|
+
expect(lastMsg.content).toContain('Merge the new messages');
|
|
548
|
+
// Should include the prior summary
|
|
549
|
+
expect(lastMsg.content).toContain('<previous-summary>');
|
|
550
|
+
expect(lastMsg.content).toContain('Prior summary content');
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe('DEFAULT_SUMMARIZATION_PROMPT', () => {
|
|
555
|
+
it('is exported and non-empty', () => {
|
|
556
|
+
expect(typeof DEFAULT_SUMMARIZATION_PROMPT).toBe('string');
|
|
557
|
+
expect(DEFAULT_SUMMARIZATION_PROMPT.length).toBeGreaterThan(0);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('contains structured checkpoint sections', () => {
|
|
561
|
+
expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Goal');
|
|
562
|
+
expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Progress');
|
|
563
|
+
expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Key Decisions');
|
|
564
|
+
expect(DEFAULT_SUMMARIZATION_PROMPT).toContain('## Next Steps');
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('DEFAULT_UPDATE_SUMMARIZATION_PROMPT', () => {
|
|
569
|
+
it('is exported and non-empty', () => {
|
|
570
|
+
expect(typeof DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toBe('string');
|
|
571
|
+
expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT.length).toBeGreaterThan(0);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('instructs merging new content', () => {
|
|
575
|
+
expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toMatch(
|
|
576
|
+
/Merge the new messages/i
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('instructs updating progress tracking', () => {
|
|
581
|
+
expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toMatch(/Done/);
|
|
582
|
+
expect(DEFAULT_UPDATE_SUMMARIZATION_PROMPT).toMatch(/In Progress/);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe('budget check — instructions exceed context', () => {
|
|
587
|
+
it('skips summarization when instructionTokens >= maxContextTokens', async () => {
|
|
588
|
+
const events = captureEvents();
|
|
589
|
+
const agentContext = createAgentContext({
|
|
590
|
+
maxContextTokens: 4000,
|
|
591
|
+
systemMessageTokens: 5000,
|
|
592
|
+
formatTokenBudgetBreakdown: () => 'mock breakdown',
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const graph = mockGraph();
|
|
596
|
+
const summarizeNode = createSummarizeNode({
|
|
597
|
+
agentContext,
|
|
598
|
+
graph: graph as never,
|
|
599
|
+
generateStepId,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const result = await summarizeNode(
|
|
603
|
+
{
|
|
604
|
+
messages: [new HumanMessage('test')],
|
|
605
|
+
summarizationRequest: {
|
|
606
|
+
remainingContextTokens: -1000,
|
|
607
|
+
agentId: 'agent_0',
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
{} as RunnableConfig
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
expect(result.summarizationRequest).toBeUndefined();
|
|
614
|
+
expect(result.messages).toBeUndefined();
|
|
615
|
+
|
|
616
|
+
// No summarization events should have fired
|
|
617
|
+
const summarizeEvents = events.filter(
|
|
618
|
+
(e) =>
|
|
619
|
+
e.event === GraphEvents.ON_SUMMARIZE_START ||
|
|
620
|
+
e.event === GraphEvents.ON_SUMMARIZE_DELTA ||
|
|
621
|
+
e.event === GraphEvents.ON_SUMMARIZE_COMPLETE
|
|
622
|
+
);
|
|
623
|
+
expect(summarizeEvents).toHaveLength(0);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('proceeds normally when instructionTokens < maxContextTokens', async () => {
|
|
627
|
+
captureEvents();
|
|
628
|
+
|
|
629
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
630
|
+
class {
|
|
631
|
+
constructor() {
|
|
632
|
+
return mockInvokeModel('Budget is fine summary');
|
|
633
|
+
}
|
|
634
|
+
} as never
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const agentContext = createAgentContext({
|
|
638
|
+
maxContextTokens: 8000,
|
|
639
|
+
systemMessageTokens: 2000,
|
|
640
|
+
formatTokenBudgetBreakdown: () => 'mock breakdown',
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const graph = mockGraph();
|
|
644
|
+
const summarizeNode = createSummarizeNode({
|
|
645
|
+
agentContext,
|
|
646
|
+
graph: graph as never,
|
|
647
|
+
generateStepId,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const result = await summarizeNode(
|
|
651
|
+
{
|
|
652
|
+
messages: [new HumanMessage('hello')],
|
|
653
|
+
summarizationRequest: {
|
|
654
|
+
remainingContextTokens: 500,
|
|
655
|
+
agentId: 'agent_0',
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
{} as RunnableConfig
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
// Should have summarized — messages returned for state replacement
|
|
662
|
+
expect(result.messages).toBeDefined();
|
|
663
|
+
expect(result.messages!.length).toBeGreaterThan(0);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
describe('emoji-heavy content does not break summarization', () => {
|
|
668
|
+
it('summarization completes without JSON errors on emoji-heavy messages', async () => {
|
|
669
|
+
captureEvents();
|
|
670
|
+
|
|
671
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
672
|
+
class {
|
|
673
|
+
constructor() {
|
|
674
|
+
return mockInvokeModel('Summary of emoji conversation');
|
|
675
|
+
}
|
|
676
|
+
} as never
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
const emojiContent = '👨💻 coding 🎉 party 🌍 world 🚀 rocket '.repeat(30);
|
|
680
|
+
const agentContext = createAgentContext({
|
|
681
|
+
maxContextTokens: 8000,
|
|
682
|
+
systemMessageTokens: 100,
|
|
683
|
+
formatTokenBudgetBreakdown: () => 'mock breakdown',
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const graph = mockGraph();
|
|
687
|
+
const summarizeNode = createSummarizeNode({
|
|
688
|
+
agentContext,
|
|
689
|
+
graph: graph as never,
|
|
690
|
+
generateStepId,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const result = await summarizeNode(
|
|
694
|
+
{
|
|
695
|
+
messages: [new HumanMessage(emojiContent)],
|
|
696
|
+
summarizationRequest: {
|
|
697
|
+
remainingContextTokens: 500,
|
|
698
|
+
agentId: 'agent_0',
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
{} as RunnableConfig
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
// Should complete without throwing JSON serialization errors
|
|
705
|
+
expect(result.messages).toBeDefined();
|
|
706
|
+
expect(result.messages!.length).toBeGreaterThan(0);
|
|
707
|
+
});
|
|
708
|
+
});
|