@librechat/agents 3.2.34 → 3.2.35

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.
Files changed (77) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +47 -10
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +13 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +121 -3
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/llm/invoke.cjs +49 -8
  8. package/dist/cjs/llm/invoke.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +2 -0
  10. package/dist/cjs/messages/content.cjs +12 -14
  11. package/dist/cjs/messages/content.cjs.map +1 -1
  12. package/dist/cjs/messages/prune.cjs +31 -13
  13. package/dist/cjs/messages/prune.cjs.map +1 -1
  14. package/dist/cjs/run.cjs +7 -2
  15. package/dist/cjs/run.cjs.map +1 -1
  16. package/dist/cjs/summarization/node.cjs +12 -1
  17. package/dist/cjs/summarization/node.cjs.map +1 -1
  18. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
  19. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  20. package/dist/cjs/utils/tokens.cjs +30 -0
  21. package/dist/cjs/utils/tokens.cjs.map +1 -1
  22. package/dist/esm/agents/AgentContext.mjs +47 -10
  23. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  24. package/dist/esm/common/enum.mjs +13 -0
  25. package/dist/esm/common/enum.mjs.map +1 -1
  26. package/dist/esm/graphs/Graph.mjs +122 -4
  27. package/dist/esm/graphs/Graph.mjs.map +1 -1
  28. package/dist/esm/llm/invoke.mjs +49 -8
  29. package/dist/esm/llm/invoke.mjs.map +1 -1
  30. package/dist/esm/main.mjs +3 -3
  31. package/dist/esm/messages/content.mjs +12 -15
  32. package/dist/esm/messages/content.mjs.map +1 -1
  33. package/dist/esm/messages/prune.mjs +31 -13
  34. package/dist/esm/messages/prune.mjs.map +1 -1
  35. package/dist/esm/run.mjs +7 -2
  36. package/dist/esm/run.mjs.map +1 -1
  37. package/dist/esm/summarization/node.mjs +12 -1
  38. package/dist/esm/summarization/node.mjs.map +1 -1
  39. package/dist/esm/tools/subagent/SubagentExecutor.mjs +138 -2
  40. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  41. package/dist/esm/utils/tokens.mjs +30 -1
  42. package/dist/esm/utils/tokens.mjs.map +1 -1
  43. package/dist/types/agents/AgentContext.d.ts +7 -3
  44. package/dist/types/common/enum.d.ts +13 -0
  45. package/dist/types/graphs/Graph.d.ts +8 -1
  46. package/dist/types/llm/invoke.d.ts +1 -1
  47. package/dist/types/messages/content.d.ts +5 -0
  48. package/dist/types/messages/prune.d.ts +4 -0
  49. package/dist/types/run.d.ts +1 -0
  50. package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
  51. package/dist/types/types/graph.d.ts +89 -3
  52. package/dist/types/types/run.d.ts +13 -0
  53. package/dist/types/utils/tokens.d.ts +7 -0
  54. package/package.json +1 -1
  55. package/src/agents/AgentContext.ts +69 -6
  56. package/src/agents/__tests__/AgentContext.test.ts +6 -2
  57. package/src/common/enum.ts +13 -0
  58. package/src/graphs/Graph.ts +196 -0
  59. package/src/llm/invoke.test.ts +79 -1
  60. package/src/llm/invoke.ts +58 -4
  61. package/src/messages/content.ts +24 -32
  62. package/src/messages/prune.ts +39 -2
  63. package/src/run.ts +5 -0
  64. package/src/scripts/subagent-usage-sink.ts +176 -0
  65. package/src/specs/context-accuracy.live.test.ts +409 -0
  66. package/src/specs/context-usage-event.test.ts +117 -0
  67. package/src/specs/context-usage.live.test.ts +297 -0
  68. package/src/specs/prune.test.ts +51 -1
  69. package/src/specs/subagent.test.ts +124 -1
  70. package/src/summarization/__tests__/node.test.ts +60 -1
  71. package/src/summarization/node.ts +20 -1
  72. package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
  73. package/src/tools/subagent/SubagentExecutor.ts +221 -3
  74. package/src/types/graph.ts +94 -1
  75. package/src/types/run.ts +13 -0
  76. package/src/utils/__tests__/apportion.test.ts +32 -0
  77. package/src/utils/tokens.ts +33 -0
