@librechat/agents 3.9.3 → 3.9.4

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 (104) hide show
  1. package/README.md +32 -0
  2. package/dist/cjs/common/enum.cjs +2 -0
  3. package/dist/cjs/common/enum.cjs.map +1 -1
  4. package/dist/cjs/events.cjs +11 -0
  5. package/dist/cjs/events.cjs.map +1 -1
  6. package/dist/cjs/graphs/Graph.cjs +41 -1
  7. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  8. package/dist/cjs/graphs/acceptedModelResponse.cjs +168 -0
  9. package/dist/cjs/graphs/acceptedModelResponse.cjs.map +1 -0
  10. package/dist/cjs/llm/invoke.cjs +10 -5
  11. package/dist/cjs/llm/invoke.cjs.map +1 -1
  12. package/dist/cjs/llm/streamLimits.cjs +1 -1
  13. package/dist/cjs/llm/streamLimits.cjs.map +1 -1
  14. package/dist/cjs/main.cjs +2 -0
  15. package/dist/cjs/messages/fading.cjs +14 -6
  16. package/dist/cjs/messages/fading.cjs.map +1 -1
  17. package/dist/cjs/messages/prune.cjs +95 -36
  18. package/dist/cjs/messages/prune.cjs.map +1 -1
  19. package/dist/cjs/openai/index.cjs +2 -0
  20. package/dist/cjs/openai/index.cjs.map +1 -1
  21. package/dist/cjs/openai/toolProjection.cjs +196 -0
  22. package/dist/cjs/openai/toolProjection.cjs.map +1 -0
  23. package/dist/cjs/run.cjs +8 -1
  24. package/dist/cjs/run.cjs.map +1 -1
  25. package/dist/cjs/session/AgentSession.cjs +1 -1
  26. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  27. package/dist/cjs/stream.cjs +9 -4
  28. package/dist/cjs/stream.cjs.map +1 -1
  29. package/dist/cjs/tools/ToolNode.cjs +2 -1
  30. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs +4 -1
  32. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  33. package/dist/cjs/utils/acceptedToolArguments.cjs +143 -0
  34. package/dist/cjs/utils/acceptedToolArguments.cjs.map +1 -0
  35. package/dist/esm/common/enum.mjs +2 -0
  36. package/dist/esm/common/enum.mjs.map +1 -1
  37. package/dist/esm/events.mjs +11 -0
  38. package/dist/esm/events.mjs.map +1 -1
  39. package/dist/esm/graphs/Graph.mjs +41 -1
  40. package/dist/esm/graphs/Graph.mjs.map +1 -1
  41. package/dist/esm/graphs/acceptedModelResponse.mjs +165 -0
  42. package/dist/esm/graphs/acceptedModelResponse.mjs.map +1 -0
  43. package/dist/esm/llm/invoke.mjs +10 -5
  44. package/dist/esm/llm/invoke.mjs.map +1 -1
  45. package/dist/esm/llm/streamLimits.mjs +1 -1
  46. package/dist/esm/llm/streamLimits.mjs.map +1 -1
  47. package/dist/esm/main.mjs +3 -3
  48. package/dist/esm/messages/fading.mjs +14 -7
  49. package/dist/esm/messages/fading.mjs.map +1 -1
  50. package/dist/esm/messages/prune.mjs +95 -37
  51. package/dist/esm/messages/prune.mjs.map +1 -1
  52. package/dist/esm/openai/index.mjs +2 -1
  53. package/dist/esm/openai/index.mjs.map +1 -1
  54. package/dist/esm/openai/toolProjection.mjs +196 -0
  55. package/dist/esm/openai/toolProjection.mjs.map +1 -0
  56. package/dist/esm/run.mjs +8 -1
  57. package/dist/esm/run.mjs.map +1 -1
  58. package/dist/esm/session/AgentSession.mjs +1 -1
  59. package/dist/esm/session/AgentSession.mjs.map +1 -1
  60. package/dist/esm/stream.mjs +9 -4
  61. package/dist/esm/stream.mjs.map +1 -1
  62. package/dist/esm/tools/ToolNode.mjs +2 -1
  63. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentReplay.mjs +5 -2
  65. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  66. package/dist/esm/utils/acceptedToolArguments.mjs +142 -0
  67. package/dist/esm/utils/acceptedToolArguments.mjs.map +1 -0
  68. package/dist/types/common/enum.d.ts +4 -0
  69. package/dist/types/graphs/Graph.d.ts +3 -1
  70. package/dist/types/graphs/acceptedModelResponse.d.ts +15 -0
  71. package/dist/types/messages/fading.d.ts +14 -2
  72. package/dist/types/messages/prune.d.ts +7 -0
  73. package/dist/types/openai/arguments.d.ts +2 -0
  74. package/dist/types/openai/index.d.ts +2 -0
  75. package/dist/types/openai/toolProjection.d.ts +30 -0
  76. package/dist/types/run.d.ts +1 -0
  77. package/dist/types/tools/ToolNode.d.ts +1 -1
  78. package/dist/types/types/graph.d.ts +5 -3
  79. package/dist/types/types/run.d.ts +5 -0
  80. package/dist/types/types/stream.d.ts +22 -1
  81. package/dist/types/types/tools.d.ts +2 -0
  82. package/dist/types/utils/acceptedToolArguments.d.ts +10 -0
  83. package/package.json +1 -1
  84. package/src/common/enum.ts +5 -0
  85. package/src/events.ts +24 -1
  86. package/src/graphs/Graph.ts +95 -0
  87. package/src/graphs/acceptedModelResponse.ts +307 -0
  88. package/src/llm/invoke.ts +39 -14
  89. package/src/llm/streamLimits.ts +1 -1
  90. package/src/messages/fading.ts +30 -5
  91. package/src/messages/prune.ts +168 -55
  92. package/src/openai/arguments.ts +2 -0
  93. package/src/openai/index.ts +6 -0
  94. package/src/openai/toolProjection.ts +318 -0
  95. package/src/run.ts +21 -0
  96. package/src/session/AgentSession.ts +2 -2
  97. package/src/stream.ts +21 -1
  98. package/src/tools/ToolNode.ts +5 -0
  99. package/src/tools/subagent/SubagentReplay.ts +10 -9
  100. package/src/types/graph.ts +15 -9
  101. package/src/types/run.ts +5 -0
  102. package/src/types/stream.ts +26 -0
  103. package/src/types/tools.ts +2 -0
  104. package/src/utils/acceptedToolArguments.ts +204 -0
