@librechat/agents 3.3.12 → 3.4.0

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 (109) hide show
  1. package/dist/cjs/graphs/Graph.cjs +10 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +1 -0
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuseSpanRegistry.cjs +6 -3
  6. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/index.cjs +35 -206
  8. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/google/index.cjs +19 -12
  12. package/dist/cjs/llm/google/index.cjs.map +1 -1
  13. package/dist/cjs/llm/mistral/index.cjs +26 -0
  14. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  15. package/dist/cjs/llm/openai/index.cjs +82 -80
  16. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  18. package/dist/cjs/llm/providers.cjs +19 -19
  19. package/dist/cjs/llm/providers.cjs.map +1 -1
  20. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  21. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  22. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  23. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  24. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  25. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  26. package/dist/cjs/main.cjs +18 -10
  27. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +34 -2
  28. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  29. package/dist/cjs/utils/tokens.cjs +76 -29
  30. package/dist/cjs/utils/tokens.cjs.map +1 -1
  31. package/dist/esm/graphs/Graph.mjs +10 -0
  32. package/dist/esm/graphs/Graph.mjs.map +1 -1
  33. package/dist/esm/instrumentation.mjs +1 -0
  34. package/dist/esm/instrumentation.mjs.map +1 -1
  35. package/dist/esm/langfuseSpanRegistry.mjs +6 -3
  36. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -1
  37. package/dist/esm/llm/anthropic/index.mjs +34 -205
  38. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  39. package/dist/esm/llm/bedrock/index.mjs +120 -240
  40. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  41. package/dist/esm/llm/google/index.mjs +19 -12
  42. package/dist/esm/llm/google/index.mjs.map +1 -1
  43. package/dist/esm/llm/mistral/index.mjs +26 -0
  44. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  45. package/dist/esm/llm/openai/index.mjs +82 -80
  46. package/dist/esm/llm/openai/index.mjs.map +1 -1
  47. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  48. package/dist/esm/llm/providers.mjs +3 -3
  49. package/dist/esm/llm/providers.mjs.map +1 -1
  50. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  51. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  52. package/dist/esm/llm/stream/smoother.mjs +365 -0
  53. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  54. package/dist/esm/llm/vertexai/index.mjs +13 -1
  55. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  56. package/dist/esm/main.mjs +4 -2
  57. package/dist/esm/tools/subagent/SubagentExecutor.mjs +34 -2
  58. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  59. package/dist/esm/utils/tokens.mjs +76 -30
  60. package/dist/esm/utils/tokens.mjs.map +1 -1
  61. package/dist/types/graphs/Graph.d.ts +3 -0
  62. package/dist/types/index.d.ts +3 -0
  63. package/dist/types/llm/google/index.d.ts +2 -0
  64. package/dist/types/llm/mistral/index.d.ts +11 -0
  65. package/dist/types/llm/openai/index.d.ts +20 -4
  66. package/dist/types/llm/openrouter/index.d.ts +4 -1
  67. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  68. package/dist/types/llm/stream/smoother.d.ts +95 -0
  69. package/dist/types/llm/vertexai/index.d.ts +2 -0
  70. package/dist/types/tools/subagent/SubagentExecutor.d.ts +3 -0
  71. package/dist/types/types/graph.d.ts +5 -0
  72. package/dist/types/types/llm.d.ts +21 -14
  73. package/dist/types/utils/tokens.d.ts +10 -0
  74. package/package.json +2 -2
  75. package/src/graphs/Graph.ts +11 -0
  76. package/src/index.ts +8 -0
  77. package/src/instrumentation.ts +1 -0
  78. package/src/langfuseSpanRegistry.ts +9 -0
  79. package/src/llm/anthropic/index.ts +85 -354
  80. package/src/llm/bedrock/index.ts +174 -390
  81. package/src/llm/bedrock/llm.spec.ts +2 -0
  82. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  83. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  84. package/src/llm/google/index.ts +17 -18
  85. package/src/llm/google/streamSmoothing.test.ts +121 -0
  86. package/src/llm/mistral/index.ts +33 -0
  87. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  88. package/src/llm/openai/deepseek.test.ts +56 -0
  89. package/src/llm/openai/index.ts +119 -126
  90. package/src/llm/openrouter/index.ts +4 -1
  91. package/src/llm/providers.ts +3 -3
  92. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  93. package/src/llm/stream/chunkAdapters.ts +317 -0
  94. package/src/llm/stream/reassembly.test.ts +241 -0
  95. package/src/llm/stream/smoother.bench.test.ts +155 -0
  96. package/src/llm/stream/smoother.test.ts +519 -0
  97. package/src/llm/stream/smoother.ts +574 -0
  98. package/src/llm/vertexai/index.ts +17 -1
  99. package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
  100. package/src/specs/langfuse-instrumentation.test.ts +26 -0
  101. package/src/specs/langfuse-span-registry.test.ts +17 -0
  102. package/src/specs/subagent.test.ts +44 -0
  103. package/src/specs/summarization.test.ts +5 -2
  104. package/src/specs/tokens.test.ts +159 -14
  105. package/src/tools/__tests__/SubagentExecutor.test.ts +48 -1
  106. package/src/tools/subagent/SubagentExecutor.ts +69 -5
  107. package/src/types/graph.ts +5 -0
  108. package/src/types/llm.ts +53 -36
  109. package/src/utils/tokens.ts +115 -30
