@cat-factory/executor-harness 1.31.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/agent-runner.js +389 -0
  4. package/dist/agent.js +810 -0
  5. package/dist/blueprint.js +367 -0
  6. package/dist/bootstrap.js +99 -0
  7. package/dist/ci-fixer.js +46 -0
  8. package/dist/coding-agent.js +285 -0
  9. package/dist/conflict-resolver.js +138 -0
  10. package/dist/embed.js +8 -0
  11. package/dist/explore.js +74 -0
  12. package/dist/failure.js +47 -0
  13. package/dist/fixer.js +44 -0
  14. package/dist/follow-ups.js +103 -0
  15. package/dist/frontend-infra.js +283 -0
  16. package/dist/fs-utils.js +11 -0
  17. package/dist/git.js +778 -0
  18. package/dist/job.js +409 -0
  19. package/dist/logger.js +27 -0
  20. package/dist/merger.js +135 -0
  21. package/dist/on-call.js +126 -0
  22. package/dist/pi-workspace.js +237 -0
  23. package/dist/pi.js +971 -0
  24. package/dist/process.js +25 -0
  25. package/dist/redact.js +109 -0
  26. package/dist/runner.js +228 -0
  27. package/dist/server.js +135 -0
  28. package/dist/spec.js +754 -0
  29. package/dist/structured-output.js +431 -0
  30. package/dist/tester.js +191 -0
  31. package/package.json +35 -0
  32. package/src/agent-runner.ts +484 -0
  33. package/src/agent.ts +948 -0
  34. package/src/coding-agent.ts +393 -0
  35. package/src/embed.ts +32 -0
  36. package/src/failure.ts +73 -0
  37. package/src/follow-ups.ts +106 -0
  38. package/src/frontend-infra.ts +340 -0
  39. package/src/fs-utils.ts +11 -0
  40. package/src/git.ts +955 -0
  41. package/src/job.ts +766 -0
  42. package/src/logger.ts +45 -0
  43. package/src/pi-workspace.ts +348 -0
  44. package/src/pi.ts +1236 -0
  45. package/src/process.ts +33 -0
  46. package/src/redact.ts +109 -0
  47. package/src/runner.ts +384 -0
  48. package/src/server.ts +153 -0
  49. package/src/structured-output.ts +524 -0
