@librechat/agents 3.3.0 → 3.3.1

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 (129) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +116 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +2 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +229 -48
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +102 -27
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/index.cjs +11 -2
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/contextOverflowRecovery.cjs +132 -0
  12. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -0
  13. package/dist/cjs/llm/google/index.cjs +1 -1
  14. package/dist/cjs/llm/invoke.cjs +60 -3
  15. package/dist/cjs/llm/invoke.cjs.map +1 -1
  16. package/dist/cjs/main.cjs +20 -7
  17. package/dist/cjs/messages/prune.cjs +16 -5
  18. package/dist/cjs/messages/prune.cjs.map +1 -1
  19. package/dist/cjs/messages/recency.cjs +2 -0
  20. package/dist/cjs/messages/recency.cjs.map +1 -1
  21. package/dist/cjs/run.cjs +15 -2
  22. package/dist/cjs/run.cjs.map +1 -1
  23. package/dist/cjs/stream.cjs +205 -49
  24. package/dist/cjs/stream.cjs.map +1 -1
  25. package/dist/cjs/summarization/node.cjs +55 -7
  26. package/dist/cjs/summarization/node.cjs.map +1 -1
  27. package/dist/cjs/tools/BashExecutor.cjs +2 -2
  28. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -2
  30. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/CodeExecutor.cjs +69 -8
  32. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  33. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +19 -15
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  35. package/dist/cjs/tools/ToolNode.cjs +32 -3
  36. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  37. package/dist/cjs/utils/errors.cjs +317 -52
  38. package/dist/cjs/utils/errors.cjs.map +1 -1
  39. package/dist/esm/agents/AgentContext.mjs +117 -3
  40. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  41. package/dist/esm/common/enum.mjs +2 -0
  42. package/dist/esm/common/enum.mjs.map +1 -1
  43. package/dist/esm/graphs/Graph.mjs +227 -46
  44. package/dist/esm/graphs/Graph.mjs.map +1 -1
  45. package/dist/esm/graphs/MultiAgentGraph.mjs +102 -27
  46. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  47. package/dist/esm/llm/bedrock/index.mjs +11 -2
  48. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  49. package/dist/esm/llm/contextOverflowRecovery.mjs +130 -0
  50. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -0
  51. package/dist/esm/llm/google/index.mjs +1 -1
  52. package/dist/esm/llm/invoke.mjs +59 -4
  53. package/dist/esm/llm/invoke.mjs.map +1 -1
  54. package/dist/esm/main.mjs +9 -9
  55. package/dist/esm/messages/prune.mjs +14 -6
  56. package/dist/esm/messages/prune.mjs.map +1 -1
  57. package/dist/esm/messages/recency.mjs +2 -1
  58. package/dist/esm/messages/recency.mjs.map +1 -1
  59. package/dist/esm/run.mjs +15 -2
  60. package/dist/esm/run.mjs.map +1 -1
  61. package/dist/esm/stream.mjs +205 -49
  62. package/dist/esm/stream.mjs.map +1 -1
  63. package/dist/esm/summarization/node.mjs +55 -7
  64. package/dist/esm/summarization/node.mjs.map +1 -1
  65. package/dist/esm/tools/BashExecutor.mjs +3 -3
  66. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  67. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -3
  68. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  69. package/dist/esm/tools/CodeExecutor.mjs +62 -9
  70. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  71. package/dist/esm/tools/ProgrammaticToolCalling.mjs +20 -16
  72. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  73. package/dist/esm/tools/ToolNode.mjs +32 -3
  74. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  75. package/dist/esm/utils/errors.mjs +317 -53
  76. package/dist/esm/utils/errors.mjs.map +1 -1
  77. package/dist/types/agents/AgentContext.d.ts +62 -3
  78. package/dist/types/common/enum.d.ts +2 -0
  79. package/dist/types/graphs/Graph.d.ts +16 -2
  80. package/dist/types/graphs/MultiAgentGraph.d.ts +1 -0
  81. package/dist/types/llm/contextOverflowRecovery.d.ts +85 -0
  82. package/dist/types/llm/invoke.d.ts +35 -6
  83. package/dist/types/messages/prune.d.ts +10 -2
  84. package/dist/types/messages/recency.d.ts +1 -0
  85. package/dist/types/run.d.ts +2 -0
  86. package/dist/types/tools/CodeExecutor.d.ts +14 -1
  87. package/dist/types/types/llm.d.ts +7 -4
  88. package/dist/types/types/stream.d.ts +5 -4
  89. package/dist/types/types/summarize.d.ts +22 -0
  90. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +40 -0
  91. package/dist/types/utils/errors.d.ts +65 -16
  92. package/dist/types/utils/redactSecrets.d.ts +3 -0
  93. package/package.json +7 -8
  94. package/src/agents/AgentContext.ts +188 -7
  95. package/src/agents/__tests__/AgentContext.overflow.test.ts +205 -0
  96. package/src/common/enum.ts +2 -0
  97. package/src/graphs/Graph.ts +389 -58
  98. package/src/graphs/MultiAgentGraph.ts +184 -46
  99. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +631 -0
  100. package/src/llm/__tests__/contextOverflowRecovery.test.ts +401 -0
  101. package/src/llm/__tests__/fallbackOverflow.test.ts +287 -0
  102. package/src/llm/bedrock/index.ts +25 -12
  103. package/src/llm/contextOverflowRecovery.ts +292 -0
  104. package/src/llm/invoke.ts +119 -4
  105. package/src/messages/prune.ts +24 -11
  106. package/src/messages/recency.ts +3 -1
  107. package/src/run.ts +24 -1
  108. package/src/scripts/context-overflow-probe.ts +997 -0
  109. package/src/specs/agent-handoffs.test.ts +903 -1
  110. package/src/specs/context-overflow-recovery.live.test.ts +213 -0
  111. package/src/splitStream.test.ts +882 -0
  112. package/src/stream.ts +315 -51
  113. package/src/summarization/__tests__/aggregator.test.ts +83 -0
  114. package/src/summarization/__tests__/node.test.ts +139 -0
  115. package/src/summarization/node.ts +99 -14
  116. package/src/tools/BashExecutor.ts +4 -2
  117. package/src/tools/BashProgrammaticToolCalling.ts +4 -7
  118. package/src/tools/CodeExecutor.ts +119 -8
  119. package/src/tools/ProgrammaticToolCalling.ts +29 -27
  120. package/src/tools/ToolNode.ts +50 -8
  121. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +297 -3
  122. package/src/types/llm.ts +8 -1
  123. package/src/types/stream.ts +5 -4
  124. package/src/types/summarize.ts +22 -0
  125. package/src/utils/__tests__/errors.test.ts +270 -0
  126. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +336 -0
  127. package/src/utils/__tests__/redactSecrets.test.ts +56 -0
  128. package/src/utils/errors.ts +484 -66
  129. package/src/utils/redactSecrets.ts +61 -0