@@ -0,0 +1,409 @@
1
+ // src/specs/context-accuracy.live.test.ts
2
+ /**
3
+ * Live ACCURACY verification for ON_CONTEXT_USAGE against real provider
4
+ * counts in the hard scenarios: tool loops (where calibration engages),
5
+ * prompt caching (cache fields for cost math), and pruning under context
6
+ * pressure (calibrated remaining-units math). Logs measured ratios.
7
+ *
8
+ * Run with:
9
+ * RUN_CONTEXT_USAGE_LIVE_TESTS=1 ANTHROPIC_API_KEY=... npm test -- context-accuracy.live.test.ts --runInBand
10
+ *
11
+ * The google/bedrock matrix entries skip without GOOGLE_API_KEY /
12
+ * BEDROCK_AWS_* creds; Bedrock's AWS SDK needs
13
+ * NODE_OPTIONS='--experimental-vm-modules' under jest.
14
+ */
15
+ import { config as dotenvConfig } from 'dotenv';
16
+ dotenvConfig();
17
+
18
+ import { z } from 'zod';
19
+ import { tool } from '@langchain/core/tools';
20
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
21
+ import { describe, expect, it, jest } from '@jest/globals';
22
+ import type { BaseMessage } from '@langchain/core/messages';
23
+ import type { RunnableConfig } from '@langchain/core/runnables';
24
+ import type * as t from '@/types';
25
+ import { createTokenCounter, TokenEncoderManager } from '@/utils/tokens';
26
+ import { GraphEvents, Providers } from '@/common';
27
+ import { ModelEndHandler } from '@/events';
28
+ import { Run } from '@/run';
29
+
30
+ const shouldRunLive =
31
+ process.env.RUN_CONTEXT_USAGE_LIVE_TESTS === '1' &&
32
+ process.env.ANTHROPIC_API_KEY != null &&
33
+ process.env.ANTHROPIC_API_KEY !== '';
34
+
35
+ const describeIfLive = shouldRunLive ? describe : describe.skip;
36
+ const modelName =
37
+ process.env.ANTHROPIC_CONTEXT_LIVE_MODEL ?? 'claude-haiku-4-5';
38
+
39
+ function createStreamConfig(threadId: string): Partial<RunnableConfig> & {
40
+ version: 'v1' | 'v2';
41
+ streamMode: string;
42
+ } {
43
+ return {
44
+ configurable: { thread_id: threadId },
45
+ streamMode: 'values',
46
+ version: 'v2',
47
+ };
48
+ }
49
+
50
+ interface Captured {
51
+ contextEvents: t.ContextUsageEvent[];
52
+ collectedUsage: Array<{
53
+ input_tokens?: number;
54
+ output_tokens?: number;
55
+ input_token_details?: { cache_creation?: number; cache_read?: number };
56
+ }>;
57
+ handlers: Record<string, t.EventHandler>;
58
+ }
59
+
60
+ function createCapture(): Captured {
61
+ const contextEvents: t.ContextUsageEvent[] = [];
62
+ const collectedUsage: Captured['collectedUsage'] = [];
63
+ const handlers: Record<string, t.EventHandler> = {
64
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage as never),
65
+ [GraphEvents.ON_CONTEXT_USAGE]: {
66
+ handle: (_event, data): void => {
67
+ contextEvents.push(data as unknown as t.ContextUsageEvent);
68
+ },
69
+ },
70
+ };
71
+ return { contextEvents, collectedUsage, handlers };
72
+ }
73
+
74
+ function estimatedUsed(event: t.ContextUsageEvent): number {
75
+ return (event.contextBudget ?? 0) - (event.remainingContextTokens ?? 0);
76
+ }
77
+
78
+ const addTool = tool(
79
+ async ({ a, b }: { a: number; b: number }) => String(a + b),
80
+ {
81
+ name: 'add',
82
+ description: 'Add two numbers and return the sum',
83
+ schema: z.object({ a: z.number(), b: z.number() }),
84
+ }
85
+ );
86
+
87
+ /** ~5K tokens so the system prompt clears the haiku prompt-cache minimum;
88
+ * salted per run so the first call always writes a cold cache entry */
89
+ function buildLongInstructions(salt: string): string {
90
+ return [
91
+ `Session ${salt}: you are a precise assistant. Use the add tool for any arithmetic, then reply with only the number.`,
92
+ ...Array.from(
93
+ { length: 200 },
94
+ (_, i) =>
95
+ `Rule ${i}: always answer precisely, verify arithmetic results twice, and keep every response to a single line without commentary.`
96
+ ),
97
+ ].join(' ');
98
+ }
99
+
100
+ /** Cross-provider accuracy matrix: different tokenizers and usage shapes */
101
+ const providerMatrix: Array<{
102
+ name: string;
103
+ enabled: boolean;
104
+ llmConfig: Record<string, unknown>;
105
+ }> = [
106
+ {
107
+ name: 'google',
108
+ enabled: !!process.env.GOOGLE_API_KEY,
109
+ llmConfig: {
110
+ provider: Providers.GOOGLE,
111
+ model: process.env.GOOGLE_CONTEXT_LIVE_MODEL ?? 'gemini-2.5-flash',
112
+ apiKey: process.env.GOOGLE_API_KEY,
113
+ temperature: 0,
114
+ streaming: true,
115
+ streamUsage: true,
116
+ },
117
+ },
118
+ {
119
+ name: 'bedrock',
120
+ enabled:
121
+ !!process.env.BEDROCK_AWS_ACCESS_KEY_ID &&
122
+ !!process.env.BEDROCK_AWS_SECRET_ACCESS_KEY,
123
+ llmConfig: {
124
+ provider: Providers.BEDROCK,
125
+ model:
126
+ process.env.BEDROCK_CONTEXT_LIVE_MODEL ??
127
+ 'us.anthropic.claude-sonnet-4-6',
128
+ region:
129
+ process.env.BEDROCK_AWS_REGION ??
130
+ process.env.AWS_DEFAULT_REGION ??
131
+ 'us-east-1',
132
+ credentials: {
133
+ accessKeyId: process.env.BEDROCK_AWS_ACCESS_KEY_ID,
134
+ secretAccessKey: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY,
135
+ },
136
+ temperature: 0,
137
+ maxTokens: 128,
138
+ streaming: true,
139
+ streamUsage: true,
140
+ },
141
+ },
142
+ ];
143
+
144
+ describeIfLive('Context accuracy live integration', () => {
145
+ jest.setTimeout(240_000);
146
+
147
+ let tokenCounter: t.TokenCounter;
148
+
149
+ beforeAll(async () => {
150
+ tokenCounter = await createTokenCounter();
151
+ });
152
+
153
+ afterAll(() => {
154
+ TokenEncoderManager.reset();
155
+ });
156
+
157
+ it('tracks provider counts through a cached tool loop and tightens with calibration', async () => {
158
+ const capture = createCapture();
159
+ const run = await Run.create<t.IState>({
160
+ runId: `ctx-acc-loop-${Date.now()}`,
161
+ graphConfig: {
162
+ type: 'standard',
163
+ llmConfig: {
164
+ provider: Providers.ANTHROPIC,
165
+ modelName,
166
+ apiKey: process.env.ANTHROPIC_API_KEY,
167
+ temperature: 0,
168
+ maxTokens: 128,
169
+ streaming: true,
170
+ streamUsage: true,
171
+ promptCache: true,
172
+ },
173
+ instructions: buildLongInstructions(`${Date.now()}`),
174
+ maxContextTokens: 16000,
175
+ tools: [addTool],
176
+ },
177
+ returnContent: true,
178
+ skipCleanup: true,
179
+ customHandlers: capture.handlers,
180
+ tokenCounter,
181
+ indexTokenCountMap: {},
182
+ });
183
+
184
+ await run.processStream(
185
+ {
186
+ messages: [
187
+ new HumanMessage(
188
+ 'Use the add tool to compute 1742 + 2581, then reply with only the number.'
189
+ ),
190
+ ],
191
+ },
192
+ createStreamConfig(`ctx-acc-loop-${Date.now()}`)
193
+ );
194
+
195
+ expect(capture.contextEvents.length).toBeGreaterThanOrEqual(2);
196
+ expect(capture.collectedUsage.length).toBe(capture.contextEvents.length);
197
+
198
+ const ratios = capture.contextEvents.map((event, index) => {
199
+ const usage = capture.collectedUsage[index];
200
+ /** LangChain-normalized Anthropic usage reports cache fields as
201
+ * subsets of input_tokens; only add them when genuinely additive
202
+ * (same heuristic as calculateTotalTokens) */
203
+ const baseInput = usage.input_tokens ?? 0;
204
+ const cacheSum =
205
+ (usage.input_token_details?.cache_creation ?? 0) +
206
+ (usage.input_token_details?.cache_read ?? 0);
207
+ const providerInput = baseInput + (cacheSum > baseInput ? cacheSum : 0);
208
+ console.log(`[ctx-accuracy] call ${index + 1}:`, {
209
+ estimated: estimatedUsed(event),
210
+ providerInput,
211
+ providerParts: {
212
+ input: usage.input_tokens,
213
+ cacheWrite: usage.input_token_details?.cache_creation,
214
+ cacheRead: usage.input_token_details?.cache_read,
215
+ },
216
+ effectiveInstructionTokens: event.effectiveInstructionTokens,
217
+ systemMessageTokens: event.breakdown.systemMessageTokens,
218
+ toolSchemaTokens: event.breakdown.toolSchemaTokens,
219
+ messageTokens: event.breakdown.messageTokens,
220
+ remaining: event.remainingContextTokens,
221
+ budget: event.contextBudget,
222
+ calibrationRatio: event.calibrationRatio,
223
+ });
224
+ return estimatedUsed(event) / providerInput;
225
+ });
226
+ console.log('[ctx-accuracy] tool-loop estimate/provider ratios:', ratios);
227
+
228
+ /** Call 1: uncalibrated local estimate must be in the right ballpark */
229
+ expect(ratios[0]).toBeGreaterThan(0.5);
230
+ expect(ratios[0]).toBeLessThan(2);
231
+
232
+ /** Call 2+: calibration has real provider counts — tighter band, and
233
+ * no further from truth than the uncalibrated call (noise epsilon) */
234
+ const last = ratios[ratios.length - 1];
235
+ expect(last).toBeGreaterThan(0.6);
236
+ expect(last).toBeLessThan(1.7);
237
+ expect(Math.abs(last - 1)).toBeLessThanOrEqual(
238
+ Math.abs(ratios[0] - 1) + 0.15
239
+ );
240
+
241
+ /** Prompt caching engaged: write on the first call, read on the next */
242
+ const cacheWrites = capture.collectedUsage.map(
243
+ (u) => u.input_token_details?.cache_creation ?? 0
244
+ );
245
+ const cacheReads = capture.collectedUsage.map(
246
+ (u) => u.input_token_details?.cache_read ?? 0
247
+ );
248
+ expect(Math.max(...cacheWrites)).toBeGreaterThan(0);
249
+ expect(Math.max(...cacheReads)).toBeGreaterThan(0);
250
+
251
+ const text = (run.getRunMessages() ?? [])
252
+ .filter((message) => message.getType() === 'ai')
253
+ .map((message) =>
254
+ typeof message.content === 'string'
255
+ ? message.content
256
+ : JSON.stringify(message.content)
257
+ )
258
+ .join(' ');
259
+ expect(text).toContain('4323');
260
+ });
261
+
262
+ for (const entry of providerMatrix) {
263
+ const itIfProvider = entry.enabled ? it : it.skip;
264
+ itIfProvider(
265
+ `${entry.name}: tool-loop estimates track provider counts across tokenizers`,
266
+ async () => {
267
+ const capture = createCapture();
268
+ const run = await Run.create<t.IState>({
269
+ runId: `ctx-acc-${entry.name}-${Date.now()}`,
270
+ graphConfig: {
271
+ type: 'standard',
272
+ llmConfig: entry.llmConfig as t.LLMConfig,
273
+ instructions:
274
+ 'You are a precise assistant. Use the add tool for any arithmetic, then reply with only the number. ' +
275
+ Array.from(
276
+ { length: 40 },
277
+ (_, i) =>
278
+ `Rule ${i}: answer precisely and keep responses to a single line.`
279
+ ).join(' '),
280
+ maxContextTokens: 16000,
281
+ tools: [addTool],
282
+ },
283
+ returnContent: true,
284
+ skipCleanup: true,
285
+ customHandlers: capture.handlers,
286
+ tokenCounter,
287
+ indexTokenCountMap: {},
288
+ });
289
+
290
+ await run.processStream(
291
+ {
292
+ messages: [
293
+ new HumanMessage(
294
+ 'Use the add tool to compute 1742 + 2581, then reply with only the number.'
295
+ ),
296
+ ],
297
+ },
298
+ createStreamConfig(`ctx-acc-${entry.name}-${Date.now()}`)
299
+ );
300
+
301
+ expect(capture.contextEvents.length).toBeGreaterThanOrEqual(2);
302
+ expect(capture.collectedUsage.length).toBe(
303
+ capture.contextEvents.length
304
+ );
305
+
306
+ const ratios = capture.contextEvents.map((event, index) => {
307
+ const usage = capture.collectedUsage[index];
308
+ const baseInput = usage.input_tokens ?? 0;
309
+ const cacheSum =
310
+ (usage.input_token_details?.cache_creation ?? 0) +
311
+ (usage.input_token_details?.cache_read ?? 0);
312
+ const providerInput =
313
+ baseInput + (cacheSum > baseInput ? cacheSum : 0);
314
+ return estimatedUsed(event) / providerInput;
315
+ });
316
+ console.log(`[ctx-accuracy] ${entry.name} ratios:`, ratios, {
317
+ provider: capture.collectedUsage.map((u) => ({
318
+ input: u.input_tokens,
319
+ details: u.input_token_details,
320
+ })),
321
+ });
322
+
323
+ /** Foreign tokenizers diverge most on the uncalibrated first call */
324
+ expect(ratios[0]).toBeGreaterThan(0.3);
325
+ expect(ratios[0]).toBeLessThan(3);
326
+
327
+ /** Calibration has real counts from call 1 — tighter band */
328
+ const last = ratios[ratios.length - 1];
329
+ expect(last).toBeGreaterThan(0.5);
330
+ expect(last).toBeLessThan(2);
331
+ expect(Math.abs(last - 1)).toBeLessThanOrEqual(
332
+ Math.abs(ratios[0] - 1) + 0.2
333
+ );
334
+ }
335
+ );
336
+ }
337
+
338
+ it('stays accurate when pruning drops history under a small budget', async () => {
339
+ const capture = createCapture();
340
+ const history: BaseMessage[] = [];
341
+ for (let i = 0; i < 8; i++) {
342
+ history.push(
343
+ new HumanMessage(
344
+ `Question ${i}: please summarize the following filler passage. ` +
345
+ `${'The quick brown fox jumps over the lazy dog while counting tokens carefully. '.repeat(12)}`
346
+ ),
347
+ new AIMessage(
348
+ `Answer ${i}: the passage repeats a pangram about a fox and a dog while counting tokens. `.repeat(
349
+ 4
350
+ )
351
+ )
352
+ );
353
+ }
354
+ history.push(new HumanMessage('Reply with exactly one word: pruned'));
355
+
356
+ const indexTokenCountMap: Record<string, number> = {};
357
+ for (let i = 0; i < history.length; i++) {
358
+ indexTokenCountMap[i] = tokenCounter(history[i]);
359
+ }
360
+
361
+ const run = await Run.create<t.IState>({
362
+ runId: `ctx-acc-prune-${Date.now()}`,
363
+ graphConfig: {
364
+ type: 'standard',
365
+ llmConfig: {
366
+ provider: Providers.ANTHROPIC,
367
+ modelName,
368
+ apiKey: process.env.ANTHROPIC_API_KEY,
369
+ temperature: 0,
370
+ maxTokens: 64,
371
+ streaming: true,
372
+ streamUsage: true,
373
+ },
374
+ instructions: 'You follow instructions exactly.',
375
+ maxContextTokens: 1500,
376
+ },
377
+ returnContent: true,
378
+ skipCleanup: true,
379
+ customHandlers: capture.handlers,
380
+ tokenCounter,
381
+ indexTokenCountMap,
382
+ });
383
+
384
+ await run.processStream(
385
+ { messages: history },
386
+ createStreamConfig(`ctx-acc-prune-${Date.now()}`)
387
+ );
388
+
389
+ expect(capture.contextEvents).toHaveLength(1);
390
+ const event = capture.contextEvents[0];
391
+ const usage = capture.collectedUsage[0];
392
+
393
+ /** Pruning engaged: the full history exceeds what was sent */
394
+ expect(event.prePruneContextTokens).toBeGreaterThan(estimatedUsed(event));
395
+ expect(event.remainingContextTokens).toBeGreaterThanOrEqual(0);
396
+ expect(event.remainingContextTokens).toBeLessThan(event.contextBudget ?? 0);
397
+
398
+ const providerInput = usage.input_tokens ?? 0;
399
+ const ratio = estimatedUsed(event) / providerInput;
400
+ console.log('[ctx-accuracy] pruned-run estimate/provider ratio:', ratio, {
401
+ estimated: estimatedUsed(event),
402
+ providerInput,
403
+ prePrune: event.prePruneContextTokens,
404
+ budget: event.contextBudget,
405
+ });
406
+ expect(ratio).toBeGreaterThan(0.4);
407
+ expect(ratio).toBeLessThan(2.5);
408
+ });
409
+ });
@@ -0,0 +1,117 @@
1
+ import { HumanMessage, BaseMessage } from '@langchain/core/messages';
2
+ import type * as t from '@/types';
3
+ import { GraphEvents, Providers } from '@/common';
4
+ import { Run } from '@/run';
5
+
6
+ const charCounter: t.TokenCounter = (msg: BaseMessage): number => {
7
+ const content = msg.content;
8
+ if (typeof content === 'string') {
9
+ return content.length + 3;
10
+ }
11
+ return 3;
12
+ };
13
+
14
+ const llmConfig: t.LLMConfig = {
15
+ provider: Providers.OPENAI,
16
+ streaming: true,
17
+ streamUsage: false,
18
+ };
19
+
20
+ const streamConfig = {
21
+ configurable: { thread_id: 'context-usage-event' },
22
+ streamMode: 'values' as const,
23
+ version: 'v2' as const,
24
+ };
25
+
26
+ describe('ON_CONTEXT_USAGE event', () => {
27
+ jest.setTimeout(15000);
28
+
29
+ it('dispatches a post-prune context snapshot per model call', async () => {
30
+ const received: t.ContextUsageEvent[] = [];
31
+ const maxContextTokens = 4000;
32
+
33
+ const run = await Run.create<t.IState>({
34
+ runId: 'test-context-usage-event',
35
+ graphConfig: {
36
+ type: 'standard',
37
+ llmConfig,
38
+ instructions: 'You are a helpful assistant.',
39
+ maxContextTokens,
40
+ },
41
+ returnContent: true,
42
+ skipCleanup: true,
43
+ customHandlers: {
44
+ [GraphEvents.ON_CONTEXT_USAGE]: {
45
+ handle: (_event: string, data: t.StreamEventData): void => {
46
+ received.push(data as unknown as t.ContextUsageEvent);
47
+ },
48
+ },
49
+ },
50
+ tokenCounter: charCounter,
51
+ indexTokenCountMap: {},
52
+ });
53
+
54
+ run.Graph?.overrideTestModel(['Hello there!'], 1);
55
+ await run.processStream(
56
+ { messages: [new HumanMessage('hello')] },
57
+ streamConfig
58
+ );
59
+
60
+ expect(received).toHaveLength(1);
61
+ const event = received[0];
62
+ expect(event.runId).toBe('test-context-usage-event');
63
+ expect(event.agentId).toBeDefined();
64
+ expect(event.breakdown.maxContextTokens).toBe(maxContextTokens);
65
+ expect(event.breakdown.instructionTokens).toBeGreaterThan(0);
66
+ expect(event.breakdown.toolTokenCounts).toEqual({});
67
+ expect(event.contextBudget).toBeGreaterThan(0);
68
+ expect(event.contextBudget).toBeLessThanOrEqual(maxContextTokens);
69
+ expect(event.effectiveInstructionTokens).toBeGreaterThan(0);
70
+ expect(event.prePruneContextTokens).toBeGreaterThan(0);
71
+ expect(event.remainingContextTokens).toBeGreaterThan(0);
72
+ expect(event.remainingContextTokens).toBeLessThan(
73
+ event.contextBudget as number
74
+ );
75
+ expect(event.breakdown.instructionTokens).toBe(
76
+ event.effectiveInstructionTokens
77
+ );
78
+ expect(event.breakdown.availableForMessages).toBe(
79
+ (event.contextBudget as number) -
80
+ (event.effectiveInstructionTokens as number)
81
+ );
82
+ expect(event.breakdown.messageTokens).toBe(
83
+ (event.contextBudget as number) -
84
+ (event.effectiveInstructionTokens as number) -
85
+ (event.remainingContextTokens as number)
86
+ );
87
+ });
88
+
89
+ it('does not dispatch when no tokenCounter is configured', async () => {
90
+ const received: t.ContextUsageEvent[] = [];
91
+
92
+ const run = await Run.create<t.IState>({
93
+ runId: 'test-context-usage-event-no-counter',
94
+ graphConfig: {
95
+ type: 'standard',
96
+ llmConfig,
97
+ },
98
+ returnContent: true,
99
+ skipCleanup: true,
100
+ customHandlers: {
101
+ [GraphEvents.ON_CONTEXT_USAGE]: {
102
+ handle: (_event: string, data: t.StreamEventData): void => {
103
+ received.push(data as unknown as t.ContextUsageEvent);
104
+ },
105
+ },
106
+ },
107
+ });
108
+
109
+ run.Graph?.overrideTestModel(['Hello there!'], 1);
110
+ await run.processStream(
111
+ { messages: [new HumanMessage('hello')] },
112
+ streamConfig
113
+ );
114
+
115
+ expect(received).toHaveLength(0);
116
+ });
117
+ });