@@ -0,0 +1,109 @@
1
+ import { expect, test, describe, jest } from '@jest/globals';
2
+ import { HumanMessage } from '@langchain/core/messages';
3
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
4
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
5
+ import { ChatVertexAI } from './index';
6
+
7
+ describe('Vertex stream smoothing', () => {
8
+ function textOutput(text: string): Record<string, unknown> {
9
+ return {
10
+ candidates: [
11
+ {
12
+ content: { role: 'model', parts: [{ text }] },
13
+ index: 0,
14
+ },
15
+ ],
16
+ };
17
+ }
18
+
19
+ async function runStream(
20
+ outputs: Record<string, unknown>[],
21
+ modelFields: Record<string, unknown> = {}
22
+ ): Promise<{
23
+ yielded: ChatGenerationChunk[];
24
+ dispatchedTokens: string[];
25
+ }> {
26
+ const model = new ChatVertexAI({
27
+ model: 'gemini-2.5-flash',
28
+ authOptions: {
29
+ projectId: 'test-project',
30
+ credentials: { client_email: 'test@test', private_key: 'test' },
31
+ },
32
+ ...modelFields,
33
+ });
34
+
35
+ let index = 0;
36
+ const fakeStream = {
37
+ get streamDone(): boolean {
38
+ return index > outputs.length;
39
+ },
40
+ async nextChunk(): Promise<unknown> {
41
+ const output = index < outputs.length ? outputs[index] : null;
42
+ index += 1;
43
+ return output;
44
+ },
45
+ };
46
+ (
47
+ model as unknown as {
48
+ streamedConnection: { request: unknown };
49
+ }
50
+ ).streamedConnection.request = jest.fn(async () => ({ data: fakeStream }));
51
+
52
+ const dispatchedTokens: string[] = [];
53
+ const runManager = {
54
+ handleCustomEvent: jest.fn(async () => undefined),
55
+ handleLLMNewToken: jest.fn(async (token: string) => {
56
+ dispatchedTokens.push(token);
57
+ }),
58
+ } as unknown as CallbackManagerForLLMRun;
59
+
60
+ const yielded: ChatGenerationChunk[] = [];
61
+ for await (const chunk of model._streamResponseChunks(
62
+ [new HumanMessage('hi')],
63
+ {} as Parameters<ChatVertexAI['_streamResponseChunks']>[1],
64
+ runManager
65
+ )) {
66
+ yielded.push(chunk);
67
+ }
68
+ return { yielded, dispatchedTokens };
69
+ }
70
+
71
+ test('splits large text outputs at stream boundaries with pacing', async () => {
72
+ const { yielded, dispatchedTokens } = await runStream(
73
+ [textOutput('alpha beta gamma')],
74
+ { _lc_stream_delay: 1 }
75
+ );
76
+
77
+ const texts = yielded.map((chunk) => chunk.text).filter(Boolean);
78
+ expect(texts).toEqual(['alpha ', 'beta ', 'gamma']);
79
+ expect(dispatchedTokens.filter(Boolean)).toEqual([
80
+ 'alpha ',
81
+ 'beta ',
82
+ 'gamma',
83
+ ]);
84
+ });
85
+
86
+ test('passes chunks through unsplit when smoothing is disabled', async () => {
87
+ const { yielded } = await runStream([textOutput('alpha beta gamma')], {
88
+ _lc_stream_delay: 0,
89
+ });
90
+
91
+ expect(yielded.map((chunk) => chunk.text).filter(Boolean)).toEqual([
92
+ 'alpha beta gamma',
93
+ ]);
94
+ });
95
+
96
+ test('defaults to 25ms adaptive smoothing with 0 disabling', () => {
97
+ const base = {
98
+ model: 'gemini-2.5-flash',
99
+ authOptions: {
100
+ projectId: 'test-project',
101
+ credentials: { client_email: 'test@test', private_key: 'test' },
102
+ },
103
+ };
104
+ expect(new ChatVertexAI(base)._lc_stream_delay).toBe(25);
105
+ expect(
106
+ new ChatVertexAI({ ...base, _lc_stream_delay: 0 })._lc_stream_delay
107
+ ).toBe(0);
108
+ });
109
+ });
@@ -320,6 +320,32 @@ describe('Langfuse instrumentation', () => {
320
320
  });