@@ -0,0 +1,307 @@
1
+ import { types } from 'node:util';
2
+ import type { AIMessageChunk } from '@langchain/core/messages';
3
+ import type { ToolCall } from '@langchain/core/messages/tool';
4
+ import type { ModelResponseEvent } from '@/types';
5
+ import {
6
+ cloneToolArguments,
7
+ serializeToolArguments,
8
+ } from '@/utils/acceptedToolArguments';
9
+ import { linkStreamLimitCanonical } from '@/llm/streamLimits';
10
+
11
+ const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024;
12
+ const MAX_SNAPSHOT_CALLS = 1024;
13
+
14
+ export class InvalidModelToolCallError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = 'InvalidModelToolCallError';
18
+ }
19
+ }
20
+
21
+ /** Detach executable calls before dispatch. Invalid diagnostics remain available
22
+ * for ToolNode to synthesize paired error results; accepted projection rejects them.
23
+ */
24
+ export function detachValidatedModelToolCalls(
25
+ message: AIMessageChunk,
26
+ partial = false
27
+ ): void {
28
+ try {
29
+ // Inspect every collection before replacing any provider-owned data. Raw
30
+ // fragments are read by accounting and handlers even when tool_calls is empty.
31
+ const toolCalls = snapshotToolCalls(message, true, partial);
32
+ const chunks = snapshotToolRecords(message, 'tool_call_chunks');
33
+ const invalid = snapshotToolRecords(message, 'invalid_tool_calls');
34
+ Object.defineProperties(message, {
35
+ tool_calls: {
36
+ value: toolCalls,
37
+ enumerable: true,
38
+ writable: true,
39
+ configurable: true,
40
+ },
41
+ tool_call_chunks: {
42
+ value: chunks,
43
+ enumerable: true,
44
+ writable: true,
45
+ configurable: true,
46
+ },
47
+ invalid_tool_calls: {
48
+ value: invalid,
49
+ enumerable: true,
50
+ writable: true,
51
+ configurable: true,
52
+ },
53
+ });
54
+ } catch (error) {
55
+ throw new InvalidModelToolCallError(
56
+ error instanceof Error
57
+ ? error.message
58
+ : 'Accepted model response contains non-serializable tool calls'
59
+ );
60
+ }
61
+ }
62
+
63
+ /** Inspect original descriptors before a getter, proxy, or custom instance can
64
+ * be normalized away. The bound applies to each model message independently.
65
+ */
66
+ function snapshotToolCalls(
67
+ finalResponse: AIMessageChunk,
68
+ allowInvalidDiagnostics: boolean,
69
+ allowFragmentArgs = false
70
+ ): ToolCall[] {
71
+ // Read own data descriptors, not accessors supplied by a custom model. Invalid
72
+ // diagnostics are rejected in O(1); cloning them can run getters and bypass
73
+ // the valid-call snapshot's byte/count limits.
74
+ if (types.isProxy(finalResponse)) {
75
+ throw new Error(
76
+ 'Accepted model response contains non-serializable tool calls'
77
+ );
78
+ }
79
+ const calls = Object.getOwnPropertyDescriptor(finalResponse, 'tool_calls');
80
+ const diagnostics = Object.getOwnPropertyDescriptor(
81
+ finalResponse,
82
+ 'invalid_tool_calls'
83
+ );
84
+ const readArray = (descriptor: PropertyDescriptor | undefined): unknown[] => {
85
+ if (descriptor === undefined) return [];
86
+ if (!('value' in descriptor)) {
87
+ throw new Error(
88
+ 'Accepted model response contains non-serializable tool calls'
89
+ );
90
+ }
91
+ const value: unknown = descriptor.value;
92
+ if (value === undefined) return [];
93
+ if (types.isProxy(value) || !Array.isArray(value)) {
94
+ throw new Error(
95
+ 'Accepted model response contains non-serializable tool calls'
96
+ );
97
+ }
98
+ return value;
99
+ };
100
+ if (readArray(diagnostics).length > 0 && !allowInvalidDiagnostics) {
101
+ throw new Error('Accepted model response contains invalid tool calls');
102
+ }
103
+ const source = readArray(calls);
104
+ if (!allowInvalidDiagnostics && source.length > MAX_SNAPSHOT_CALLS) {
105
+ throw new Error('Accepted model response exceeds snapshot limits');
106
+ }
107
+ const toolCalls: ToolCall[] = [];
108
+ let remaining = allowInvalidDiagnostics ? Infinity : MAX_SNAPSHOT_BYTES;
109
+ for (let index = 0; index < source.length; index++) {
110
+ const entry = Object.getOwnPropertyDescriptor(source, String(index));
111
+ if (entry == null || !('value' in entry) || entry.enumerable !== true) {
112
+ throw new Error(
113
+ 'Accepted model response contains non-serializable tool calls'
114
+ );
115
+ }
116
+ const call: unknown = entry.value;
117
+ if (call == null || typeof call !== 'object' || types.isProxy(call)) {
118
+ throw new Error(
119
+ 'Accepted model response contains non-serializable tool calls'
120
+ );
121
+ }
122
+ const name = Object.getOwnPropertyDescriptor(call, 'name');
123
+ const originalId = Object.getOwnPropertyDescriptor(call, 'id');
124
+ const originalArgs = Object.getOwnPropertyDescriptor(call, 'args');
125
+ if (
126
+ name == null ||
127
+ !('value' in name) ||
128
+ typeof name.value !== 'string' ||
129
+ originalArgs == null ||
130
+ !('value' in originalArgs) ||
131
+ (originalId != null && !('value' in originalId))
132
+ ) {
133
+ throw new Error(
134
+ 'Accepted model response contains non-serializable tool calls'
135
+ );
136
+ }
137
+ const providerId: unknown = originalId?.value;
138
+ if (providerId !== undefined && typeof providerId !== 'string') {
139
+ throw new Error(
140
+ 'Accepted model response contains non-serializable tool calls'
141
+ );
142
+ }
143
+ let args: ToolCall['args'];
144
+ if (allowInvalidDiagnostics) {
145
+ // Callback streams may carry a not-yet-plannable argument string. It is
146
+ // safe scalar data, but must not become an accepted executable call.
147
+ args =
148
+ allowFragmentArgs && typeof originalArgs.value === 'string'
149
+ ? (originalArgs.value as unknown as ToolCall['args'])
150
+ : cloneToolArguments(originalArgs.value);
151
+ } else {
152
+ remaining -=
153
+ Buffer.byteLength(name.value, 'utf8') +
154
+ (typeof providerId === 'string'
155
+ ? Buffer.byteLength(providerId, 'utf8')
156
+ : 0);
157
+ const encoded = serializeToolArguments(originalArgs.value, remaining);
158
+ remaining -= Buffer.byteLength(encoded, 'utf8');
159
+ args = JSON.parse(encoded);
160
+ }
161
+ toolCalls.push({
162
+ name: name.value,
163
+ id: providerId,
164
+ args,
165
+ type: 'tool_call',
166
+ });
167
+ }
168
+ return toolCalls;
169
+ }
170
+
171
+ /** Only the accepted final response becomes a host-visible model result. */
172
+ export function snapshotAcceptedModelResponse(
173
+ finalResponse: AIMessageChunk,
174
+ id: string,
175
+ agentId: string,
176
+ providerExecutedIds?: ReadonlySet<string>,
177
+ clientDelegatedToolNames?: ReadonlySet<string>
178
+ ): ModelResponseEvent {
179
+ const toolCalls = snapshotToolCalls(finalResponse, false);
180
+ const hasClientCall = toolCalls.some(
181
+ (call) => clientDelegatedToolNames?.has(call.name) === true
182
+ );
183
+ if (
184
+ hasClientCall &&
185
+ (toolCalls.some(
186
+ (call) =>
187
+ clientDelegatedToolNames?.has(call.name) !== true ||
188
+ (call.id != null && providerExecutedIds?.has(call.id) === true)
189
+ ) ||
190
+ (finalResponse.invalid_tool_calls?.length ?? 0) > 0)
191
+ ) {
192
+ throw new InvalidModelToolCallError(
193
+ 'Mixed client and graph-owned tool calls require separate model turns'
194
+ );
195
+ }
196
+ return {
197
+ type: 'model_response',
198
+ id,
199
+ agentId,
200
+ ...(finalResponse.id != null ? { messageId: finalResponse.id } : {}),
201
+ toolCalls,
202
+ toolCallDispositions: toolCalls.map((call) => {
203
+ if (hasClientCall) return 'client';
204
+ return call.id != null && providerExecutedIds?.has(call.id) === true
205
+ ? 'provider'
206
+ : 'sdk';
207
+ }),
208
+ invalidToolCalls: [],
209
+ };
210
+ }
211
+
212
+ /** These records contain only scalar fields, unlike parsed tool arguments. Never
213
+ * spread or iterate provider records until their original descriptors pass.
214
+ */
215
+ function snapshotToolRecords(
216
+ message: AIMessageChunk,
217
+ field: 'tool_call_chunks' | 'invalid_tool_calls'
218
+ ): Record<string, unknown>[] {
219
+ function invalid(): never {
220
+ throw new Error(
221
+ 'Accepted model response contains non-serializable tool calls'
222
+ );
223
+ }
224
+ const descriptor = Object.getOwnPropertyDescriptor(message, field);
225
+ if (descriptor == null) return [];
226
+ if (!('value' in descriptor)) invalid();
227
+ const source: unknown = descriptor.value;
228
+ if (source === undefined) return [];
229
+ if (source == null || types.isProxy(source) || !Array.isArray(source))
230
+ invalid();
231
+ const result: Record<string, unknown>[] = [];
232
+ for (let index = 0; index < source.length; index++) {
233
+ const entry = Object.getOwnPropertyDescriptor(source, String(index));
234
+ if (entry == null || !('value' in entry)) invalid();
235
+ const record: unknown = entry.value;
236
+ if (record == null || typeof record !== 'object' || types.isProxy(record))
237
+ invalid();
238
+ const copy: Record<string, unknown> = {};
239
+ for (const key of Reflect.ownKeys(record)) {
240
+ if (typeof key !== 'string') invalid();
241
+ const property = Object.getOwnPropertyDescriptor(record, key);
242
+ if (property == null || !('value' in property)) invalid();
243
+ const value: unknown = property.value;
244
+ if (
245
+ value != null &&
246
+ (key === 'index'
247
+ ? typeof value !== 'number' ||
248
+ !Number.isSafeInteger(value) ||
249
+ value < 0
250
+ : typeof value !== 'string')
251
+ )
252
+ invalid();
253
+ Object.defineProperty(copy, key, {
254
+ value,
255
+ enumerable: true,
256
+ writable: true,
257
+ configurable: true,
258
+ });
259
+ }
260
+ result.push(copy);
261
+ }
262
+ return result;
263
+ }
264
+
265
+ /** Providers can mutate and re-yield records. Inspect a per-emission snapshot,
266
+ * leaving their originals intact, but keep producer/consumer charge identity.
267
+ */
268
+ export function snapshotValidatedModelChunk(
269
+ message: AIMessageChunk,
270
+ partial = true
271
+ ): AIMessageChunk {
272
+ if (types.isProxy(message)) {
273
+ throw new InvalidModelToolCallError(
274
+ 'Accepted model response contains non-serializable tool calls'
275
+ );
276
+ }
277
+ const descriptors = Object.getOwnPropertyDescriptors(message);
278
+ // Object.freeze on the provider result must not freeze the SDK's own
279
+ // envelope. LangChain updates IDs, lc_kwargs and response metadata later.
280
+ for (const descriptor of Object.values(descriptors)) {
281
+ descriptor.configurable = true;
282
+ if ('value' in descriptor) descriptor.writable = true;
283
+ }
284
+ const kwargs = descriptors.lc_kwargs;
285
+ if (Object.hasOwn(descriptors, 'lc_kwargs') && 'value' in kwargs) {
286
+ const value: unknown = kwargs.value;
287
+ if (value === null || typeof value !== 'object' || types.isProxy(value)) {
288
+ throw new InvalidModelToolCallError('Invalid model response metadata');
289
+ }
290
+ const fields = Object.getOwnPropertyDescriptors(value);
291
+ for (const field of Object.values(fields)) {
292
+ if (!('value' in field)) {
293
+ throw new InvalidModelToolCallError('Invalid model response metadata');
294
+ }
295
+ field.configurable = true;
296
+ field.writable = true;
297
+ }
298
+ kwargs.value = Object.create(Object.getPrototypeOf(value), fields);
299
+ }
300
+ const copy = Object.create(
301
+ Object.getPrototypeOf(message),
302
+ descriptors
303
+ ) as AIMessageChunk;
304
+ detachValidatedModelToolCalls(copy, partial);
305
+ if (partial) linkStreamLimitCanonical(copy, message);
306
+ return copy;
307
+ }
package/src/llm/invoke.ts CHANGED
@@ -41,6 +41,11 @@ import {
41
41
  resolvePreemptAction,
42
42
  resolveRestartGraceMs,
43
43
  } from '@/llm/preempt';