@@ -1,45 +1,388 @@
1
1
  /**
2
- * Context overflow error detection utilities.
2
+ * Context overflow error detection.
3
3
  *
4
- * Identifies provider-specific error messages that indicate the request
5
- * exceeded the model's context window. Used by the overflow recovery loop
6
- * to decide whether to retry with truncation/compaction vs. propagating
7
- * the error.
4
+ * Providers disagree on how they report "your input is bigger than I can
5
+ * take" the class thrown, the HTTP status, whether numbers are reported,
6
+ * and even whether it arrives as an HTTP error at all. Every pattern below
7
+ * was captured from a live over-limit request (see
8
+ * `src/scripts/context-overflow-probe.ts` and
9
+ * `docs/context-overflow-signatures.md`); nothing here is guessed.
10
+ *
11
+ * Consumed by the graph's overflow recovery loop, which converts a detection
12
+ * into a forced summarization pass instead of surfacing the error.
13
+ */
14
+ import { ContextOverflowError } from '@langchain/core/errors';
15
+ import type { Providers } from '@/common';
16
+
17
+ /**
18
+ * Why the request was rejected. Both kinds are fixed by shrinking the
19
+ * prompt, which is what makes them recoverable; they are distinguished
20
+ * because only `context_window` tells us the model's true window.
8
21
  */