321
321
  });
322
322
 
323
+ it('does not reuse processors across different media upload policies', async () => {
324
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
325
+ initializeLangfuseTracing({
326
+ publicKey: 'pk-media',
327
+ secretKey: 'sk-media',
328
+ baseUrl: 'https://langfuse.media',
329
+ mediaUploadEnabled: false,
330
+ });
331
+ initializeLangfuseTracing({
332
+ publicKey: 'pk-media',
333
+ secretKey: 'sk-media',
334
+ baseUrl: 'https://langfuse.media',
335
+ mediaUploadEnabled: true,
336
+ });
337
+
338
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledTimes(2);
339
+ expect(mockLangfuseSpanProcessor).toHaveBeenNthCalledWith(
340
+ 1,
341
+ expect.objectContaining({ mediaUploadEnabled: false })
342
+ );
343
+ expect(mockLangfuseSpanProcessor).toHaveBeenNthCalledWith(
344
+ 2,
345
+ expect.objectContaining({ mediaUploadEnabled: true })
346
+ );
347
+ });
348
+
323
349
  it('reuses the isolated provider after initialization', async () => {
324
350
  process.env.LANGFUSE_SECRET_KEY = 'sk-test';
325
351
  process.env.LANGFUSE_PUBLIC_KEY = 'pk-test';
@@ -1,6 +1,7 @@
1
1
  import type { Span } from '@opentelemetry/api';
2
2
  import type * as t from '@/types';
3
3
  import {
4
+ getLangfuseSpanProcessorParams,
4
5
  getLangfuseManagedSpanDestination,
5
6
  registerLangfuseManagedSpan,
6
7
  resolveLangfuseDestinationKey,
@@ -41,7 +42,23 @@ describe('Langfuse span registry', () => {
41
42
  const redacting = resolveLangfuseDestinationKey(
42
43
  tenantConfig({ toolOutputTracing: { enabled: false } })
43
44
  );
45
+ const mediaDisabled = resolveLangfuseDestinationKey(
46
+ tenantConfig({ mediaUploadEnabled: false })
47
+ );
44
48
  expect(redacting).toBe(base);
49
+ expect(mediaDisabled).toBe(base);
50
+ });
51
+
52
+ it('passes media upload policy to the Langfuse span processor params', () => {
53
+ expect(
54
+ getLangfuseSpanProcessorParams(
55
+ tenantConfig({ mediaUploadEnabled: false })
56
+ )
57
+ ).toEqual(
58
+ expect.objectContaining({
59
+ mediaUploadEnabled: false,
60
+ })
61
+ );
45
62
  });
46
63
 
47
64
  it('separates destinations by credentials, endpoint, and environment', () => {
@@ -21,6 +21,7 @@ import * as providers from '@/llm/providers';
21
21
  import { Run } from '@/run';
22
22
 
23
23
  const CHILD_RESPONSE = 'Research result: Paris is the capital of France.';
24
+ const OVERRIDDEN_CHILD_RESPONSE = 'Deterministic child override result.';
24
25
 
25
26
  const callerConfig: Partial<RunnableConfig> & {
26
27
  version: 'v1' | 'v2';
@@ -226,6 +227,49 @@ describe('Subagent Integration', () => {
226
227
  expect(subagentTool).toBeDefined();
227
228
  });
228
229
 
230
+ it('only applies an explicitly configured subagent model override', async () => {
231
+ const invokeSubagent = async (
232
+ overrideSubagents: boolean
233
+ ): Promise<string> => {
234
+ const run = await Run.create<t.IState>({
235
+ runId: `subagent-model-override-${overrideSubagents}-${Date.now()}`,
236
+ graphConfig: {
237
+ type: 'standard',
238
+ agents: [createParentAgent()],
239
+ },
240
+ returnContent: true,
241
+ skipCleanup: true,
242
+ });
243
+ const graph = run.Graph as StandardGraph;
244
+ const model = new FakeListChatModel({
245
+ responses: [OVERRIDDEN_CHILD_RESPONSE],
246
+ });
247
+ graph.overrideModel = model;
248
+ if (overrideSubagents) {
249
+ graph.setSubagentModelOverride(model);
250
+ }
251
+
252
+ const context = graph.agentContexts.get('parent');
253
+ const subagentTool = (context?.graphTools as t.GenericTool[]).find(
254
+ (tool) => 'name' in tool && tool.name === Constants.SUBAGENT
255
+ );
256
+ expect(subagentTool).toBeDefined();
257
+
258
+ return String(
259
+ await subagentTool!.invoke(
260
+ {
261
+ description: 'What is the capital of France?',
262
+ subagent_type: 'researcher',
263
+ },
264
+ callerConfig
265
+ )
266
+ );
267
+ };
268
+
269
+ await expect(invokeSubagent(false)).resolves.toBe(CHILD_RESPONSE);
270
+ await expect(invokeSubagent(true)).resolves.toBe(OVERRIDDEN_CHILD_RESPONSE);
271
+ });
272
+
229
273
  it('inherits eager event-tool settings into self-spawn child graphs', async () => {
230
274
  const originalCreateWorkflow = StandardGraph.prototype.createWorkflow;
231
275
  const observedChildGraphs: Array<{
@@ -1162,7 +1162,7 @@ const hasBedrock = hasEveryEnv(requiredBedrockEnv);
1162
1162
 
1163
1163
  const hasOpenAI = hasEnv('OPENAI_API_KEY');
1164
1164
  (hasOpenAI ? describe : describe.skip)('OpenAI Summarization E2E', () => {
1165
- jest.setTimeout(120_000);
1165
+ jest.setTimeout(240_000);
1166
1166
 
1167
1167
  const agentProvider = Providers.OPENAI;
1168
1168
  const streamConfig = {
@@ -1190,6 +1190,9 @@ const hasOpenAI = hasEnv('OPENAI_API_KEY');
1190
1190
  agentProvider,
1191
1191
  summarizationProvider: Providers.OPENAI,
1192
1192
  summarizationModel: 'gpt-4.1-mini',
1193
+ llmConfigOverride: {
1194
+ model: 'gpt-4.1-mini',
1195
+ },
1193
1196
  maxContextTokens: maxTokens,
1194
1197
  instructions:
1195
1198
  'You are a helpful math tutor. Use the calculator tool for ALL computations. Keep responses concise.',
@@ -1320,7 +1323,7 @@ const hasOpenAI = hasEnv('OPENAI_API_KEY');
1320
1323
  ` OpenAI summary: "${getSummaryText(completePayload.summary).substring(0, 200)}…"`
1321
1324
  );
1322
1325
  console.log(` Final messages: ${conversationHistory.length}`);
1323
- }, 120_000);
1326
+ }, 240_000);
1324
1327
  });
1325
1328
 
1326
1329
  // ---------------------------------------------------------------------------
@@ -13,6 +13,7 @@ import {
13
13
  estimateImageBlockTokens,
14
14
  estimateDocumentBlockTokens,
15
15
  estimateTimedMediaBlockTokens,
16
+ UnsafeTokenMeasurementError,
16
17
  } from '@/utils/tokens';
17
18
 
18
19
  /** Builds a minimal PNG data URI whose IHDR encodes the given dimensions. */
@@ -71,6 +72,32 @@ describe('createTokenCounter with different encodings', () => {
71
72
  expect(count).toBeGreaterThan(0);
72
73
  });
73
74
 
75
+ test('claude correction keeps proxy-backed tool history safely measurable', async () => {
76
+ let getCalls = 0;
77
+ const args = new Proxy(
78
+ { query: 'select * from reports' },
79
+ {
80
+ get(target, property, receiver) {
81
+ getCalls++;
82
+ return Reflect.get(target, property, receiver);
83
+ },
84
+ }
85
+ );
86
+ const message = {
87
+ content: '',
88
+ tool_calls: [{ id: 'proxy-call', name: 'query', args }],
89
+ getType: () => 'ai',
90
+ } as unknown as AIMessage;
91
+ const counter = await createTokenCounter('claude');
92
+
93
+ const count = counter(message);
94
+
95
+ expect(Number.isSafeInteger(count)).toBe(true);
96
+ expect(count).toBeGreaterThan(0);
97
+ expect(count).toBeLessThan(1_000);
98
+ expect(getCalls).toBe(0);
99
+ });
100
+
74
101
  test('o200k_base encoding produces valid token counts', async () => {
75
102
  const counter = await createTokenCounter('o200k_base');
76
103
  const msg = new HumanMessage('Hello, world!');
@@ -110,6 +137,22 @@ describe('getTokenCountForMessage', () => {
110
137
  expect(count).toBe(600_003);
111
138
  });
112
139
 
140
+ test.each([
141
+ -1,
142
+ 1.5,
143
+ Number.MAX_SAFE_INTEGER,
144
+ Number.MAX_SAFE_INTEGER + 1,
145
+ Number.POSITIVE_INFINITY,
146
+ Number.NaN,
147
+ ])('rejects an unsafe tokenizer result of %s', (unsafeCount) => {
148
+ expect(() =>
149
+ getTokenCountForMessage(
150
+ new HumanMessage('unsafe count'),
151
+ () => unsafeCount
152
+ )
153
+ ).toThrow(UnsafeTokenMeasurementError);
154
+ });
155
+
113
156
  test('bounds direct string tool args before tokenization and charges omitted characters', () => {
114
157
  const callbackLengths: number[] = [];
115
158
  const args = 'x'.repeat(300_000);
@@ -153,10 +196,13 @@ describe('getTokenCountForMessage', () => {
153
196
  );
154
197
 
155
198
  expect(Math.max(...callbackLengths)).toBeLessThanOrEqual(200_000);
156
- expect(count).toBeGreaterThan(payload.length);
199
+ // A traversal-work sentinel is unknown size, not real context usage.
200
+ // The provider-input projection will compact the value before invoke.
201
+ expect(count).toBeGreaterThan(0);
202
+ expect(count).toBeLessThan(Number.MAX_SAFE_INTEGER);
157
203
  });
158
204
 
159
- test('fails closed when prototype traps throw', () => {
205
+ test('counts an opaque proxy argument as its bounded provider-safe placeholder', () => {
160
206
  let prototypeCalls = 0;
161
207
  const args = new Proxy(
162
208
  { safe: true },
@@ -171,11 +217,11 @@ describe('getTokenCountForMessage', () => {
171
217
  expect(hasUnsafeStructuredSerialization(args)).toBe(true);
172
218
  expect(
173
219
  getTokenCountForMessage(messageWithToolArgs(args), (text) => text.length)
174
- ).toBe(Number.MAX_SAFE_INTEGER);
220
+ ).toBeLessThan(Number.MAX_SAFE_INTEGER);
175
221
  expect(prototypeCalls).toBeLessThanOrEqual(2);
176
222
  });
177
223
 
178
- test('fails closed without recursing through self-referential prototype proxies', () => {
224
+ test('does not recurse through self-referential prototype proxies', () => {
179
225
  let prototypeCalls = 0;
180
226
  const args: Record<string, unknown> = new Proxy<Record<string, unknown>>(
181
227
  {},
@@ -190,11 +236,11 @@ describe('getTokenCountForMessage', () => {
190
236
  expect(hasUnsafeStructuredSerialization(args)).toBe(true);
191
237
  expect(
192
238
  getTokenCountForMessage(messageWithToolArgs(args), (text) => text.length)
193
- ).toBe(Number.MAX_SAFE_INTEGER);
239
+ ).toBeLessThan(Number.MAX_SAFE_INTEGER);
194
240
  expect(prototypeCalls).toBeLessThanOrEqual(2);
195
241
  });
196
242
 
197
- test('fails closed when descriptor and own-key proxy traps throw', () => {
243
+ test('uses bounded placeholders when descriptor and own-key proxy traps throw', () => {
198
244
  const descriptorProxy = new Proxy(
199
245
  {},
200
246
  {
@@ -219,11 +265,11 @@ describe('getTokenCountForMessage', () => {
219
265
  messageWithToolArgs(args),
220
266
  (text) => text.length
221
267
  )
222
- ).toBe(Number.MAX_SAFE_INTEGER);
268
+ ).toBeLessThan(Number.MAX_SAFE_INTEGER);
223
269
  }
224
270
  });
225
271
 
226
- test('fails closed on get traps and revoked proxies without invoking them', () => {
272
+ test('does not invoke get traps or revoked proxies while measuring arguments', () => {
227
273
  let getCalls = 0;
228
274
  const getProxy = new Proxy(
229
275
  {},
@@ -250,12 +296,12 @@ describe('getTokenCountForMessage', () => {
250
296
  messageWithToolArgs(args),
251
297
  (text) => text.length
252
298
  )
253
- ).toBe(Number.MAX_SAFE_INTEGER);
299
+ ).toBeLessThan(Number.MAX_SAFE_INTEGER);
254
300
  }
255
301
  expect(getCalls).toBe(0);
256
302
  });
257
303
 
258
- test('fails closed on proxied content blocks without invoking their traps', () => {
304
+ test('reports a typed terminal error for a proxied content block', () => {
259
305
  let getCalls = 0;
260
306
  const contentBlock = new Proxy(
261
307
  { type: 'text', text: 'safe' },
@@ -271,12 +317,88 @@ describe('getTokenCountForMessage', () => {
271
317
  getType: () => 'tool',
272
318
  } as unknown as ToolMessage;
273
319
 
274
- expect(getTokenCountForMessage(message, (text) => text.length)).toBe(
275
- Number.MAX_SAFE_INTEGER
320
+ expect(() => getTokenCountForMessage(message, (text) => text.length)).toThrow(
321
+ UnsafeTokenMeasurementError
276
322
  );
323
+ try {
324
+ getTokenCountForMessage(message, (text) => text.length);
325
+ } catch (error) {
326
+ expect(JSON.parse((error as Error).message)).toEqual({
327
+ type: 'unsafe_token_measurement',
328
+ reason: 'content_proxy',
329
+ path: 'content[0]',
330
+ });
331
+ }
277
332
  expect(getCalls).toBe(0);
278
333
  });
279
334
 
335
+ test('reports typed errors for unsafe message and metadata branches', () => {
336
+ let getterCalls = 0;
337
+ const messageProxy = new Proxy(new HumanMessage('secret payload'), {
338
+ get() {
339
+ getterCalls++;
340
+ throw new Error('message read denied');
341
+ },
342
+ });
343
+ const metadataProxy = new Proxy(
344
+ {},
345
+ {
346
+ get() {
347
+ getterCalls++;
348
+ throw new Error('metadata read denied');
349
+ },
350
+ }
351
+ );
352
+ const accessorMetadata = {};
353
+ Object.defineProperty(accessorMetadata, 'type', {
354
+ get() {
355
+ getterCalls++;
356
+ return 'computer_call_output';
357
+ },
358
+ });
359
+ const cases = [
360
+ {
361
+ message: messageProxy,
362
+ reason: 'message_proxy',
363
+ path: 'message',
364
+ },
365
+ {
366
+ message: {
367
+ content: '',
368
+ additional_kwargs: metadataProxy,
369
+ getType: () => 'tool',
370
+ } as unknown as ToolMessage,
371
+ reason: 'metadata_proxy',
372
+ path: 'additional_kwargs',
373
+ },
374
+ {
375
+ message: {
376
+ content: '',
377
+ additional_kwargs: accessorMetadata,
378
+ getType: () => 'tool',
379
+ } as unknown as ToolMessage,
380
+ reason: 'metadata_accessor',
381
+ path: 'additional_kwargs.type',
382
+ },
383
+ ];
384
+
385
+ for (const testCase of cases) {
386
+ try {
387
+ getTokenCountForMessage(testCase.message, (text) => text.length);
388
+ throw new Error('Expected token measurement to fail');
389
+ } catch (error) {
390
+ expect(error).toBeInstanceOf(UnsafeTokenMeasurementError);
391
+ expect(JSON.parse((error as Error).message)).toEqual({
392
+ type: 'unsafe_token_measurement',
393
+ reason: testCase.reason,
394
+ path: testCase.path,
395
+ });
396
+ expect((error as Error).message).not.toContain('secret payload');
397
+ }
398
+ }
399
+ expect(getterCalls).toBe(0);
400
+ });
401
+
280
402
  test('detects inherited accessors without invoking them', () => {
281
403
  let accessorCalls = 0;
282
404
  const prototype = {};
@@ -292,10 +414,32 @@ describe('getTokenCountForMessage', () => {
292
414
  expect(hasUnsafeStructuredSerialization(args)).toBe(true);
293
415
  expect(
294
416
  getTokenCountForMessage(messageWithToolArgs(args), (text) => text.length)
295
- ).toBe(Number.MAX_SAFE_INTEGER);
417
+ ).toBeLessThan(Number.MAX_SAFE_INTEGER);
296
418
  expect(accessorCalls).toBe(0);
297
419
  });
298
420
 
421
+ test('counts safely serializable proxy tool-call history without invoking getters', () => {
422
+ let getCalls = 0;
423
+ const args = new Proxy(
424
+ { query: 'select * from reports' },
425
+ {
426
+ get(target, property, receiver) {
427
+ getCalls++;
428
+ return Reflect.get(target, property, receiver);
429
+ },
430
+ }
431
+ );
432
+
433
+ const count = getTokenCountForMessage(
434
+ messageWithToolArgs(args),
435
+ (text) => text.length
436
+ );
437
+
438
+ expect(count).toBeGreaterThan(0);
439
+ expect(count).toBeLessThan(Number.MAX_SAFE_INTEGER);
440
+ expect(getCalls).toBe(0);
441
+ });
442
+
299
443
  test('counts tool_calls-only names and arguments', () => {
300
444
  const message = new AIMessage({
301
445
  content: '',
@@ -334,7 +478,8 @@ describe('getTokenCountForMessage', () => {
334
478
 
335
479
  const count = getTokenCountForMessage(message, (text) => text.length);
336
480
 
337
- expect(count).toBeGreaterThanOrEqual(Number.MAX_SAFE_INTEGER);
481
+ expect(count).toBeLessThan(Number.MAX_SAFE_INTEGER);
482
+ expect(count).toBeGreaterThan(0);
338
483
  expect(toJSONCalls).toBe(0);
339
484
  });
340
485
 
@@ -67,6 +67,50 @@ describe('filterSubagentResult', () => {
67
67
  expect(filterSubagentResult(messages)).toBe('First part.\nSecond part.');
68
68
  });
69
69
 
70
+ it('prefers final text_delta blocks over earlier AI text', () => {
71
+ const messages: BaseMessage[] = [
72
+ new AIMessage({
73
+ content: [
74
+ { type: 'text', text: 'Let me search.' },
75
+ { type: 'tool_use', id: 'call_1', name: 'search', input: {} },
76
+ ],
77
+ }),
78
+ new ToolMessage({ content: 'result', tool_call_id: 'call_1' }),
79
+ new AIMessage({
80
+ content: [
81
+ { type: 'text_delta', index: 0, text: 'Streamed ' },
82
+ { type: 'text_delta', index: 0, text: 'result.' },
83
+ ],
84
+ }),
85
+ ];
86
+ expect(filterSubagentResult(messages)).toBe('Streamed result.');
87
+ });
88
+
89
+ it('keeps annotation-only text blocks within a text_delta sequence', () => {
90
+ const messages: BaseMessage[] = [
91
+ new AIMessage({
92
+ content: [
93
+ { type: 'text_delta', index: 0, text: 'Cited ' },
94
+ { type: 'text', index: 0, citations: [{ url: 'source' }] },
95
+ { type: 'text_delta', index: 0, text: 'answer.' },
96
+ ],
97
+ }),
98
+ ];
99
+ expect(filterSubagentResult(messages)).toBe('Cited answer.');
100
+ });
101
+
102
+ it('separates text_delta blocks with different indexes', () => {
103
+ const messages: BaseMessage[] = [
104
+ new AIMessage({
105
+ content: [
106
+ { type: 'text_delta', index: 0, text: 'First.' },
107
+ { type: 'text_delta', index: 1, text: 'Second.' },
108
+ ],
109
+ }),
110
+ ];
111
+ expect(filterSubagentResult(messages)).toBe('First.\nSecond.');
112
+ });
113
+
70
114
  it('strips tool_use blocks from array content', () => {
71
115
  const messages: BaseMessage[] = [
72
116
  new AIMessage({
@@ -470,7 +514,10 @@ describe('SubagentExecutor', () => {
470
514
  }) as unknown as StandardGraph,
471
515
  });
472
516
  await expect(
473
- executor.execute({ description: 'Do something', subagentType: 'researcher' })
517
+ executor.execute({
518
+ description: 'Do something',
519
+ subagentType: 'researcher',
520
+ })
474
521
  ).rejects.toBeInstanceOf(StreamLimitExceededError);
475
522
  });
476
523