44
+ import {
45
+ detachValidatedModelToolCalls,
46
+ snapshotValidatedModelChunk,
47
+ InvalidModelToolCallError,
48
+ } from '@/graphs/acceptedModelResponse';
44
49
  import {
45
50
  assertPreparedProviderRequestFor,
46
51
  prepareProviderRequest,
@@ -1079,7 +1084,8 @@ async function attemptInvokeBody(
1079
1084
  const signal = config.signal;
1080
1085
  if (
1081
1086
  signal?.aborted === true &&
1082
- (signal.reason instanceof StreamLimitExceededError || signal.reason instanceof PreparedSubagentError)
1087
+ (signal.reason instanceof StreamLimitExceededError ||
1088
+ signal.reason instanceof PreparedSubagentError)
1083
1089
  ) {
1084
1090
  throw signal.reason;
1085
1091
  }
@@ -1105,8 +1111,9 @@ async function attemptInvokeBody(
1105
1111
  const attemptMetadata = config.metadata as
1106
1112
  | Record<string, unknown>
1107
1113
  | undefined;
1108
- for await (const chunk of stream) {
1114
+ for await (const rawChunk of stream) {
1109
1115
  throwIfBreakerTripped();
1116
+ const chunk = snapshotValidatedModelChunk(rawChunk);
1110
1117
  /** An onChunk consumer replaces the stream handler entirely, so
1111
1118
  * stream limits are enforced here for every such caller — public
1112
1119
  * package consumers get no other accounting. The internal
@@ -1127,10 +1134,13 @@ async function attemptInvokeBody(
1127
1134
  });
1128
1135
  }
1129
1136
  } else if (registeredStreamHandler == null) {
1130
- const metadata = config.metadata as Record<string, unknown> | undefined;
1137
+ const metadata = config.metadata as
1138
+ | Record<string, unknown>
1139
+ | undefined;
1131
1140
  const streamHandler = new ChatModelStreamHandler();
1132
- for await (const chunk of stream) {
1141
+ for await (const rawChunk of stream) {
1133
1142
  throwIfBreakerTripped();
1143
+ const chunk = snapshotValidatedModelChunk(rawChunk);
1134
1144
  /**
1135
1145
  * The decision is final, so stop consuming here rather than
1136
1146
  * trusting the adapter to honor the abort. An adapter that ignores
@@ -1239,7 +1249,9 @@ async function attemptInvokeBody(
1239
1249
  }
1240
1250
  }
1241
1251
  } else {
1242
- const metadata = config.metadata as Record<string, unknown> | undefined;
1252
+ const metadata = config.metadata as
1253
+ | Record<string, unknown>
1254
+ | undefined;
1243
1255
  /**
1244
1256
  * The original wire chunk still reaches the registered handler through
1245
1257
  * `streamEvents` (where the late-reasoning skip discards it AFTER the
@@ -1248,8 +1260,9 @@ async function attemptInvokeBody(
1248
1260
  * once per attempt, only when a transformation occurs.
1249
1261
  */
1250
1262
  let redispatchMetadata: Record<string, unknown> | undefined;
1251
- for await (const chunk of stream) {
1263
+ for await (const rawChunk of stream) {
1252
1264
  throwIfBreakerTripped();
1265
+ const chunk = snapshotValidatedModelChunk(rawChunk);
1253
1266
  /**
1254
1267
  * Charged synchronously, ahead of the decoupled `streamEvents`
1255
1268
  * reader that will echo this same chunk to the registered handler:
@@ -1258,7 +1271,11 @@ async function attemptInvokeBody(
1258
1271
  * throws. The chunk is marked so the echo skips accounting.
1259
1272
  */
1260
1273
  if (context != null) {
1261
- enforceStreamLimitsForWireChunk({ graph: context, metadata, chunk });
1274
+ enforceStreamLimitsForWireChunk({
1275
+ graph: context,
1276
+ metadata,
1277
+ chunk,
1278
+ });
1262
1279
  }
1263
1280
  const handlingChunk = getStreamHandlingChunk({
1264
1281
  current: finalChunk,
@@ -1366,7 +1383,8 @@ async function attemptInvokeBody(
1366
1383
  );
1367
1384
  }
1368
1385
  if (finalChunk != null || sealedRunId != null) {
1369
- const discardedChunk = finalChunk ?? new AIMessageChunk({ content: '' });
1386
+ const discardedChunk =
1387
+ finalChunk ?? new AIMessageChunk({ content: '' });
1370
1388
  const responseMetadata = {
1371
1389
  ...discardedChunk.response_metadata,
1372
1390
  preempted: true,
@@ -1448,6 +1466,7 @@ async function attemptInvokeBody(
1448
1466
  );
1449
1467
  }
1450
1468
 
1469
+ if (finalChunk != null) detachValidatedModelToolCalls(finalChunk);
1451
1470
  if ((finalChunk?.tool_calls?.length ?? 0) > 0) {
1452
1471
  finalChunk!.tool_calls = finalChunk!.tool_calls?.filter(
1453
1472
  (tool_call: ToolCall) => !!tool_call.name
@@ -1458,9 +1477,9 @@ async function attemptInvokeBody(
1458
1477
  return { messages: [finalChunk as AIMessageChunk] };
1459
1478
  }
1460
1479
 
1461
- const finalMessage = await model.invoke(
1462
- messagesForProvider,
1463
- invocationConfig
1480
+ const finalMessage = snapshotValidatedModelChunk(
1481
+ await model.invoke(messagesForProvider, invocationConfig),
1482
+ false
1464
1483
  );
1465
1484
  if ((finalMessage.tool_calls?.length ?? 0) > 0) {
1466
1485
  finalMessage.tool_calls = finalMessage.tool_calls?.filter(
@@ -1666,7 +1685,8 @@ export async function tryFallbackProviders({
1666
1685
  * a run that must reject. Check before every fallback invocation. */
1667
1686
  if (
1668
1687
  config?.signal?.aborted === true &&
1669
- (config.signal.reason instanceof StreamLimitExceededError || config.signal.reason instanceof PreparedSubagentError)
1688
+ (config.signal.reason instanceof StreamLimitExceededError ||
1689
+ config.signal.reason instanceof PreparedSubagentError)
1670
1690
  ) {
1671
1691
  throw config.signal.reason;
1672
1692
  }
@@ -1700,7 +1720,11 @@ export async function tryFallbackProviders({
1700
1720
  * provider failure. Continuing would try the remaining fallbacks and a
1701
1721
  * succeeding one would resolve a run that must reject.
1702
1722
  */
1703
- if (e instanceof StreamLimitExceededError || e instanceof PreparedSubagentError) {
1723
+ if (
1724
+ e instanceof StreamLimitExceededError ||
1725
+ e instanceof PreparedSubagentError ||
1726
+ e instanceof InvalidModelToolCallError
1727
+ ) {
1704
1728
  throw e;
1705
1729
  }
1706
1730
  /** A parallel sibling's trip aborts this branch's composed signal, and
@@ -1709,7 +1733,8 @@ export async function tryFallbackProviders({
1709
1733
  * abort. Rethrow the breaker's own reason instead. */
1710
1734
  if (
1711
1735
  config?.signal?.aborted === true &&
1712
- (config.signal.reason instanceof StreamLimitExceededError || config.signal.reason instanceof PreparedSubagentError)
1736
+ (config.signal.reason instanceof StreamLimitExceededError ||
1737
+ config.signal.reason instanceof PreparedSubagentError)
1713
1738
  ) {
1714
1739
  throw config.signal.reason;
1715
1740
  }
@@ -981,7 +981,7 @@ export function linkStreamLimitCanonical(
981
981
  canonical: object
982
982
  ): void {
983
983
  Object.defineProperty(copy, STREAM_LIMIT_CANONICAL, {
984
- value: canonical,
984
+ value: canonicalChunk(canonical),
985
985
  enumerable: false,
986
986
  configurable: true,
987
987
  });
@@ -4,7 +4,12 @@ import {
4
4
  calculateMaxToolResultChars,
5
5
  } from '@/utils/truncation';
6
6
 
7
- export const FADING_TIER_VERSION = 1;
7
+ /**
8
+ * Version 2: exchange width counts only the current turn. Version 1 tiers may
9
+ * have latched on history whose steps storage had merged into one assistant
10
+ * message, so they are discarded and re-derived once.
11
+ */
12
+ export const FADING_TIER_VERSION = 2;
8
13
 
9
14
  /** Context pressure at which observation masking activates. */
10
15
  export const PRESSURE_THRESHOLD_MASKING = 0.8;
@@ -34,7 +39,7 @@ export type FadingSignals = {
34
39
  /** (pruningBudget − instruction tokens) ÷ calibrationRatio, in raw token space. */
35
40
  effectiveRawTokens: number;
36
41
  summarizationEnabled: boolean;
37
- /** Largest number of parallel calls observed in one assistant exchange. */
42
+ /** Largest number of parallel calls in one assistant exchange of the current turn. */
38
43
  toolExchangeWidth?: number;
39
44
  /** Recovery paths force at least this rung on the current window's ladder. */
40
45
  minRung?: number;
@@ -59,7 +64,10 @@ export function createFadingTier(window: number): FadingTier {
59
64
  return { v: FADING_TIER_VERSION, budgetTokens: window, masked: false };
60
65
  }
61
66
 
62
- export function isFadingTier(value: unknown): value is FadingTier {
67
+ /** Tier version before exchange width became turn-scoped. */
68
+ const LEGACY_FADING_TIER_VERSION = 1;
69
+
70
+ function isFadingTierOfVersion(value: unknown, version: number): boolean {
63
71
  if (typeof value !== 'object' || value === null) {
64
72
  return false;
65
73
  }
@@ -67,7 +75,7 @@ export function isFadingTier(value: unknown): value is FadingTier {
67
75
  Record<keyof FadingTier, unknown>
68
76
  >;
69
77
  return (
70
- v === FADING_TIER_VERSION &&
78
+ v === version &&
71
79
  typeof budgetTokens === 'number' &&
72
80
  Number.isFinite(budgetTokens) &&
73
81
  budgetTokens > 0 &&
@@ -76,6 +84,20 @@ export function isFadingTier(value: unknown): value is FadingTier {
76
84
  );
77
85
  }
78
86
 
87
+ export function isFadingTier(value: unknown): value is FadingTier {
88
+ return isFadingTierOfVersion(value, FADING_TIER_VERSION);
89
+ }
90
+
91
+ /**
92
+ * A well-formed tier persisted before version 2. Stored snapshots that carry
93
+ * one (resume manifests, session files) stay valid; every consumer then drops
94
+ * it through `isFadingTier`, so the tier re-derives once instead of the
95
+ * snapshot being rejected as corrupt.
96
+ */
97
+ export function isLegacyFadingTier(value: unknown): boolean {
98
+ return isFadingTierOfVersion(value, LEGACY_FADING_TIER_VERSION);
99
+ }
100
+
79
101
  /** Deepest rung for a window: the point where the budget reaches its floor. */
80
102
  export function maxFadingRung(window: number): number {
81
103
  const floor = floorBudgetTokens(window);
@@ -199,7 +221,10 @@ export function fadingRungForExchangeChars(
199
221
  maxToolResultChars == null
200
222
  ? windowResultChars
201
223
  : Math.min(windowResultChars, maxToolResultChars);
202
- if (resultChars + calculateMaxToolCallInputChars(budgetTokens) <= targetChars) {
224
+ if (
225
+ resultChars + calculateMaxToolCallInputChars(budgetTokens) <=
226
+ targetChars
227
+ ) {
203
228
  return rung;
204
229
  }
205
230
  }