@@ -0,0 +1,524 @@
1
+ import { redact, redactSecrets, secretsToRedact } from './redact.js'
2
+ import { log } from './logger.js'
3
+ import { PI_MAX_OUTPUT_TOKENS } from './pi.js'
4
+
5
+ // A reusable abstraction for the "agent returns a structured JSON document as its
6
+ // final assistant message" pattern (requirements, blueprint, merger — and any future
7
+ // kind). An agent of this kind emits its result as text, not a tool call, and the
8
+ // harness parses it. A model can produce text that won't parse: truncated JSON,
9
+ // prose/fences around it, trailing commas, or the workers-ai-provider reasoning-model
10
+ // streaming corruption that duplicates every token (`serviceservice…`).
11
+ //
12
+ // Instead of failing the whole container run on the first unparseable reply, a caller
13
+ // describes its output once as a `StructuredOutputSpec<T>` (a label, a shape hint, and
14
+ // a parser) and calls `resolveStructuredOutput`. That:
15
+ // 1. tries to parse the primary (Pi) output;
16
+ // 2. on failure, makes ONE structured repair call — a single-shot, no-tools,
17
+ // NON-streaming completion through the same proxy with `response_format:
18
+ // json_object`, asking the model to return only the corrected JSON — and reparses;
19
+ // 3. returns the value (or null) plus structured diagnostics.
20
+ //
21
+ // It is provider-agnostic (external OpenAI-compatible upstreams honour
22
+ // `response_format`; the in-process Workers AI path ignores it but answers buffered,
23
+ // sidestepping the streaming double-emit, and the focused prompt keeps it to JSON) and
24
+ // observable (the repair call lands in `llm_call_metrics` as a NON-streaming row, and
25
+ // every parse failure / repair outcome is logged so "this happened" and "the retry
26
+ // didn't help" are both queryable).
27
+
28
+ /** Output-token ceiling for the repair call — mirrors the harness's PI_MAX_OUTPUT_TOKENS. */
29
+ const REPAIR_MAX_OUTPUT_TOKENS = PI_MAX_OUTPUT_TOKENS
30
+
31
+ /** Hard cap on how much malformed text we feed the repair model (keep the call cheap). */
32
+ const MAX_REPAIR_INPUT_CHARS = 40_000
33
+
34
+ const REPAIR_SYSTEM =
35
+ 'You repair malformed JSON. You are given text that was meant to be a single ' +
36
+ 'JSON object but does not parse. Return ONLY the corrected JSON object: no prose, ' +
37
+ 'no markdown code fences, no commentary, and never repeat or duplicate any tokens. ' +
38
+ 'Preserve the original content faithfully; only fix the JSON structure.'
39
+
40
+ /**
41
+ * Declarative definition of one structured-output kind: how to label it, what shape
42
+ * to ask the repair model for, and how to turn the agent's text into the domain value.
43
+ * `parse` returns null (or throws) when the text is unusable. Built per-job when the
44
+ * parser needs job context (e.g. a fallback service name).
45
+ */
46
+ export interface StructuredOutputSpec<T> {
47
+ /** Label for logs/telemetry, e.g. `requirements` / `blueprint` / `merger`. */
48
+ label: string
49
+ /** Compact human description of the expected top-level JSON shape, fed to the model. */
50
+ shapeHint: string
51
+ /** Parse the agent's text into the domain value, or null/throw when unusable. */
52
+ parse: (text: string) => T | null
53
+ }
54
+
55
+ /** Runtime wiring to reach the LLM proxy for the repair call. */
56
+ export interface ProxyAccess {
57
+ /** Pi-harness proxy base URL; absent for subscription harnesses (no proxy repair). */
58
+ proxyBaseUrl?: string
59
+ /** Pi-harness proxy session token; absent for subscription harnesses. */
60
+ sessionToken?: string
61
+ model: string
62
+ jobId: string
63
+ signal?: AbortSignal
64
+ /** Carried for context (the subscription harnesses can't use the proxy for repair). */
65
+ harness?: string
66
+ subscriptionToken?: string
67
+ subscriptionBaseUrl?: string
68
+ }
69
+
70
+ /** Structured diagnostics for a resolution attempt, surfaced to logs + the failure reason. */
71
+ export interface StructuredOutputDiagnostics {
72
+ /** Which attempt produced a usable value (or `none` when both failed). */
73
+ parsedOn: 'primary' | 'repair' | 'none'
74
+ /** Length of the agent's primary (Pi) output, in characters. */
75
+ primaryChars: number
76
+ /** Whether the primary output looked token-doubled (advisory heuristic). */
77
+ looksDoubled: boolean
78
+ /** Whether a repair call was made. */
79
+ repairAttempted: boolean
80
+ /** Whether the repair call produced a usable value. */
81
+ repairSucceeded: boolean
82
+ /** One-line reason the repair call itself failed (HTTP error / still-unparseable), if any. */
83
+ repairError?: string
84
+ }
85
+
86
+ export interface StructuredOutputResult<T> {
87
+ value: T | null
88
+ diagnostics: StructuredOutputDiagnostics
89
+ }
90
+
91
+ /**
92
+ * Largest immediately-repeated run length we look for. The corruption duplicates
93
+ * whole model tokens, which carry whitespace/punctuation context and run to ~10-15
94
+ * chars (`"service"`, `observability`); 24 covers them with headroom while staying
95
+ * cheap. We don't match single chars (k>=2): a lone doubled `{`/space is normal.
96
+ */
97
+ const MAX_DOUBLE_RUN = 24
98
+
99
+ /**
100
+ * Cap on how much of a (possibly huge) failed output the doubling heuristic scans.
101
+ * The corruption is uniform across the whole reply, so a prefix is representative,
102
+ * and this bounds the otherwise O(n·{@link MAX_DOUBLE_RUN}²) scan on a large
103
+ * document. The detector only runs on the parse-failure path, so this is belt-and-
104
+ * braces rather than a hot-path concern.
105
+ */
106
+ const MAX_DOUBLE_SCAN_CHARS = 20_000
107
+
108
+ /**
109
+ * Heuristic detector for the token-doubling corruption ("serviceservice",
110
+ * "observobservabilityability", `{\n{\n`). Greedy scan over a bounded prefix: at each
111
+ * position, find the longest 2..{@link MAX_DOUBLE_RUN}-char run that is immediately
112
+ * repeated and count both copies as "doubled", then measure the doubled fraction of
113
+ * the scanned text. Token-doubled text (consecutive `t t` pairs) scores near 1.0;
114
+ * normal JSON/prose scores low (only incidental short repeats). Advisory ONLY — it
115
+ * labels a failure for telemetry, it never mutates output.
116
+ */
117
+ export function looksTokenDoubled(text: string): { doubled: boolean; ratio: number } {
118
+ // Scan at most MAX_DOUBLE_SCAN_CHARS; `startsWith` stays within this prefix because
119
+ // `maxK` bounds each match so `i + matched * 2 <= n`.
120
+ const n = Math.min(text.length, MAX_DOUBLE_SCAN_CHARS)
121
+ if (n < 40) return { doubled: false, ratio: 0 }
122
+ let covered = 0
123
+ let i = 0
124
+ while (i < n) {
125
+ let matched = 0
126
+ const maxK = Math.min(MAX_DOUBLE_RUN, Math.floor((n - i) / 2))
127
+ for (let k = maxK; k >= 2; k--) {
128
+ // Is the k-char run at i immediately followed by an identical run?
129
+ if (text.startsWith(text.slice(i, i + k), i + k)) {
130
+ matched = k
131
+ break
132
+ }
133
+ }
134
+ if (matched > 0) {
135
+ covered += matched * 2
136
+ i += matched * 2
137
+ } else {
138
+ i += 1
139
+ }
140
+ }
141
+ const ratio = covered / n
142
+ return { doubled: ratio >= 0.5, ratio }
143
+ }
144
+
145
+ /**
146
+ * Resolve a structured output: parse the agent's `primaryText` via `spec.parse`; on
147
+ * failure, make ONE structured repair call and re-parse. Returns the value (or null
148
+ * when both attempts fail) plus {@link StructuredOutputDiagnostics}. Logging side
149
+ * effects only; never throws (a repair transport error is captured in the diagnostics).
150
+ */
151
+ export async function resolveStructuredOutput<T>(
152
+ spec: StructuredOutputSpec<T>,
153
+ primaryText: string,
154
+ access: ProxyAccess,
155
+ ): Promise<StructuredOutputResult<T>> {
156
+ const trace = { agent: spec.label, jobId: access.jobId }
157
+ const primaryChars = primaryText.length
158
+
159
+ const primary = safeParse(primaryText, spec.parse)
160
+ if (primary !== null) {
161
+ return {
162
+ value: primary,
163
+ diagnostics: {
164
+ parsedOn: 'primary',
165
+ primaryChars,
166
+ looksDoubled: false,
167
+ repairAttempted: false,
168
+ repairSucceeded: false,
169
+ },
170
+ }
171
+ }
172
+
173
+ // Pick a repair channel. The Pi harness repairs through the LLM proxy; the
174
+ // claude-code subscription harness has no proxy but DOES speak a standard
175
+ // Anthropic Messages API (Anthropic itself, or an Anthropic-compatible endpoint
176
+ // for GLM/Kimi/DeepSeek), so it repairs straight against the vendor with the
177
+ // leased token. Codex has no simple JSON API, so it keeps the graceful no-repair
178
+ // path (the smaller GLM/Kimi/DeepSeek models — most prone to malformed JSON — are
179
+ // covered by the claude-code channel).
180
+ const canProxyRepair = !!access.proxyBaseUrl && !!access.sessionToken
181
+ const canSubscriptionRepair = access.harness === 'claude-code' && !!access.subscriptionToken
182
+ if (!canProxyRepair && !canSubscriptionRepair) {
183
+ return {
184
+ value: null,
185
+ diagnostics: {
186
+ parsedOn: 'none',
187
+ primaryChars,
188
+ looksDoubled: looksTokenDoubled(primaryText).doubled,
189
+ repairAttempted: false,
190
+ repairSucceeded: false,
191
+ repairError: `structured-output repair unavailable for the ${access.harness ?? 'pi'} harness`,
192
+ },
193
+ }
194
+ }
195
+
196
+ // Primary failed: label the corruption (doubling is the known reasoning-model
197
+ // streaming bug) and record the event before spending a repair call.
198
+ const doubled = looksTokenDoubled(primaryText)
199
+ log.warn('structured-output: primary unparseable — attempting structured repair', {
200
+ ...trace,
201
+ primaryChars,
202
+ looksDoubled: doubled.doubled,
203
+ doubledRatio: Number(doubled.ratio.toFixed(2)),
204
+ })
205
+
206
+ let repairError: string | undefined
207
+ let repaired: T | null = null
208
+ try {
209
+ const repairedText = await callRepair(primaryText, spec, access)
210
+ repaired = safeParse(repairedText, spec.parse)
211
+ if (repaired === null) repairError = 'repair output still did not parse'
212
+ } catch (err) {
213
+ repairError = err instanceof Error ? err.message : String(err)
214
+ }
215
+
216
+ if (repaired !== null) {
217
+ log.info('structured-output: repair recovered a usable document', { ...trace, primaryChars })
218
+ return {
219
+ value: repaired,
220
+ diagnostics: {
221
+ parsedOn: 'repair',
222
+ primaryChars,
223
+ looksDoubled: doubled.doubled,
224
+ repairAttempted: true,
225
+ repairSucceeded: true,
226
+ },
227
+ }
228
+ }
229
+
230
+ // The retry did not help — the case we explicitly want visible in telemetry.
231
+ log.error('structured-output: unrecoverable after structured repair', {
232
+ ...trace,
233
+ primaryChars,
234
+ looksDoubled: doubled.doubled,
235
+ doubledRatio: Number(doubled.ratio.toFixed(2)),
236
+ repairError,
237
+ })
238
+ return {
239
+ value: null,
240
+ diagnostics: {
241
+ parsedOn: 'none',
242
+ primaryChars,
243
+ looksDoubled: doubled.doubled,
244
+ repairAttempted: true,
245
+ repairSucceeded: false,
246
+ repairError,
247
+ },
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Make the structured repair call and return the model's text (the corrected JSON,
253
+ * ideally). Throws on a transport/HTTP error so the caller records it as the repair
254
+ * failure reason. Routes to the LLM proxy (Pi harness) when present, else to the
255
+ * claude-code subscription harness's own Anthropic-compatible endpoint.
256
+ */
257
+ async function callRepair<T>(
258
+ badText: string,
259
+ spec: StructuredOutputSpec<T>,
260
+ access: ProxyAccess,
261
+ ): Promise<string> {
262
+ if ((!access.proxyBaseUrl || !access.sessionToken) && access.subscriptionToken) {
263
+ return callSubscriptionRepair(badText, spec, access)
264
+ }
265
+ // Only ever called after the caller verified the proxy is present (Pi harness).
266
+ if (!access.proxyBaseUrl || !access.sessionToken) {
267
+ throw new Error('structured-output repair requires the LLM proxy (Pi harness)')
268
+ }
269
+ const url = `${access.proxyBaseUrl.replace(/\/+$/, '')}/chat/completions`
270
+ const messages = [
271
+ { role: 'system', content: REPAIR_SYSTEM },
272
+ {
273
+ role: 'user',
274
+ content:
275
+ `${spec.shapeHint}\n\n` +
276
+ 'The text below was meant to be that JSON object but does not parse. Return ' +
277
+ 'ONLY the corrected JSON object.\n\n' +
278
+ badText.slice(0, MAX_REPAIR_INPUT_CHARS),
279
+ },
280
+ ]
281
+ const base = {
282
+ // The proxy locks the model to the session's; sent for completeness.
283
+ model: access.model,
284
+ stream: false,
285
+ max_tokens: REPAIR_MAX_OUTPUT_TOKENS,
286
+ // No `temperature`: the newest models (Anthropic Opus 4.7+/the Claude 5 family) reject
287
+ // any sampling parameter with a 400, and a single-shot repair whose system prompt already
288
+ // forces JSON-only output doesn't need one — so we omit it for every model/provider.
289
+ messages,
290
+ }
291
+
292
+ // Capability gate: ask for `json_object` structured output (honoured by external
293
+ // OpenAI-compatible upstreams; ignored by the in-process Workers AI path). If an
294
+ // upstream REJECTS the parameter (4xx), fall back to the prompt-only path — the
295
+ // system prompt already demands JSON — rather than failing the repair outright.
296
+ const withFormat = { ...base, response_format: { type: 'json_object' } }
297
+ let res = await post(url, access, withFormat)
298
+ // A 4xx here means the upstream REJECTED `response_format` → fall back to prompt-only. Exclude
299
+ // 429: it is a rate-limit (already retried with backoff inside `post`), not a param rejection,
300
+ // so re-interpreting it as one would waste a second full prompt-only round on a rate-limit.
301
+ if (!res.ok && res.status !== 429 && res.status >= 400 && res.status < 500) {
302
+ log.warn('structured-output: repair upstream rejected response_format — retrying prompt-only', {
303
+ agent: spec.label,
304
+ jobId: access.jobId,
305
+ status: res.status,
306
+ })
307
+ res = await post(url, access, base)
308
+ }
309
+ if (!res.ok) {
310
+ const detail = redactSecrets((await res.text().catch(() => '')).slice(0, 300))
311
+ throw new Error(`repair call failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`)
312
+ }
313
+ const json = (await res.json()) as { choices?: Array<{ message?: { content?: string | null } }> }
314
+ const content = json.choices?.[0]?.message?.content
315
+ return typeof content === 'string' ? content : ''
316
+ }
317
+
318
+ /**
319
+ * Repair via the claude-code subscription harness's own vendor endpoint (no proxy):
320
+ * a single non-streaming Anthropic Messages call with the leased token. Anthropic
321
+ * itself uses the OAuth token (Bearer + the oauth beta header) against
322
+ * api.anthropic.com; an Anthropic-compatible vendor (GLM/Kimi/DeepSeek) uses its
323
+ * `subscriptionBaseUrl` with the API-token `x-api-key` header. Best-effort: any
324
+ * error propagates to the caller's `repairError` and degrades to the null path.
325
+ */
326
+ async function callSubscriptionRepair<T>(
327
+ badText: string,
328
+ spec: StructuredOutputSpec<T>,
329
+ access: ProxyAccess,
330
+ ): Promise<string> {
331
+ if (!access.subscriptionToken) {
332
+ throw new Error('structured-output subscription repair requires a subscription token')
333
+ }
334
+ const base = access.subscriptionBaseUrl?.replace(/\/+$/, '') ?? 'https://api.anthropic.com'
335
+ const url = `${base}/v1/messages`
336
+ const headers: Record<string, string> = {
337
+ 'content-type': 'application/json',
338
+ 'anthropic-version': '2023-06-01',
339
+ }
340
+ if (access.subscriptionBaseUrl) {
341
+ // Anthropic-compatible vendor (GLM/Kimi/DeepSeek): API token via x-api-key.
342
+ headers['x-api-key'] = access.subscriptionToken
343
+ } else {
344
+ // Anthropic on a Claude subscription OAuth token.
345
+ headers.authorization = `Bearer ${access.subscriptionToken}`
346
+ headers['anthropic-beta'] = 'oauth-2025-04-20'
347
+ }
348
+ const body = {
349
+ model: access.model,
350
+ max_tokens: REPAIR_MAX_OUTPUT_TOKENS,
351
+ // No `temperature`: Anthropic's newest models (Opus 4.7+/Claude 5 family) reject the
352
+ // sampling parameters with `400 invalid_request_error: temperature is deprecated for this
353
+ // model`. The repair prompt fully constrains the output to JSON, so determinism via
354
+ // temperature=0 isn't needed — omitting it keeps the call valid on every model.
355
+ system: REPAIR_SYSTEM,
356
+ messages: [
357
+ {
358
+ role: 'user',
359
+ content:
360
+ `${spec.shapeHint}\n\n` +
361
+ 'The text below was meant to be that JSON object but does not parse. Return ' +
362
+ 'ONLY the corrected JSON object.\n\n' +
363
+ badText.slice(0, MAX_REPAIR_INPUT_CHARS),
364
+ },
365
+ ],
366
+ }
367
+ const res = await fetchRepairWithRetry(
368
+ () =>
369
+ fetch(url, {
370
+ method: 'POST',
371
+ headers,
372
+ body: JSON.stringify(body),
373
+ signal: access.signal,
374
+ }),
375
+ access.signal,
376
+ access.jobId,
377
+ )
378
+ if (!res.ok) {
379
+ // A vendor 4xx body can echo the API key/token back; `redact` applies both the
380
+ // GitHub-shaped pattern rules AND scrubs the leased subscription credential (the raw
381
+ // value, and — for a JSON auth bundle — its nested token leaves) before surfacing.
382
+ const raw = (await res.text().catch(() => '')).slice(0, 300)
383
+ const detail = redact(raw, secretsToRedact(access.subscriptionToken ?? ''))
384
+ throw new Error(
385
+ `subscription repair call failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`,
386
+ )
387
+ }
388
+ const json = (await res.json()) as { content?: Array<{ type?: string; text?: string }> }
389
+ // Concatenate the text blocks of the Anthropic Messages response.
390
+ return (json.content ?? [])
391
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
392
+ .map((b) => b.text)
393
+ .join('')
394
+ }
395
+
396
+ /** POST a chat-completions body to the proxy with the session bearer token. */
397
+ function post(url: string, access: ProxyAccess, body: unknown): Promise<Response> {
398
+ return fetchRepairWithRetry(
399
+ () =>
400
+ fetch(url, {
401
+ method: 'POST',
402
+ headers: {
403
+ authorization: `Bearer ${access.sessionToken}`,
404
+ 'content-type': 'application/json',
405
+ },
406
+ body: JSON.stringify(body),
407
+ signal: access.signal,
408
+ }),
409
+ access.signal,
410
+ access.jobId,
411
+ )
412
+ }
413
+
414
+ // A single structured-repair call is the LAST line of defence before an unparseable agent
415
+ // reply fails the whole run. A TRANSIENT upstream blip on that one call — most importantly a
416
+ // 429 rate-limit (which once turned a recoverable parse into a hard `no structured result`
417
+ // failure), but also a 5xx or a dropped connection — must not be fatal, so retry it with
418
+ // exponential backoff honoring `Retry-After`.
419
+ const REPAIR_RETRY_ATTEMPTS = 3
420
+ const REPAIR_RETRY_BASE_MS = 500
421
+ const REPAIR_RETRY_MAX_MS = 8_000
422
+
423
+ /** `Retry-After` (seconds or HTTP-date) as ms, capped; undefined if absent/invalid. */
424
+ function repairRetryAfterMs(res: Response): number | undefined {
425
+ const raw = res.headers.get('retry-after')
426
+ if (!raw) return undefined
427
+ const secs = Number(raw)
428
+ if (Number.isFinite(secs))
429
+ return secs > 0 ? Math.min(secs * 1000, REPAIR_RETRY_MAX_MS) : undefined
430
+ const at = Date.parse(raw)
431
+ if (Number.isNaN(at)) return undefined
432
+ const ms = at - Date.now()
433
+ return ms > 0 ? Math.min(ms, REPAIR_RETRY_MAX_MS) : undefined
434
+ }
435
+
436
+ /** Exponential backoff (base 500ms, capped 8s) with up to 25% positive jitter. */
437
+ function repairBackoffMs(attempt: number): number {
438
+ const base = Math.min(REPAIR_RETRY_MAX_MS, REPAIR_RETRY_BASE_MS * 2 ** (attempt - 1))
439
+ return base + Math.floor(base * 0.25 * Math.random())
440
+ }
441
+
442
+ /** Sleep `ms`, rejecting early if the abort signal fires. */
443
+ async function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
444
+ if (ms <= 0) return
445
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
446
+ await new Promise<void>((resolve, reject) => {
447
+ const onAbort = () => {
448
+ clearTimeout(timer)
449
+ reject(signal?.reason ?? new Error('aborted'))
450
+ }
451
+ const timer = setTimeout(() => {
452
+ signal?.removeEventListener('abort', onAbort)
453
+ resolve()
454
+ }, ms)
455
+ signal?.addEventListener('abort', onAbort, { once: true })
456
+ })
457
+ }
458
+
459
+ /**
460
+ * Run a repair fetch, retrying TRANSIENT failures (HTTP 429 / >=500 / network error) with
461
+ * exponential backoff honoring `Retry-After`. A caller abort is terminal. Non-transient
462
+ * responses (2xx/4xx, e.g. a `response_format` rejection) and the final attempt return
463
+ * as-is — the caller's existing `!res.ok` handling then produces the repair diagnostic
464
+ * without this masking a genuine, non-retryable error.
465
+ */
466
+ async function fetchRepairWithRetry(
467
+ doFetch: () => Promise<Response>,
468
+ signal?: AbortSignal,
469
+ jobId?: string,
470
+ ): Promise<Response> {
471
+ let lastError: unknown
472
+ for (let attempt = 1; attempt <= REPAIR_RETRY_ATTEMPTS; attempt++) {
473
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
474
+ let res: Response | undefined
475
+ try {
476
+ res = await doFetch()
477
+ } catch (err) {
478
+ // A caller/watchdog abort is terminal; a network error is transient → retry.
479
+ if (signal?.aborted) throw err
480
+ lastError = err
481
+ }
482
+ if (res) {
483
+ const transient = res.status === 429 || res.status >= 500
484
+ if (!transient || attempt >= REPAIR_RETRY_ATTEMPTS) return res
485
+ const wait = repairRetryAfterMs(res) ?? repairBackoffMs(attempt)
486
+ // Discard the unread body before retrying so the connection can be reused.
487
+ await res.body?.cancel().catch(() => {})
488
+ log.warn('structured-output: repair upstream transient failure — backing off', {
489
+ jobId,
490
+ status: res.status,
491
+ attempt,
492
+ waitMs: wait,
493
+ })
494
+ await abortableDelay(wait, signal)
495
+ continue
496
+ }
497
+ if (attempt >= REPAIR_RETRY_ATTEMPTS) break
498
+ await abortableDelay(repairBackoffMs(attempt), signal)
499
+ }
500
+ throw lastError instanceof Error ? lastError : new Error('repair request failed after retries')
501
+ }
502
+
503
+ /** Run `parse`, treating a thrown error (e.g. `extractJsonObject`) as "no value". */
504
+ function safeParse<T>(text: string, parse: (text: string) => T | null): T | null {
505
+ try {
506
+ return parse(text)
507
+ } catch {
508
+ return null
509
+ }
510
+ }
511
+
512
+ /** Append a compact, human-readable diagnostics suffix to a no-document failure reason. */
513
+ export function diagnosticsSuffix(d: StructuredOutputDiagnostics): string {
514
+ const parts: string[] = []
515
+ if (d.looksDoubled) parts.push('output appeared token-doubled (streaming corruption)')
516
+ if (d.repairAttempted) {
517
+ parts.push(
518
+ d.repairSucceeded
519
+ ? 'structured repair recovered it'
520
+ : `structured repair did not help${d.repairError ? ` (${d.repairError})` : ''}`,
521
+ )
522
+ }
523
+ return parts.length > 0 ? ` [${parts.join('; ')}]` : ''
524
+ }