@namzu/sdk 52.0.0 → 52.1.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 (114) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/compaction/verifier.d.ts.map +1 -1
  3. package/dist/compaction/verifier.js +57 -17
  4. package/dist/compaction/verifier.js.map +1 -1
  5. package/dist/contracts/a2a.d.ts +2 -2
  6. package/dist/provider/capabilities.d.ts +9 -0
  7. package/dist/provider/capabilities.d.ts.map +1 -1
  8. package/dist/provider/capabilities.js +15 -0
  9. package/dist/provider/capabilities.js.map +1 -1
  10. package/dist/provider/errors.d.ts.map +1 -1
  11. package/dist/provider/errors.js +13 -0
  12. package/dist/provider/errors.js.map +1 -1
  13. package/dist/provider/fallback.d.ts.map +1 -1
  14. package/dist/provider/fallback.js +10 -0
  15. package/dist/provider/fallback.js.map +1 -1
  16. package/dist/provider/idle-timeout.d.ts.map +1 -1
  17. package/dist/provider/idle-timeout.js +5 -0
  18. package/dist/provider/idle-timeout.js.map +1 -1
  19. package/dist/provider/image-input-refusal.d.ts +9 -0
  20. package/dist/provider/image-input-refusal.d.ts.map +1 -0
  21. package/dist/provider/image-input-refusal.js +54 -0
  22. package/dist/provider/image-input-refusal.js.map +1 -0
  23. package/dist/provider/index.d.ts +2 -1
  24. package/dist/provider/index.d.ts.map +1 -1
  25. package/dist/provider/index.js +2 -1
  26. package/dist/provider/index.js.map +1 -1
  27. package/dist/provider/retry.d.ts +7 -2
  28. package/dist/provider/retry.d.ts.map +1 -1
  29. package/dist/provider/retry.js +38 -4
  30. package/dist/provider/retry.js.map +1 -1
  31. package/dist/public-runtime.d.ts +1 -1
  32. package/dist/public-runtime.d.ts.map +1 -1
  33. package/dist/public-runtime.js +1 -1
  34. package/dist/public-runtime.js.map +1 -1
  35. package/dist/runtime/query/deferred-sandbox.d.ts +54 -0
  36. package/dist/runtime/query/deferred-sandbox.d.ts.map +1 -0
  37. package/dist/runtime/query/deferred-sandbox.js +75 -0
  38. package/dist/runtime/query/deferred-sandbox.js.map +1 -0
  39. package/dist/runtime/query/executor.d.ts +10 -0
  40. package/dist/runtime/query/executor.d.ts.map +1 -1
  41. package/dist/runtime/query/executor.js +24 -3
  42. package/dist/runtime/query/executor.js.map +1 -1
  43. package/dist/runtime/query/index.d.ts.map +1 -1
  44. package/dist/runtime/query/index.js +93 -25
  45. package/dist/runtime/query/index.js.map +1 -1
  46. package/dist/runtime/query/iteration/index.d.ts +26 -0
  47. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  48. package/dist/runtime/query/iteration/index.js +164 -21
  49. package/dist/runtime/query/iteration/index.js.map +1 -1
  50. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +10 -7
  51. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -1
  52. package/dist/runtime/query/iteration/phases/compaction-breaker.js +10 -7
  53. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -1
  54. package/dist/runtime/query/iteration/phases/compaction.d.ts +42 -1
  55. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  56. package/dist/runtime/query/iteration/phases/compaction.js +119 -21
  57. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  58. package/dist/runtime/query/iteration/provider-rejected-image.d.ts +21 -1
  59. package/dist/runtime/query/iteration/provider-rejected-image.d.ts.map +1 -1
  60. package/dist/runtime/query/iteration/provider-rejected-image.js +53 -7
  61. package/dist/runtime/query/iteration/provider-rejected-image.js.map +1 -1
  62. package/dist/runtime/query/iteration/request-repair.d.ts +108 -0
  63. package/dist/runtime/query/iteration/request-repair.d.ts.map +1 -0
  64. package/dist/runtime/query/iteration/request-repair.js +593 -0
  65. package/dist/runtime/query/iteration/request-repair.js.map +1 -0
  66. package/dist/runtime/query/iteration/stream-turn.d.ts +6 -0
  67. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  68. package/dist/runtime/query/iteration/stream-turn.js +2 -2
  69. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  70. package/dist/runtime/query/request-rich-content.d.ts +5 -1
  71. package/dist/runtime/query/request-rich-content.d.ts.map +1 -1
  72. package/dist/runtime/query/request-rich-content.js +67 -25
  73. package/dist/runtime/query/request-rich-content.js.map +1 -1
  74. package/dist/tools/builtins/computer-use.d.ts +1 -1
  75. package/dist/tools/builtins/computer-use.d.ts.map +1 -1
  76. package/dist/tools/builtins/computer-use.js +12 -5
  77. package/dist/tools/builtins/computer-use.js.map +1 -1
  78. package/dist/types/provider/errors.d.ts.map +1 -1
  79. package/dist/types/provider/errors.js +30 -5
  80. package/dist/types/provider/errors.js.map +1 -1
  81. package/dist/types/provider/interface.d.ts +12 -0
  82. package/dist/types/provider/interface.d.ts.map +1 -1
  83. package/dist/types/provider/stream.d.ts +1 -1
  84. package/dist/types/session/config.d.ts +14 -0
  85. package/dist/types/session/config.d.ts.map +1 -1
  86. package/dist/types/session/events.d.ts +20 -5
  87. package/dist/types/session/events.d.ts.map +1 -1
  88. package/dist/types/session/events.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/compaction/verifier.ts +57 -20
  91. package/src/provider/capabilities.ts +17 -0
  92. package/src/provider/errors.ts +13 -0
  93. package/src/provider/fallback.ts +10 -0
  94. package/src/provider/idle-timeout.ts +5 -0
  95. package/src/provider/image-input-refusal.ts +54 -0
  96. package/src/provider/index.ts +2 -0
  97. package/src/provider/retry.ts +42 -4
  98. package/src/public-runtime.ts +3 -0
  99. package/src/runtime/query/deferred-sandbox.ts +110 -0
  100. package/src/runtime/query/executor.ts +25 -3
  101. package/src/runtime/query/index.ts +124 -49
  102. package/src/runtime/query/iteration/index.ts +188 -23
  103. package/src/runtime/query/iteration/phases/compaction-breaker.ts +10 -8
  104. package/src/runtime/query/iteration/phases/compaction.ts +151 -35
  105. package/src/runtime/query/iteration/provider-rejected-image.ts +71 -5
  106. package/src/runtime/query/iteration/request-repair.ts +743 -0
  107. package/src/runtime/query/iteration/stream-turn.ts +11 -1
  108. package/src/runtime/query/request-rich-content.ts +77 -27
  109. package/src/tools/builtins/computer-use.ts +13 -4
  110. package/src/types/provider/errors.ts +33 -5
  111. package/src/types/provider/interface.ts +13 -0
  112. package/src/types/provider/stream.ts +1 -1
  113. package/src/types/session/config.ts +14 -0
  114. package/src/types/session/events.ts +25 -5