22
+ export type ContextOverflowKind =
23
+ /** Input exceeded the model's context window. */
24
+ | 'context_window'
25
+ /**
26
+ * A single request exceeded a per-minute token allowance. Waiting cannot
27
+ * help — the request can never fit the bucket — so this is a payload
28
+ * problem wearing a 429, not throttling.
29
+ */
30
+ | 'request_too_large';
31
+
32
+ export interface ContextOverflowInfo {
33
+ kind: ContextOverflowKind;
34
+ /** Ceiling the provider reported, when it named one. */
35
+ limitTokens?: number;
36
+ /**
37
+ * Token count the provider attributed to the whole request. Several
38
+ * providers fold the requested completion allowance into this number, so it
39
+ * is not interchangeable with the prompt size.
40
+ */
41
+ requestedTokens?: number;
42
+ /**
43
+ * The prompt alone, counted by the provider — set only when the provider
44
+ * distinguished input from output, either by reporting an input-only figure
45
+ * or by breaking the total down. Callers comparing provider counts against
46
+ * their own prompt estimate must use this and not `requestedTokens`, whose
47
+ * completion component would inflate the comparison.
48
+ */
49
+ promptTokens?: number;
50
+ /** Which layer produced the verdict. Surfaced in logs and asserted in tests. */
51
+ source: 'langchain' | 'pattern';
52
+ provider?: Providers;
53
+ }
54
+
55
+ interface OverflowPattern {
56
+ readonly kind: ContextOverflowKind;
57
+ readonly re: RegExp;
58
+ readonly limitGroup?: number;
59
+ readonly requestedGroup?: number;
60
+ /**
61
+ * Whether `requestedGroup` counts the prompt alone. Providers that report a
62
+ * combined input+completion total leave this false, and their number is
63
+ * never used as a prompt measurement.
64
+ */
65
+ readonly requestedIsPromptOnly?: boolean;
66
+ /**
67
+ * Marks a signature that is consistent with overflow but not exclusive to
68
+ * it, so it only counts when the caller can corroborate that the prompt was
69
+ * actually near the budget. Without corroboration the error propagates
70
+ * untouched rather than triggering a needless compaction.
71
+ */
72
+ readonly requiresContextPressure?: boolean;
73
+ }
74
+
75
+ /**
76
+ * Fraction of the believed budget the prompt must reach before an ambiguous
77
+ * provider error is read as overflow. Well above normal traffic, low enough to
78
+ * catch the case the budget itself was miscalibrated.
79
+ */
80
+ const CONTEXT_PRESSURE_RATIO = 0.8;
81
+
82
+ export interface ContextOverflowContext {
83
+ provider?: Providers;
84
+ /** Our own estimate of the prompt size for the call that failed. */
85
+ estimatedPromptTokens?: number;
86
+ /** The budget we believed applied when we built that prompt. */
87
+ maxContextTokens?: number;
88
+ }
9
89
 
10
90
  /**
11
- * Exact phrases that definitively indicate a context overflow error.
12
- * These are returned by various LLM providers when the prompt is too large.
91
+ * Ordered most-specific first; the first match wins. Patterns that capture
92
+ * both numbers come before the bare-phrase fallbacks for the same provider so
93
+ * a recoverable error still yields the numbers when the provider reported
94
+ * them.
13
95
  */