@@ -0,0 +1,743 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { repairToolMessageHistory, toolHistoryRepairChanged } from '../../../compaction/dangling.js'
4
+ import { isProviderRequestError } from '../../../provider/errors.js'
5
+ import { isImageInputRefusal } from '../../../provider/image-input-refusal.js'
6
+ import type {
7
+ AssistantMessage,
8
+ Message,
9
+ MessageAttachment,
10
+ ToolResultBlock,
11
+ } from '../../../types/message/index.js'
12
+ import type {
13
+ ChatCompletionParams,
14
+ LLMProvider,
15
+ StreamChunk,
16
+ } from '../../../types/provider/index.js'
17
+ import { findSingleRequestImage } from '../request-rich-content.js'
18
+ import { isProviderRejectedImageError } from './provider-rejected-image.js'
19
+
20
+ /**
21
+ * Repair-and-retry for a request a provider rejects as invalid (a 400 that is
22
+ * not a context overflow), so a conversation does not dead-end on it.
23
+ *
24
+ * Mirrors the reference terminal's table of silent 400 recoveries: pair tool
25
+ * calls with results before a send, strip rejected reasoning, drop a
26
+ * parameter the model refuses, and take rejected media out of the OUTGOING
27
+ * request. Stored history is never edited.
28
+ *
29
+ * **Nothing is remembered on a guess.** Every repair is a guess about what the
30
+ * provider objected to. What it teaches (a parameter to leave out, reasoning to
31
+ * strip, media to omit, a media limit) is kept for the run only once a resend
32
+ * WITHOUT the offending part was accepted. A guess that did not fix the
33
+ * request is forgotten and the original rejection is thrown, so a wrong guess
34
+ * never changes a later request.
35
+ *
36
+ * It decorates one model call of the agent loop (a turn, or the closing
37
+ * summary), so a rejection is only acted on when nothing has been accepted yet:
38
+ * resending then cannot duplicate output. Other callers of a provider, such as
39
+ * compaction summaries, do not pass through it.
40
+ */
41
+
42
+ export type RejectedParameter = 'effort' | 'thinking' | 'temperature' | 'topP' | 'responseFormat'
43
+
44
+ export interface MediaRemoval {
45
+ readonly count: number
46
+ readonly kind: 'image' | 'document' | 'media'
47
+ /** Some of it was attached to the person's latest message. */
48
+ readonly current: boolean
49
+ /** It was dropped to fit a count the provider accepts, not because a file was faulty. */
50
+ readonly limit: boolean
51
+ }
52
+
53
+ export interface RequestRepairHooks {
54
+ /** Debug-level record of a silent repair. Never shown to the person. */
55
+ readonly debug?: (message: string) => void
56
+ /** Media was taken out of a request that then went through. Called once per removal. */
57
+ readonly onMediaRemoved?: (removal: MediaRemoval) => void | Promise<void>
58
+ /**
59
+ * The member that serves the next request. A fallback chain moves between
60
+ * members, and what a member refused is a fact about that member, not about
61
+ * the head of the chain.
62
+ */
63
+ readonly servedBy?: () => { readonly providerId: string; readonly model?: string } | undefined
64
+ }
65
+
66
+ type Rejection =
67
+ | { readonly kind: 'tool-pairing' }
68
+ | { readonly kind: 'reasoning' }
69
+ | { readonly kind: 'parameter'; readonly parameters: readonly RejectedParameter[] }
70
+ | {
71
+ readonly kind: 'media'
72
+ readonly generic: boolean
73
+ readonly messageIndex?: number
74
+ readonly contentIndex?: number
75
+ }
76
+
77
+ /** Most repair resends for one original request, across every send it makes. */
78
+ export const MAX_REPAIRS_PER_REQUEST = 5
79
+ const MAX_STRIPS_PER_INDEX = 3
80
+ /** The reference drops media in these steps for an unprocessable request. */
81
+ const GENERIC_MEDIA_LIMITS: readonly number[] = [510, 255, 127]
82
+
83
+ const PARAMETER_CODES: ReadonlySet<string> = new Set([
84
+ 'unsupported_parameter',
85
+ 'unsupported_value',
86
+ 'unknown_parameter',
87
+ ])
88
+
89
+ const REASONING_CODES: ReadonlySet<string> = new Set([
90
+ 'invalid_encrypted_content',
91
+ 'invalid_reasoning',
92
+ 'invalid_signature',
93
+ ])
94
+
95
+ const TOOL_PAIRING =
96
+ /\btool_use\b.{0,120}\btool_result\b|\btool_result\b.{0,120}\btool_use\b|unexpected\s+`?tool_use_id`?|tool_use\s+ids?\s+must\s+be\s+unique|\btool_call_id\b|role\s+'tool'\s+must\s+be\s+a\s+response|'tool_calls'\s+must\s+be\s+followed|Expected\s+toolResult\s+blocks|function\s+call\s+turn\s+comes\s+immediately|number\s+of\s+function\s+response\s+parts/i
97
+
98
+ const REASONING =
99
+ /\bthought[_ ]?signature\b|`?signature`?\s+in\s+`?(?:thinking|redacted_thinking)|\b(?:thinking|redacted_thinking)\s+blocks?\b.{0,160}\b(?:cannot|can['’]t|must|invalid|modified|expected)|\bexpected\s+`?(?:thinking|redacted_thinking)`?|\bencrypted[_ ]content\b|\breasoning\s+item\b|\bitem\s+'rs_[^']*'|\bof\s+type\s+'reasoning'/i
100
+
101
+ /** A path into the tool list: wording about a tool schema is never about a request parameter or media. */
102
+ const TOOL_SCHEMA_PATH =
103
+ /\b(?:tools?|functions?)[.[]\s*\d+|input_schema|\.properties\b|json\s+schema/i
104
+
105
+ const REFUSAL =
106
+ "(?:unsupported|not\\s+supported|does\\s?n[o']t\\s+support|do\\s+not\\s+support|unrecognized|unknown\\s+(?:request\\s+)?(?:parameter|argument|field)|not\\s+allowed|deprecated|only\\s+the\\s+default)"
107
+
108
+ const PARAMETER_NAMES: Record<RejectedParameter, string> = {
109
+ effort: '(?:reasoning[_. -]?effort|effort)',
110
+ thinking: 'thinking',
111
+ temperature: 'temperature',
112
+ topP: 'top[_ ]?p',
113
+ responseFormat:
114
+ '(?:response_format|output_config(?:\\.format)?|json_schema|json_object|structured\\s+output)',
115
+ }
116
+
117
+ const PARAMETER_PATTERNS = Object.fromEntries(
118
+ (Object.entries(PARAMETER_NAMES) as [RejectedParameter, string][]).map(([name, token]) => [
119
+ name,
120
+ {
121
+ named: new RegExp(`\\b${token}\\b`, 'i'),
122
+ refused: new RegExp(`${REFUSAL}.{0,80}\\b${token}\\b|\\b${token}\\b.{0,80}${REFUSAL}`, 'i'),
123
+ },
124
+ ]),
125
+ ) as Record<RejectedParameter, { named: RegExp; refused: RegExp }>
126
+
127
+ const BOTH_SAMPLING =
128
+ /temperature.{0,60}top[_ ]?p.{0,80}(?:cannot|can['’]t|may\s+not|should\s+not).{0,20}(?:both|together)/i
129
+
130
+ const IMAGE_FAULT =
131
+ /\b(?:images?|pictures?|photos?)\b.{0,100}\b(?:too\s+(?:large|big)|exceeds?|larger\s+than|maximum\s+(?:size|dimensions?)|dimensions?|(?:could|can)\s?not\s+be\s+(?:processed|decoded|loaded|read|fetched|downloaded)|failed\s+to\s+(?:process|decode|load|read|download)|unsupported|not\s+supported|corrupt\w*|malformed|truncated|invalid)\b|\b(?:(?:could|can)\s?not|failed\s+to|unable\s+to)\s+(?:process|decode|load|read|download|fetch)\b.{0,60}\b(?:images?|pictures?)\b|\bunsupported\s+(?:image|media)\b|\binvalid\s+image\b/i
132
+ const DOCUMENT_FAULT =
133
+ /\bdocument_block\b|\b(?:documents?|pdfs?)\b.{0,80}\b(?:too\s+(?:large|big)|exceeds?\s+(?:the\s+)?(?:maximum|size|page)|page\s+limit|password|corrupt\w*|(?:could|can)\s?not\s+be\s+(?:processed|read|decoded)|failed\s+to\s+(?:process|read|decode))\b|\bpassword[- ]protected\s+pdf\b|\bpdf\b.{0,40}\bpages?\b.{0,20}\b(?:limit|maximum|exceeds?)\b/i
134
+ const GENERIC_MEDIA =
135
+ /\bunprocessable\b.{0,100}\b(?:images?|media|documents?)\b|\b(?:images?|media|documents?)\b.{0,100}\bunprocessable\b|\btoo\s+many\s+(?:images?|documents?|media|attachments?)\b|\bmedia_budget\b|\bimage_block\b/i
136
+ const NAMES_MEDIA = /\b(?:images?|pictures?|photos?|documents?|pdfs?|media|attachments?)\b/i
137
+
138
+ function wireIndexes(text: string): { messageIndex?: number; contentIndex?: number } {
139
+ const both = /\bmessages?[.[]\s*(\d+)\s*\]?[.[]\s*content[.[]\s*(\d+)/i.exec(text)
140
+ if (both?.[1] !== undefined && both[2] !== undefined) {
141
+ return { messageIndex: Number(both[1]), contentIndex: Number(both[2]) }
142
+ }
143
+ const only = /\bmessages?[.[]\s*(\d+)\b/i.exec(text)
144
+ return only?.[1] !== undefined ? { messageIndex: Number(only[1]) } : {}
145
+ }
146
+
147
+ function rejectedParameters(
148
+ text: string,
149
+ code: string | undefined,
150
+ params: ChatCompletionParams,
151
+ ): RejectedParameter[] {
152
+ if (TOOL_SCHEMA_PATH.test(text)) return []
153
+ const structured = code !== undefined && PARAMETER_CODES.has(code)
154
+ const set = {
155
+ effort: params.effort !== undefined,
156
+ thinking: params.thinking !== undefined,
157
+ temperature: params.temperature !== undefined,
158
+ topP: params.topP !== undefined,
159
+ responseFormat: params.responseFormat !== undefined,
160
+ } satisfies Record<RejectedParameter, boolean>
161
+ const found: RejectedParameter[] = []
162
+ for (const name of Object.keys(set) as RejectedParameter[]) {
163
+ if (!set[name]) continue
164
+ const pattern = PARAMETER_PATTERNS[name]
165
+ if (structured ? pattern.named.test(text) : pattern.refused.test(text)) found.push(name)
166
+ }
167
+ if (found.length === 0 && set.topP && BOTH_SAMPLING.test(text)) found.push('topP')
168
+ return found
169
+ }
170
+
171
+ function requestHasMedia(params: ChatCompletionParams): boolean {
172
+ return listMedia(params.messages).length > 0
173
+ }
174
+
175
+ /**
176
+ * What kind of invalid request this is, from the provider's structured code
177
+ * where it sent one and from its wording only as a fallback.
178
+ */
179
+ export function classifyRequestRejection(
180
+ error: unknown,
181
+ params: ChatCompletionParams,
182
+ ): Rejection | null {
183
+ if (!isProviderRequestError(error) || error.kind !== 'bad_request') return null
184
+ if (
185
+ error.status !== undefined &&
186
+ error.status !== 400 &&
187
+ error.status !== 413 &&
188
+ error.status !== 422
189
+ ) {
190
+ return null
191
+ }
192
+ const text = error.detail ?? error.message
193
+ const code = error.providerCode
194
+
195
+ if (code !== undefined && REASONING_CODES.has(code)) return { kind: 'reasoning' }
196
+ if (REASONING.test(text)) return { kind: 'reasoning' }
197
+ if (TOOL_PAIRING.test(text)) return { kind: 'tool-pairing' }
198
+
199
+ const parameters = rejectedParameters(text, code, params)
200
+ if (parameters.length > 0) return { kind: 'parameter', parameters }
201
+
202
+ if (TOOL_SCHEMA_PATH.test(text)) return null
203
+ if (code === 'invalid_image') return { kind: 'media', generic: false, ...wireIndexes(text) }
204
+ if (GENERIC_MEDIA.test(text)) return { kind: 'media', generic: true }
205
+ // A bare 413 or 422 says nothing about media: it counts only when the
206
+ // request carries some and the provider's words name it.
207
+ if ((error.status === 413 || error.status === 422) && NAMES_MEDIA.test(text)) {
208
+ return requestHasMedia(params) ? { kind: 'media', generic: true } : null
209
+ }
210
+ if (IMAGE_FAULT.test(text) || DOCUMENT_FAULT.test(text)) {
211
+ return { kind: 'media', generic: false, ...wireIndexes(text) }
212
+ }
213
+ return null
214
+ }
215
+
216
+ function isAccepted(chunk: StreamChunk): boolean {
217
+ if (chunk.retry !== undefined || chunk.fallback !== undefined || chunk.error !== undefined) {
218
+ return false
219
+ }
220
+ return Boolean(
221
+ chunk.delta.content ||
222
+ chunk.delta.toolCalls?.length ||
223
+ chunk.delta.toolCallEnd ||
224
+ chunk.delta.reasoning ||
225
+ chunk.delta.citation ||
226
+ chunk.finishReason !== undefined ||
227
+ chunk.usage !== undefined ||
228
+ chunk.replayState !== undefined ||
229
+ chunk.textParts !== undefined,
230
+ )
231
+ }
232
+
233
+ const mediaFingerprint = (data: string, mediaType: string): string =>
234
+ createHash('sha256').update(mediaType).update('\0').update(data).digest('hex')
235
+
236
+ const FAULT_NOTE =
237
+ 'the provider could not process it. The file may be unsupported or corrupt; do not retry reading it. If you need to inspect it, use a shell command instead.'
238
+ const LIMIT_NOTE =
239
+ 'this conversation holds more files than the provider accepts at once, so the oldest are left out of the request. They are still in the conversation.'
240
+ const removedNote = (kind: 'image' | 'document', limit: boolean): string =>
241
+ `[${kind} removed: ${limit ? LIMIT_NOTE : FAULT_NOTE}]`
242
+
243
+ interface MediaSlot {
244
+ readonly messageIndex: number
245
+ readonly itemIndex: number
246
+ readonly source: 'user' | 'tool'
247
+ readonly kind: 'image' | 'document'
248
+ readonly fingerprint: string
249
+ }
250
+
251
+ function listMedia(messages: readonly Message[]): MediaSlot[] {
252
+ const slots: MediaSlot[] = []
253
+ for (const [messageIndex, message] of messages.entries()) {
254
+ if (message.role === 'user') {
255
+ for (const [itemIndex, attachment] of (message.attachments ?? []).entries()) {
256
+ if (attachment.type === 'stored') continue
257
+ slots.push({
258
+ messageIndex,
259
+ itemIndex,
260
+ source: 'user',
261
+ kind: attachment.type === 'document' ? 'document' : 'image',
262
+ fingerprint: mediaFingerprint(attachment.data, attachment.mediaType),
263
+ })
264
+ }
265
+ } else if (message.role === 'tool' && Array.isArray(message.content)) {
266
+ for (const [itemIndex, block] of message.content.entries()) {
267
+ if (block.type !== 'image' && block.type !== 'document') continue
268
+ slots.push({
269
+ messageIndex,
270
+ itemIndex,
271
+ source: 'tool',
272
+ kind: block.type,
273
+ fingerprint: mediaFingerprint(block.data, block.mediaType),
274
+ })
275
+ }
276
+ }
277
+ }
278
+ return slots
279
+ }
280
+
281
+ /** Replace the given media with a short text note, in a copy. */
282
+ function removeMedia(
283
+ messages: readonly Message[],
284
+ slots: readonly MediaSlot[],
285
+ limit: boolean,
286
+ ): Message[] {
287
+ if (slots.length === 0) return messages as Message[]
288
+ const byMessage = new Map<number, Map<number, MediaSlot>>()
289
+ for (const slot of slots) {
290
+ const items = byMessage.get(slot.messageIndex) ?? new Map<number, MediaSlot>()
291
+ items.set(slot.itemIndex, slot)
292
+ byMessage.set(slot.messageIndex, items)
293
+ }
294
+ return messages.map((message, messageIndex) => {
295
+ const items = byMessage.get(messageIndex)
296
+ if (!items) return message
297
+ if (message.role === 'user') {
298
+ const attachments: readonly MessageAttachment[] = message.attachments ?? []
299
+ const kept = attachments.filter((_a, i) => !items.has(i))
300
+ const notes = [...items.values()]
301
+ .sort((a, b) => a.itemIndex - b.itemIndex)
302
+ .map((slot) => removedNote(slot.kind, limit))
303
+ .join('\n\n')
304
+ const { attachments: _drop, ...rest } = message
305
+ return {
306
+ ...rest,
307
+ content: message.content.length > 0 ? `${message.content}\n\n${notes}` : notes,
308
+ ...(kept.length > 0 ? { attachments: kept } : {}),
309
+ }
310
+ }
311
+ if (message.role === 'tool' && Array.isArray(message.content)) {
312
+ const content: ToolResultBlock[] = message.content.map((block, i) => {
313
+ const slot = items.get(i)
314
+ return slot ? { type: 'text', text: removedNote(slot.kind, limit) } : block
315
+ })
316
+ return { ...message, content }
317
+ }
318
+ return message
319
+ })
320
+ }
321
+
322
+ function stripReasoning(messages: readonly Message[]): Message[] {
323
+ let changed = false
324
+ const out = messages.map((message) => {
325
+ if (message.role !== 'assistant') return message
326
+ const assistant = message as AssistantMessage
327
+ const hasState =
328
+ assistant.source !== undefined &&
329
+ (assistant.source as { replayState?: unknown }).replayState !== undefined
330
+ if (assistant.reasoning === undefined && !hasState) return message
331
+ changed = true
332
+ const { reasoning: _reasoning, source, ...rest } = assistant
333
+ if (source === undefined) return rest as Message
334
+ const { replayState: _state, ...route } = source as typeof source & {
335
+ replayState?: unknown
336
+ }
337
+ return { ...rest, source: route } as Message
338
+ })
339
+ return changed ? out : (messages as Message[])
340
+ }
341
+
342
+ /**
343
+ * The media a provider's `messages.N.content.M` points at, mapped back through
344
+ * that driver's own serialisation. Exact for the two wires whose layout is
345
+ * known; anything else returns undefined and the caller does not guess.
346
+ *
347
+ * - the content-block wire: system turns are left out; a run of consecutive tool results
348
+ * is ONE user entry whose blocks are those results; every other message,
349
+ * including a user turn that follows tool results, is its own entry; a user
350
+ * entry's blocks are its text (when not empty) and then each attachment.
351
+ * - the chat-completions wire: one entry per message, system included; only user attachments
352
+ * travel as media (tool results are sent as text); blocks are the text (when
353
+ * not empty) and then each attachment.
354
+ */
355
+ function mediaAtWireIndex(
356
+ messages: readonly Message[],
357
+ slots: readonly MediaSlot[],
358
+ family: string,
359
+ wireIndex: number,
360
+ contentIndex: number | undefined,
361
+ ): MediaSlot[] | undefined {
362
+ if (family !== 'anthropic' && family !== 'openai') return undefined
363
+ const entries: number[][] = []
364
+ let inToolRun = false
365
+ for (const [index, message] of messages.entries()) {
366
+ if (family === 'anthropic') {
367
+ if (message.role === 'system') continue
368
+ if (message.role === 'tool') {
369
+ if (!inToolRun) entries.push([])
370
+ inToolRun = true
371
+ entries[entries.length - 1]?.push(index)
372
+ continue
373
+ }
374
+ inToolRun = false
375
+ }
376
+ entries.push([index])
377
+ }
378
+ const entry = entries[wireIndex]
379
+ if (entry === undefined) return undefined
380
+ const mine = slots.filter((slot) => entry.includes(slot.messageIndex))
381
+ if (mine.length === 0) return undefined
382
+ if (contentIndex === undefined) return mine
383
+ const first = entry[0]
384
+ const message = first === undefined ? undefined : messages[first]
385
+ if (message?.role === 'user' && !(family === 'openai' && mine.some((s) => s.source === 'tool'))) {
386
+ const itemIndex = contentIndex - (message.content.length > 0 ? 1 : 0)
387
+ const hit = mine.filter((slot) => slot.itemIndex === itemIndex)
388
+ return hit.length > 0 ? hit : mine
389
+ }
390
+ if (family === 'anthropic' && message?.role === 'tool') {
391
+ const owner = entry[contentIndex]
392
+ const hit = owner === undefined ? [] : mine.filter((slot) => slot.messageIndex === owner)
393
+ return hit.length > 0 ? hit : mine
394
+ }
395
+ return mine
396
+ }
397
+
398
+ interface Learned {
399
+ readonly latched: Map<string, Set<RejectedParameter>>
400
+ readonly reasoningOff: Set<string>
401
+ readonly rejectedMedia: Set<string>
402
+ mediaLimit: number | undefined
403
+ }
404
+
405
+ const emptyLearned = (): Learned => ({
406
+ latched: new Map(),
407
+ reasoningOff: new Set(),
408
+ rejectedMedia: new Set(),
409
+ mediaLimit: undefined,
410
+ })
411
+
412
+ /** The budget and the unconfirmed guesses of one original request, shared by every send it makes. */
413
+ export interface RepairSession {
414
+ readonly spent: Set<string>
415
+ readonly stripsByIndex: Map<number, number>
416
+ pending: Learned
417
+ repairs: number
418
+ removal: MediaRemoval | undefined
419
+ }
420
+
421
+ export class RequestRepair {
422
+ private readonly learned: Learned = emptyLearned()
423
+
424
+ constructor(private readonly hooks: RequestRepairHooks = {}) {}
425
+
426
+ /** A fresh budget for one original request. */
427
+ session(): RepairSession {
428
+ return {
429
+ spent: new Set(),
430
+ stripsByIndex: new Map(),
431
+ pending: emptyLearned(),
432
+ repairs: 0,
433
+ removal: undefined,
434
+ }
435
+ }
436
+
437
+ private served(
438
+ provider: Pick<LLMProvider, 'id'>,
439
+ params: ChatCompletionParams,
440
+ ): { providerId: string; model: string } {
441
+ const member = this.hooks.servedBy?.()
442
+ return {
443
+ providerId: member?.providerId ?? params.providerRoute?.providerId ?? provider.id,
444
+ model: member?.model ?? params.providerRoute?.model ?? params.model,
445
+ }
446
+ }
447
+
448
+ private routeKey(provider: Pick<LLMProvider, 'id'>, params: ChatCompletionParams): string {
449
+ const { providerId, model } = this.served(provider, params)
450
+ return `${providerId}|${model}`
451
+ }
452
+
453
+ /** Everything already confirmed, then the tool pairing the provider requires. */
454
+ private prepare(
455
+ provider: Pick<LLMProvider, 'id'>,
456
+ params: ChatCompletionParams,
457
+ ): ChatCompletionParams {
458
+ const key = this.routeKey(provider, params)
459
+ let next: ChatCompletionParams = params
460
+
461
+ const latched = this.learned.latched.get(key)
462
+ if (latched) next = withoutParameters(next, latched)
463
+
464
+ let messages = next.messages
465
+ if (this.learned.reasoningOff.has(key)) messages = stripReasoning(messages)
466
+ messages = this.applyMediaMemory(messages)
467
+ messages = this.pair(messages)
468
+ return messages === next.messages ? next : { ...next, messages }
469
+ }
470
+
471
+ private applyMediaMemory(messages: Message[]): Message[] {
472
+ const { rejectedMedia, mediaLimit } = this.learned
473
+ if (rejectedMedia.size === 0 && mediaLimit === undefined) return messages
474
+ const slots = listMedia(messages)
475
+ const faulty = slots.filter((slot) => rejectedMedia.has(slot.fingerprint))
476
+ let out = removeMedia(messages, faulty, false)
477
+ if (mediaLimit !== undefined) {
478
+ const rest = listMedia(out)
479
+ if (rest.length > mediaLimit) {
480
+ out = removeMedia(out, rest.slice(0, rest.length - mediaLimit), true)
481
+ }
482
+ }
483
+ return out
484
+ }
485
+
486
+ /** The shared tool-history repair, on the outgoing copy. Duplicate call ids fail closed there, so the request goes out as it is. */
487
+ private pair(messages: Message[]): Message[] {
488
+ try {
489
+ const repaired = repairToolMessageHistory(messages)
490
+ if (!toolHistoryRepairChanged(repaired.report)) return messages
491
+ this.hooks.debug?.(`Repaired tool pairing before sending: ${JSON.stringify(repaired.report)}`)
492
+ return repaired.messages
493
+ } catch (error) {
494
+ this.hooks.debug?.(
495
+ `Tool pairing could not be repaired before sending: ${error instanceof Error ? error.message : String(error)}`,
496
+ )
497
+ return messages
498
+ }
499
+ }
500
+
501
+ /** What a resend that was accepted has proved. */
502
+ private commit(session: RepairSession): void {
503
+ const { pending } = session
504
+ for (const [key, names] of pending.latched) {
505
+ const set = this.learned.latched.get(key) ?? new Set<RejectedParameter>()
506
+ for (const name of names) set.add(name)
507
+ this.learned.latched.set(key, set)
508
+ }
509
+ for (const key of pending.reasoningOff) this.learned.reasoningOff.add(key)
510
+ for (const fingerprint of pending.rejectedMedia) this.learned.rejectedMedia.add(fingerprint)
511
+ if (pending.mediaLimit !== undefined) {
512
+ this.learned.mediaLimit = Math.min(
513
+ this.learned.mediaLimit ?? pending.mediaLimit,
514
+ pending.mediaLimit,
515
+ )
516
+ }
517
+ session.pending = emptyLearned()
518
+ }
519
+
520
+ /** The request to send after `rejection`, or null when nothing more can be tried. */
521
+ private next(
522
+ provider: Pick<LLMProvider, 'id'>,
523
+ params: ChatCompletionParams,
524
+ rejection: Rejection,
525
+ session: RepairSession,
526
+ ): ChatCompletionParams | null {
527
+ const { spent, pending } = session
528
+ const key = this.routeKey(provider, params)
529
+ switch (rejection.kind) {
530
+ case 'tool-pairing': {
531
+ if (spent.has('tool-pairing')) return null
532
+ spent.add('tool-pairing')
533
+ const repaired = repairOnce(params.messages)
534
+ if (repaired === null) return null
535
+ this.hooks.debug?.(`Retrying after a tool-history rejection (${key})`)
536
+ return { ...params, messages: repaired }
537
+ }
538
+ case 'reasoning': {
539
+ if (spent.has('reasoning')) return null
540
+ spent.add('reasoning')
541
+ const messages = stripReasoning(params.messages)
542
+ if (messages === params.messages) return null
543
+ pending.reasoningOff.add(key)
544
+ let next: ChatCompletionParams = { ...params, messages }
545
+ // With thinking on, a tool turn that carries no thinking block is
546
+ // itself refused ("Expected `thinking` … but found `tool_use`"), so
547
+ // the two go together: neither is asked for in this resend.
548
+ if (params.thinking !== undefined && params.thinking.type !== 'disabled') {
549
+ next = withoutParameters(next, new Set<RejectedParameter>(['thinking']))
550
+ const names = pending.latched.get(key) ?? new Set<RejectedParameter>()
551
+ names.add('thinking')
552
+ pending.latched.set(key, names)
553
+ }
554
+ this.hooks.debug?.(`Retrying without reasoning blocks (${key})`)
555
+ return next
556
+ }
557
+ case 'parameter': {
558
+ const fresh = rejection.parameters.filter((name) => !spent.has(`param:${name}`))
559
+ if (fresh.length === 0) return null
560
+ for (const name of fresh) spent.add(`param:${name}`)
561
+ const names = pending.latched.get(key) ?? new Set<RejectedParameter>()
562
+ for (const name of fresh) names.add(name)
563
+ pending.latched.set(key, names)
564
+ this.hooks.debug?.(
565
+ `Retrying without parameters the model refused (${key}): ${fresh.join(', ')}`,
566
+ )
567
+ return withoutParameters(params, new Set(fresh))
568
+ }
569
+ case 'media':
570
+ return this.nextWithoutMedia(provider, params, rejection, session)
571
+ }
572
+ }
573
+
574
+ private nextWithoutMedia(
575
+ provider: Pick<LLMProvider, 'id'>,
576
+ params: ChatCompletionParams,
577
+ rejection: Extract<Rejection, { kind: 'media' }>,
578
+ session: RepairSession,
579
+ ): ChatCompletionParams | null {
580
+ const { spent, stripsByIndex, pending } = session
581
+ const slots = listMedia(params.messages)
582
+ if (slots.length === 0) return null
583
+
584
+ let victims: MediaSlot[] = []
585
+ let limited = false
586
+ if (!rejection.generic) {
587
+ if (rejection.messageIndex !== undefined) {
588
+ const used = stripsByIndex.get(rejection.messageIndex) ?? 0
589
+ if (used < MAX_STRIPS_PER_INDEX) {
590
+ stripsByIndex.set(rejection.messageIndex, used + 1)
591
+ victims =
592
+ mediaAtWireIndex(
593
+ params.messages,
594
+ slots,
595
+ this.served(provider, params).providerId,
596
+ rejection.messageIndex,
597
+ rejection.contentIndex,
598
+ ) ?? []
599
+ }
600
+ }
601
+ if (victims.length === 0) {
602
+ // No exact index: the newest message that carries media.
603
+ const newest = slots[slots.length - 1]
604
+ if (newest === undefined) return null
605
+ const count = (stripsByIndex.get(-1) ?? 0) + 1
606
+ if (count > MAX_STRIPS_PER_INDEX) return null
607
+ stripsByIndex.set(-1, count)
608
+ victims = slots.filter((slot) => slot.messageIndex === newest.messageIndex)
609
+ }
610
+ } else {
611
+ // The reference's steps, then halving for smaller requests, down to
612
+ // none: each step keeps fewer than the request held, newest first.
613
+ const limit =
614
+ GENERIC_MEDIA_LIMITS.find((candidate) => candidate < slots.length) ??
615
+ Math.floor(slots.length / 2)
616
+ spent.add(`limit:${limit}`)
617
+ pending.mediaLimit = Math.min(pending.mediaLimit ?? limit, limit)
618
+ victims = slots.slice(0, slots.length - limit)
619
+ limited = true
620
+ }
621
+
622
+ // A count limit teaches a limit, not that these files are faulty.
623
+ if (!limited) for (const slot of victims) pending.rejectedMedia.add(slot.fingerprint)
624
+ this.hooks.debug?.(`Retrying without ${victims.length} media the provider rejected`)
625
+
626
+ let lastUser = -1
627
+ for (const [index, message] of params.messages.entries()) {
628
+ if (message.role === 'user') lastUser = index
629
+ }
630
+ const kinds = new Set(victims.map((slot) => slot.kind))
631
+ const removal: MediaRemoval = {
632
+ count: victims.length,
633
+ kind: kinds.size === 1 ? ([...kinds][0] as 'image' | 'document') : 'media',
634
+ current: victims.some((slot) => slot.source === 'user' && slot.messageIndex === lastUser),
635
+ limit: limited,
636
+ }
637
+ const before = session.removal
638
+ session.removal = before
639
+ ? {
640
+ count: before.count + removal.count,
641
+ kind: before.kind === removal.kind ? before.kind : 'media',
642
+ current: before.current || removal.current,
643
+ limit: before.limit && removal.limit,
644
+ }
645
+ : removal
646
+ return { ...params, messages: removeMedia(params.messages, victims, limited) }
647
+ }
648
+
649
+ /**
650
+ * Send `params`, repairing and resending a request the provider rejects as
651
+ * invalid until it is accepted or nothing more can be changed. The original
652
+ * rejection is thrown when nothing applies, and what the guesses taught is
653
+ * forgotten unless a resend was accepted.
654
+ */
655
+ async *stream(
656
+ provider: LLMProvider,
657
+ params: ChatCompletionParams,
658
+ session: RepairSession = this.session(),
659
+ ): AsyncIterable<StreamChunk> {
660
+ let current = this.prepare(provider, params)
661
+ let firstError: unknown
662
+
663
+ for (;;) {
664
+ let produced = false
665
+ let reported = false
666
+ try {
667
+ for await (const chunk of provider.chatStream(current)) {
668
+ if (isAccepted(chunk)) {
669
+ produced = true
670
+ this.commit(session)
671
+ if (session.removal !== undefined && !reported) {
672
+ reported = true
673
+ const removal = session.removal
674
+ session.removal = undefined
675
+ await this.hooks.onMediaRemoved?.(removal)
676
+ }
677
+ }
678
+ yield chunk
679
+ }
680
+ this.commit(session)
681
+ if (session.removal !== undefined && !reported) {
682
+ const removal = session.removal
683
+ session.removal = undefined
684
+ await this.hooks.onMediaRemoved?.(removal)
685
+ }
686
+ return
687
+ } catch (error) {
688
+ if (current.signal?.aborted) throw current.signal.reason
689
+ if (produced) throw error
690
+ firstError ??= error
691
+ const forget = (): never => {
692
+ session.pending = emptyLearned()
693
+ session.removal = undefined
694
+ throw firstError
695
+ }
696
+ if (session.repairs >= MAX_REPAIRS_PER_REQUEST) return forget()
697
+ if (isHandledElsewhere(error, current)) return forget()
698
+ const rejection = classifyRequestRejection(error, current)
699
+ if (rejection === null) return forget()
700
+ const step = this.next(provider, current, rejection, session)
701
+ if (step === null) return forget()
702
+ session.repairs++
703
+ current = step
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ /** Re-run the shared repair on the outgoing copy; null when it changes nothing or cannot. */
710
+ function repairOnce(messages: Message[]): Message[] | null {
711
+ try {
712
+ const repaired = repairToolMessageHistory(messages)
713
+ return toolHistoryRepairChanged(repaired.report) ? repaired.messages : null
714
+ } catch {
715
+ return null
716
+ }
717
+ }
718
+
719
+ function withoutParameters(
720
+ params: ChatCompletionParams,
721
+ names: ReadonlySet<RejectedParameter>,
722
+ ): ChatCompletionParams {
723
+ if (names.size === 0) return params
724
+ const next = { ...params }
725
+ if (names.has('effort')) next.effort = undefined
726
+ if (names.has('thinking')) next.thinking = undefined
727
+ if (names.has('temperature')) next.temperature = undefined
728
+ if (names.has('topP')) next.topP = undefined
729
+ if (names.has('responseFormat')) next.responseFormat = undefined
730
+ return next
731
+ }
732
+
733
+ /**
734
+ * Rejections another recovery already owns: a model that reads text only, and
735
+ * a single distinct image the provider calls invalid. Those keep their own
736
+ * commit points in `provider-rejected-image.ts`, which wraps this one.
737
+ */
738
+ function isHandledElsewhere(error: unknown, params: ChatCompletionParams): boolean {
739
+ if (isProviderRejectedImageError(error) && findSingleRequestImage(params.messages) !== null) {
740
+ return true
741
+ }
742
+ return isImageInputRefusal(error)
743
+ }