14
- const CONTEXT_OVERFLOW_PHRASES = [
15
- 'request_too_large',
16
- 'context length exceeded',
17
- 'maximum context length',
18
- 'prompt is too long',
19
- 'exceeds model context window',
20
- 'exceeds the model',
21
- 'too large for model',
22
- 'context_length_exceeded',
23
- 'max_tokens',
24
- 'token limit',
25
- 'input too long',
26
- 'payload too large',
27
- 'content_too_large',
96
+ const OVERFLOW_PATTERNS: readonly OverflowPattern[] = [
97
+ /** Anthropic, and Bedrock's passthrough of the same upstream. */
98
+ {
99
+ kind: 'context_window',
100
+ re: /prompt is too long:\s*(\d+)\s*tokens\s*>\s*(\d+)\s*maximum/i,
101
+ requestedGroup: 1,
102
+ limitGroup: 2,
103
+ requestedIsPromptOnly: true,
104
+ },
105
+ /**
106
+ * OpenAI's own wording, which measures the messages and nothing else.
107
+ * Ordered ahead of the shared sentence below so the prompt-only reading is
108
+ * preferred when OpenAI is the one answering.
109
+ */
110
+ {
111
+ kind: 'context_window',
112
+ re: /maximum context length is\s*(\d+)\s*tokens\.\s*however,\s*your messages resulted in\s*(\d+)/i,
113
+ limitGroup: 1,
114
+ requestedGroup: 2,
115
+ requestedIsPromptOnly: true,
116
+ },
117
+ /**
118
+ * OpenRouter (`you requested about`) and DeepSeek (`you requested`). Their
119
+ * total folds in the completion allowance — both then break it down in
120
+ * parentheses, which `PROMPT_ONLY_BREAKDOWN_RE` recovers.
121
+ */
122
+ {
123
+ kind: 'context_window',
124
+ re: /maximum context length is\s*(\d+)\s*tokens\.\s*however,\s*you requested(?:\s*about)?\s*(\d+)/i,
125
+ limitGroup: 1,
126
+ requestedGroup: 2,
127
+ },
128
+ /**
129
+ * xAI. Says "prompt length" rather than "context length", and does not say
130
+ * whether the count it quotes includes the completion allowance — so it is
131
+ * not trusted as a prompt measurement.
132
+ */
133
+ {
134
+ kind: 'context_window',
135
+ re: /maximum prompt length is\s*(\d+)\s*(?:tokens\s*)?but the request contains\s*(\d+)\s*tokens/i,
136
+ limitGroup: 1,
137
+ requestedGroup: 2,
138
+ },
139
+ /** Mistral. */
140
+ {
141
+ kind: 'context_window',
142
+ re: /prompt contains\s*(\d+)\s*tokens[^.]*?too large for model with\s*(\d+)\s*maximum context length/i,
143
+ requestedGroup: 1,
144
+ limitGroup: 2,
145
+ requestedIsPromptOnly: true,
146
+ },
147
+ /** Google Gemini / Vertex. Reports the ceiling only. */
148
+ {
149
+ kind: 'context_window',
150
+ re: /input token count exceeds the maximum number of tokens allowed\s*\((\d+)\)/i,
151
+ limitGroup: 1,
152
+ },
153
+ /**
154
+ * OpenAI's token-bucket rejection. Numbers are validated by the caller:
155
+ * `Requested >= Limit` means no retry can ever succeed.
156
+ */
157
+ {
158
+ kind: 'request_too_large',
159
+ re: /request too large[\s\S]*?limit\s*(\d+),\s*requested\s*(\d+)/i,
160
+ limitGroup: 1,
161
+ requestedGroup: 2,
162
+ },
163
+ /** Bedrock (Llama upstream) and any provider naming the ceiling alone. */
164
+ {
165
+ kind: 'context_window',
166
+ re: /(?:maximum context length|maximum prompt length) is\s*(\d+)\s*tokens/i,
167
+ limitGroup: 1,
168
+ },
169
+ /** Bedrock (Nova upstream). */
170
+ {
171
+ kind: 'context_window',
172
+ re: /number of input tokens exceeds maximum length|input tokens exceeded/i,
173
+ },
174
+ /** Bedrock (Claude Sonnet upstream). */
175
+ {
176
+ kind: 'context_window',
177
+ re: /\binput (?:is )?too long(?: for requested model)?\b/i,
178
+ },
179
+ /** OpenAI-compatible error code, and the phrases LangChain itself keys on. */
180
+ {
181
+ kind: 'context_window',
182
+ re: /context[_ ]length[_ ]exceeded|input tokens exceed the configured limit|exceeds the context window|exceeds model context window/i,
183
+ },
184
+ /** Generic long-tail phrasings observed across OpenAI-compatible gateways. */
185
+ {
186
+ kind: 'context_window',
187
+ re: /prompt is too long|too large for model|reduce the length of (?:the |your )?(?:messages|prompt)/i,
188
+ },
189
+ /**
190
+ * Vertex AI. Its gaxios path discards the API error document, so an
191
+ * over-limit prompt arrives as a bare status line with no reason —
192
+ * identical to every other 400 from the same endpoint. Only a corroborated
193
+ * near-budget prompt makes this readable as overflow.
194
+ */
195
+ {
196
+ kind: 'context_window',
197
+ re: /google request failed with status code 400(?!\s*:)/i,
198
+ requiresContextPressure: true,
199
+ },
28
200
  ] as const;
29
201
 
30
202
  /**
31
- * HTTP status codes and broader hints that suggest context overflow.
32
- * Used by the less-strict `isLikelyContextOverflowError`.
203
+ * Errors that mention size or limits but are NOT recoverable by compaction.
204
+ * Checked before the positive patterns.
205
+ *
206
+ * Note the deliberate precision: OpenAI's *recoverable* "Request too large"
207
+ * body links to `platform.openai.com/account/rate-limits`, so a loose
208
+ * /rate.?limit/ test would discard the very case this module exists to catch.
209
+ * URLs are stripped from the haystack before matching, and genuine throttling
210
+ * is matched on its own distinct phrasing.
211
+ */
212
+ const NON_RECOVERABLE_RE =
213
+ /rate limit reached|requests per (?:min|day)|\brpm\b|too many requests|insufficient[_ ]quota|quota exceeded|billing|payment required|invalid[_ ]api[_ ]key|authentication|unauthorized|permission denied|forbidden/i;
214
+
215
+ /**
216
+ * Output-cap complaints. `max_tokens` appears in both families, so these are
217
+ * matched on the surrounding grammar rather than the bare parameter name.
218
+ */
219
+ const OUTPUT_LIMIT_RE =
220
+ /max_?(?:completion_?)?tokens\s*(?:must be|is too|cannot|exceeds|too large|greater than)|maximum number of output tokens|max_tokens.*less than or equal/i;
221
+
222
+ /**
223
+ * Recovers the input-only figure from providers that quote a combined total
224
+ * and then break it down — OpenRouter's "(56811 of text input, 16 in the
225
+ * output)" and DeepSeek's "(1179652 in the messages, 16 in the completion)".
33
226
  */
227
+ const PROMPT_ONLY_BREAKDOWN_RE =
228
+ /\(\s*(\d+)\s*(?:of\s+text\s+input|in\s+the\s+messages|of\s+input|input\s+tokens)\b/i;
229
+
230
+ /** Broader hints for the deliberately fuzzy `isLikelyContextOverflowError`. */
34
231
  const CONTEXT_OVERFLOW_HINT_RE =
35
- /413|too large|too long|context.*exceed|exceed.*context|token.*limit|limit.*token|prompt.*size|size.*limit|maximum.*length|length.*maximum/i;
232
+ /413|payload too large|content_too_large|request entity too large|too many tokens|token count.*exceed|exceed.*token count/i;
233
+
234
+ const MAX_CAUSE_DEPTH = 4;
235
+
236
+ interface NestedErrorShape {
237
+ message?: unknown;
238
+ code?: unknown;
239
+ type?: unknown;
240
+ status?: unknown;
241
+ reason?: unknown;
242
+ error?: unknown;
243
+ cause?: unknown;
244
+ body?: unknown;
245
+ response?: unknown;
246
+ /** gaxios-style clients (Vertex AI) put the API error document here. */
247
+ data?: unknown;
248
+ }
249
+
250
+ function asRecord(value: unknown): NestedErrorShape | undefined {
251
+ return typeof value === 'object' && value !== null
252
+ ? (value as NestedErrorShape)
253
+ : undefined;
254
+ }
36
255
 
37
256
  /**
38
- * Patterns that should NOT be treated as context overflow even if they
39
- * contain words like "limit" or "too large".
257
+ * Flattens an error into a single searchable string.
258
+ *
259
+ * Necessary because providers bury the useful sentence at different depths:
260
+ * Anthropic puts a JSON document in `message`, Mistral puts one in `body`,
261
+ * LangChain's `ContextOverflowError` keeps the original API error under
262
+ * `cause`, and the OpenAI SDK nests the body under `error`.
40
263
  */
41
- const FALSE_POSITIVE_RE =
42
- /rate.?limit|too many requests|quota|billing|auth|permission|forbidden/i;
264
+ function collectErrorText(error: unknown, depth = 0): string {
265
+ if (error == null || depth > MAX_CAUSE_DEPTH) {
266
+ return '';
267
+ }
268
+ if (typeof error === 'string') {
269
+ return error;
270
+ }
271
+ const record = asRecord(error);
272
+ if (record == null) {
273
+ return String(error);
274
+ }
275
+
276
+ const parts: string[] = [];
277
+ if (typeof record.message === 'string') {
278
+ parts.push(record.message);
279
+ }
280
+ for (const reason of [
281
+ record.code,
282
+ record.type,
283
+ record.status,
284
+ record.reason,
285
+ ]) {
286
+ if (typeof reason === 'string') {
287
+ parts.push(reason);
288
+ }
289
+ }
290
+ for (const nested of [
291
+ record.error,
292
+ record.cause,
293
+ record.body,
294
+ record.data,
295
+ record.response,
296
+ ]) {
297
+ if (nested == null) {
298
+ continue;
299
+ }
300
+ parts.push(
301
+ typeof nested === 'string' ? nested : collectErrorText(nested, depth + 1)
302
+ );
303
+ }
304
+ if (parts.length === 0) {
305
+ try {
306
+ /** Non-objects returned above, so this always yields a string. */
307
+ return JSON.stringify(error);
308
+ } catch {
309
+ return String(error);
310
+ }
311
+ }
312
+ return parts.join(' ');
313
+ }
314
+
315
+ /** Strips URLs so their path segments cannot trip the negative matchers. */
316
+ function stripUrls(text: string): string {
317
+ return text.replace(/https?:\/\/\S+/gi, ' ');
318
+ }
319
+
320
+ function readNumber(
321
+ match: RegExpMatchArray,
322
+ group?: number
323
+ ): number | undefined {
324
+ if (group == null) {
325
+ return undefined;
326
+ }
327
+ const parsed = Number(match[group]);
328
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
329
+ }
330
+
331
+ /**
332
+ * The provider's count of the prompt alone: its own breakdown when it gave
333
+ * one, otherwise the quoted total but only for providers that quote the
334
+ * prompt rather than the whole request. Returns undefined when the number on
335
+ * offer includes the completion allowance, since treating that as a prompt
336
+ * measurement would overstate how much the prompt has to shrink.
337
+ */
338
+ function resolvePromptTokens(
339
+ haystack: string,
340
+ pattern: OverflowPattern,
341
+ requestedTokens: number | undefined
342
+ ): number | undefined {
343
+ const breakdown = haystack.match(PROMPT_ONLY_BREAKDOWN_RE);
344
+ if (breakdown != null) {
345
+ const parsed = Number(breakdown[1]);
346
+ if (Number.isFinite(parsed) && parsed > 0) {
347
+ return parsed;
348
+ }
349
+ }
350
+ return pattern.requestedIsPromptOnly === true ? requestedTokens : undefined;
351
+ }
352
+
353
+ /**
354
+ * True when the caller's own accounting says the failed prompt was close
355
+ * enough to the budget that an otherwise ambiguous provider error is best
356
+ * explained by overflow.
357
+ */
358
+ function hasContextPressure(context?: ContextOverflowContext): boolean {
359
+ const estimated = context?.estimatedPromptTokens;
360
+ const budget = context?.maxContextTokens;
361
+ if (
362
+ estimated == null ||
363
+ budget == null ||
364
+ !Number.isFinite(estimated) ||
365
+ !Number.isFinite(budget) ||
366
+ budget <= 0
367
+ ) {
368
+ return false;
369
+ }
370
+ return estimated / budget >= CONTEXT_PRESSURE_RATIO;
371
+ }
372
+
373
+ function isLangChainOverflowError(error: unknown): boolean {
374
+ if (ContextOverflowError.isInstance(error)) {
375
+ return true;
376
+ }
377
+ /** Duplicate `@langchain/core` copies break branding; the name survives. */
378
+ const record = asRecord(error) as
379
+ | { name?: unknown; lc_error_code?: unknown }
380
+ | undefined;
381
+ return (
382
+ record?.name === 'ContextOverflowError' ||
383
+ record?.lc_error_code === 'CONTEXT_OVERFLOW'
384
+ );
385
+ }
43
386
 
44
387
  /**
45
388
  * Extracts a human-readable error message from an unknown error value.
@@ -54,21 +397,20 @@ export function extractErrorMessage(error: unknown): string {
54
397
  if (error instanceof Error) {
55
398
  return error.message;
56
399
  }
57
- if (typeof error === 'object') {
58
- const record = error as Record<string, unknown>;
59
- if (typeof record.message === 'string') {
60
- return record.message;
61
- }
62
- if (typeof record.error === 'string') {
63
- return record.error;
64
- }
65
- if (
66
- typeof record.error === 'object' &&
67
- record.error != null &&
68
- typeof (record.error as Record<string, unknown>).message === 'string'
69
- ) {
70
- return (record.error as Record<string, unknown>).message as string;
71
- }
400
+ const record = asRecord(error);
401
+ if (record == null) {
402
+ /** Functions and symbols serialize to nothing; describe them instead. */
403
+ return String(error);
404
+ }
405
+ if (typeof record.message === 'string') {
406
+ return record.message;
407
+ }
408
+ if (typeof record.error === 'string') {
409
+ return record.error;
410
+ }
411
+ const nested = asRecord(record.error);
412
+ if (typeof nested?.message === 'string') {
413
+ return nested.message;
72
414
  }
73
415
  try {
74
416
  return JSON.stringify(error);
@@ -78,40 +420,116 @@ export function extractErrorMessage(error: unknown): string {
78
420
  }
79
421
 
80
422
  /**
81
- * Returns true if the error message definitively indicates a context
82
- * overflow / prompt-too-large error from the provider.
423
+ * Classifies a provider error as a recoverable context overflow, returning
424
+ * whatever the provider disclosed about the limit and the request size.
83
425
  *
84
- * This is the strict check: only matches known, unambiguous phrases.
85
- * Use this when you want high confidence before taking recovery action.
426
+ * Returns `null` for anything that compaction cannot fix genuine
427
+ * throttling, auth, quota, and output-token-cap errors all mention limits
428
+ * and must not be mistaken for overflow.
429
+ *
430
+ * The reported numbers are the point of the return value: they let the
431
+ * caller retarget the token budget to the provider's real ceiling instead of
432
+ * retrying blindly against a configured value that was evidently wrong.
86
433
  */
87
- export function isContextOverflowError(errorMessage?: string): boolean {
88
- if (!errorMessage) {
89
- return false;
434
+ export function getContextOverflowInfo(
435
+ error: unknown,
436
+ context?: ContextOverflowContext
437
+ ): ContextOverflowInfo | null {
438
+ const provider = context?.provider;
439
+ const haystack = stripUrls(collectErrorText(error));
440
+ if (haystack === '') {
441
+ return null;
90
442
  }
91
- const lower = errorMessage.toLowerCase();
92
- if (FALSE_POSITIVE_RE.test(lower)) {
93
- return false;
443
+
444
+ if (OUTPUT_LIMIT_RE.test(haystack)) {
445
+ return null;
446
+ }
447
+
448
+ const langChainFlagged = isLangChainOverflowError(error);
449
+ if (!langChainFlagged && NON_RECOVERABLE_RE.test(haystack)) {
450
+ return null;
451
+ }
452
+
453
+ const underContextPressure = hasContextPressure(context);
454
+
455
+ for (const pattern of OVERFLOW_PATTERNS) {
456
+ const match = haystack.match(pattern.re);
457
+ if (match == null) {
458
+ continue;
459
+ }
460
+ if (pattern.requiresContextPressure === true && !underContextPressure) {
461
+ continue;
462
+ }
463
+ const limitTokens = readNumber(match, pattern.limitGroup);
464
+ const requestedTokens = readNumber(match, pattern.requestedGroup);
465
+
466
+ /**
467
+ * A token-bucket rejection is only unrecoverable-by-waiting when the
468
+ * request alone overruns the bucket. When it merely fills it, the account
469
+ * was busy and the request will fit once the window drains — so equality
470
+ * belongs on the retry side, not the compaction side. Losing conversation
471
+ * history to a temporarily busy account is the worse error.
472
+ */
473
+ if (
474
+ pattern.kind === 'request_too_large' &&
475
+ limitTokens != null &&
476
+ requestedTokens != null &&
477
+ requestedTokens <= limitTokens
478
+ ) {
479
+ return null;
480
+ }
481
+
482
+ return {
483
+ kind: pattern.kind,
484
+ limitTokens,
485
+ requestedTokens,
486
+ promptTokens: resolvePromptTokens(haystack, pattern, requestedTokens),
487
+ source: 'pattern',
488
+ provider,
489
+ };
94
490
  }
95
- return CONTEXT_OVERFLOW_PHRASES.some((phrase) => lower.includes(phrase));
491
+
492
+ if (langChainFlagged) {
493
+ return { kind: 'context_window', source: 'langchain', provider };
494
+ }
495
+
496
+ return null;
96
497
  }
97
498
 
98
499
  /**
99
- * Returns true if the error message likely indicates a context overflow.
100
- * Uses broader heuristic matching (regex) in addition to exact phrases.
500
+ * Returns true if the error definitively indicates a context overflow.
101
501
  *
102
- * May produce false positives for unusual error messages. Use this when
103
- * the cost of a false positive (one extra retry) is acceptable.
502
+ * Accepts either a raw error or a pre-extracted message; passing the error
503
+ * itself is preferred, since several providers report the decisive detail in
504
+ * a nested body rather than in `message`.
104
505
  */
105
- export function isLikelyContextOverflowError(errorMessage?: string): boolean {
106
- if (!errorMessage) {
107
- return false;
108
- }
109
- if (isContextOverflowError(errorMessage)) {
506
+ export function isContextOverflowError(
507
+ error?: unknown,
508
+ context?: ContextOverflowContext
509
+ ): boolean {
510
+ return getContextOverflowInfo(error, context) != null;
511
+ }
512
+
513
+ /**
514
+ * Returns true if the error likely indicates a context overflow, adding
515
+ * body-size and token-count heuristics on top of the definitive patterns.
516
+ *
517
+ * May produce false positives on unusual messages. Use when the cost of
518
+ * being wrong is one extra compaction pass.
519
+ */
520
+ export function isLikelyContextOverflowError(
521
+ error?: unknown,
522
+ context?: ContextOverflowContext
523
+ ): boolean {
524
+ if (isContextOverflowError(error, context)) {
110
525
  return true;
111
526
  }
112
- const lower = errorMessage.toLowerCase();
113
- if (FALSE_POSITIVE_RE.test(lower)) {
527
+ const haystack = stripUrls(collectErrorText(error));
528
+ if (haystack === '' || OUTPUT_LIMIT_RE.test(haystack)) {
529
+ return false;
530
+ }
531
+ if (NON_RECOVERABLE_RE.test(haystack)) {
114
532
  return false;
115
533
  }
116
- return CONTEXT_OVERFLOW_HINT_RE.test(lower);
534
+ return CONTEXT_OVERFLOW_HINT_RE.test(haystack);
117
535
  }
@@ -0,0 +1,61 @@
1
+ const SECRET_KEY_RE =
2
+ /key|token|secret|credential|authorization|password|cookie|signature|(?:^|[_-])sig(?:$|[_-])/i;
3
+
4
+ const REDACTED_VALUE = '[REDACTED]';
5
+ const CIRCULAR_VALUE = '[CIRCULAR]';
6
+
7
+ export function isSecretKey(key: string): boolean {
8
+ return SECRET_KEY_RE.test(key);
9
+ }
10
+
11
+ function redactUrlCredentials(value: string): string {
12
+ if (!/^https?:\/\//i.test(value)) {
13
+ return value;
14
+ }
15
+ try {
16
+ const url = new URL(value);
17
+ if (url.username !== '') {
18
+ url.username = REDACTED_VALUE;
19
+ }
20
+ if (url.password !== '') {
21
+ url.password = REDACTED_VALUE;
22
+ }
23
+ for (const key of url.searchParams.keys()) {
24
+ if (isSecretKey(key)) {
25
+ url.searchParams.set(key, REDACTED_VALUE);
26
+ }
27
+ }
28
+ return url.toString();
29
+ } catch {
30
+ return value;
31
+ }
32
+ }
33
+
34
+ /** Recursively removes credentials from structured diagnostic payloads. */
35
+ export function redactSecrets(
36
+ value: unknown,
37
+ seen: WeakSet<object> = new WeakSet()
38
+ ): unknown {
39
+ if (typeof value === 'string') {
40
+ return redactUrlCredentials(value);
41
+ }
42
+ if (value == null || typeof value !== 'object') {
43
+ return value;
44
+ }
45
+ if (seen.has(value)) {
46
+ return CIRCULAR_VALUE;
47
+ }
48
+ seen.add(value);
49
+
50
+ if (Array.isArray(value)) {
51
+ return value.map((entry) => redactSecrets(entry, seen));
52
+ }
53
+
54
+ const redacted: Record<string, unknown> = {};
55
+ for (const [key, entry] of Object.entries(value)) {
56
+ redacted[key] = isSecretKey(key)
57
+ ? REDACTED_VALUE
58
+ : redactSecrets(entry, seen);
59
+ }
60
+ return redacted;
61
+ }