@bitkyc08/opencodex 2.7.4 → 2.7.7

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.
@@ -12,28 +12,53 @@ const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
12
12
  const DEFAULT_SIDECAR_REASONING = "low";
13
13
  const DEFAULT_MAX_SEARCHES = 3;
14
14
  const DEFAULT_TIMEOUT_MS = 200_000;
15
+ const DEFAULT_ROUTED_MODEL_STALL_TIMEOUT_MS = 200_000;
16
+ const MAX_ROUTED_MODEL_STALL_TIMEOUT_MS = 2_147_483_647;
15
17
  // Mirrors the bridge's stall default (bridge.ts `options?.stallTimeoutSec ?? 90`).
16
18
  const DEFAULT_STALL_TIMEOUT_SEC = 90;
17
19
  const STALL_MARGIN_SEC = 30;
18
20
 
21
+ /**
22
+ * Resolve the config-file-only routed-model raw-byte inactivity budget. Runtime config loading is
23
+ * deliberately permissive, so malformed values fall back locally without rejecting or rewriting
24
+ * the caller's config object.
25
+ */
26
+ export function resolveRoutedModelStallTimeoutMs(value: unknown): number {
27
+ return typeof value === "number"
28
+ && Number.isInteger(value)
29
+ && value >= 1
30
+ && value <= MAX_ROUTED_MODEL_STALL_TIMEOUT_MS
31
+ ? value
32
+ : DEFAULT_ROUTED_MODEL_STALL_TIMEOUT_MS;
33
+ }
34
+
35
+ function finiteCeil(value: number | undefined, fallback: number): number {
36
+ return typeof value === "number" && Number.isFinite(value)
37
+ ? Math.max(0, Math.ceil(value))
38
+ : fallback;
39
+ }
40
+
19
41
  /**
20
42
  * Effective bridge stall deadline (seconds) for the web-search loop. The loop's silent work units
21
- * are individually bounded one non-streaming model iteration by `connectTimeoutMs`, one sidecar
22
- * search by the sidecar `timeoutMs` and seam heartbeats in the loop keep every silent span down
23
- * to ONE such unit. The stall deadline must therefore cover the largest unit plus a margin;
43
+ * are individually bounded by the configured bridge stall, response-header connect timeout,
44
+ * routed-model response-body inactivity timeout, or sidecar timeout. The stall deadline must cover
45
+ * the largest unit plus a margin;
24
46
  * otherwise a legitimately slow search trips the bridge's 90s default upstream_stall_timeout and
25
47
  * kills the whole turn. Stays finite so a genuine hang is still cut off.
26
48
  */
27
49
  export function webSearchStallTimeoutSec(
28
50
  configuredSec: number | undefined,
29
51
  connectTimeoutMs: number | undefined,
30
- sidecarTimeoutMs: number,
52
+ routedModelStallTimeoutMs: number,
53
+ sidecarTimeoutMs: number = routedModelStallTimeoutMs,
31
54
  ): number {
32
- return Math.max(
33
- configuredSec ?? DEFAULT_STALL_TIMEOUT_SEC,
34
- Math.ceil((connectTimeoutMs ?? 0) / 1000),
35
- Math.ceil(sidecarTimeoutMs / 1000),
36
- ) + STALL_MARGIN_SEC;
55
+ const largestUnitSec = Math.max(
56
+ finiteCeil(configuredSec, DEFAULT_STALL_TIMEOUT_SEC),
57
+ finiteCeil(connectTimeoutMs, 0) / 1000,
58
+ finiteCeil(routedModelStallTimeoutMs, 0) / 1000,
59
+ finiteCeil(sidecarTimeoutMs, 0) / 1000,
60
+ );
61
+ return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC);
37
62
  }
38
63
 
39
64
  /** First configured forward (ChatGPT passthrough) provider — the only path with server-side web_search. */
@@ -50,6 +75,8 @@ export interface SidecarPlan {
50
75
  hostedTool: Record<string, unknown>;
51
76
  settings: SidecarSettings;
52
77
  maxSearches: number;
78
+ /** Resolved routed-model response-body raw-byte inactivity deadline (ms). */
79
+ routedModelStallTimeoutMs: number;
53
80
  /** Effective bridge stall deadline for the sidecar turn (see webSearchStallTimeoutSec). */
54
81
  stallTimeoutSec: number;
55
82
  }
@@ -76,6 +103,7 @@ export function planWebSearch(
76
103
  const forwardProvider = findForwardProvider(config);
77
104
  if (!forwardProvider) return undefined;
78
105
  const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
106
+ const routedModelStallTimeoutMs = resolveRoutedModelStallTimeoutMs(cfg.routedModelStallTimeoutMs);
79
107
  // Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop.
80
108
  const connectTimeoutMs = config.connectTimeoutMs ?? 200_000;
81
109
  return {
@@ -89,6 +117,12 @@ export function planWebSearch(
89
117
  describeImages: modelInList(provider.noVisionModels, modelId),
90
118
  },
91
119
  maxSearches: cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES,
92
- stallTimeoutSec: webSearchStallTimeoutSec(config.stallTimeoutSec, connectTimeoutMs, timeoutMs),
120
+ routedModelStallTimeoutMs,
121
+ stallTimeoutSec: webSearchStallTimeoutSec(
122
+ config.stallTimeoutSec,
123
+ connectTimeoutMs,
124
+ routedModelStallTimeoutMs,
125
+ timeoutMs,
126
+ ),
93
127
  };
94
128
  }
@@ -3,9 +3,11 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx
3
3
  import { namespacedToolName } from "../types";
4
4
  import { bridgeToResponsesSSE } from "../bridge";
5
5
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
6
- import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
6
+ import { clearableDeadline } from "../lib/abort";
7
+ import { readBoundedResponseBody } from "../lib/bounded-body";
7
8
  import { fetchWithResetRetry } from "../lib/upstream-retry";
8
9
  import { formatWebSearchResults } from "./format-result";
10
+ import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "./progress-stream";
9
11
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
10
12
 
11
13
  const SSE_HEADERS = {
@@ -168,13 +170,14 @@ export interface WebSearchLoopDeps {
168
170
  forceEmptyResponseId?: boolean;
169
171
  abortSignal?: AbortSignal;
170
172
  recordSidecarOutcome?: SidecarOutcomeRecorder;
171
- /** Per-iteration deadline for routed model calls (mirrors the normal path's connectTimeoutMs). */
173
+ /** Cumulative per-iteration deadline for DNS/TCP/TLS and final response headers only. */
172
174
  connectTimeoutMs?: number;
175
+ /** Continuous routed-model response-body raw-byte inactivity deadline. Default 200000ms. */
176
+ routedModelStallTimeoutMs?: number;
173
177
  /**
174
178
  * Effective bridge stall deadline for this turn (seconds). Computed by planWebSearch
175
- * (webSearchStallTimeoutSec) to cover the loop's bounded silent units a non-streaming model
176
- * iteration (connectTimeoutMs) or one sidecar search (settings.timeoutMs) so a legitimately
177
- * slow-but-bounded unit never trips the bridge's 90s default upstream_stall_timeout.
179
+ * (webSearchStallTimeoutSec) to cover response-header wait, routed-model body inactivity, and one
180
+ * sidecar search, so a legitimately slow-but-progressing unit never trips the bridge watchdog.
178
181
  */
179
182
  stallTimeoutSec?: number;
180
183
  /**
@@ -185,16 +188,15 @@ export interface WebSearchLoopDeps {
185
188
  }
186
189
 
187
190
  /**
188
- * Run the main (non-OpenAI) model in a small agentic loop. Each iteration is a NON-streaming adapter
189
- * call; if the model invokes web_search, run it via the gpt-mini sidecar, inject the answer as a
190
- * tool_result, and loop (bounded by `maxSearches`). Otherwise bridge the final events to Codex as a
191
- * streamed Responses SSE. web_search calls are executed internally and never relayed to Codex.
191
+ * Run the main (non-OpenAI) model in a small agentic loop. Each upstream iteration is streamed and
192
+ * fully buffered internally so raw byte progress is observable without leaking a synthetic tool or
193
+ * preliminary assistant output. If the model invokes web_search, run it via the hosted sidecar,
194
+ * inject the answer as a tool_result, and loop (bounded by `maxSearches`).
192
195
  */
193
196
  export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
194
197
  const { parsed, selectedForwardHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal, recordSidecarOutcome } = deps;
195
198
  // Mutable: 429 key-failover (deps.on429) can swap in a rebuilt adapter mid-loop.
196
199
  let adapter = deps.adapter;
197
- if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
198
200
 
199
201
  const messages: OcxMessage[] = [...parsed.context.messages];
200
202
  const loopT0 = Date.now();
@@ -221,16 +223,19 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
221
223
 
222
224
  // Hard iteration bound (termination safety net); forceAnswer normally ends the loop sooner.
223
225
  const HARD_CAP = maxSearches + 2;
226
+ const connectTimeoutMs = deps.connectTimeoutMs ?? 200_000;
227
+ const routedModelStallTimeoutMs = deps.routedModelStallTimeoutMs ?? 200_000;
224
228
 
225
- // Run one model iteration: build the request, fetch it, parse to adapter events. RETURNS the
226
- // scanned split (generator return value, not a yield). Throws `LoopError` on a hard
227
- // provider/parse failure so the EAGER first call can turn it into a non-200 jsonError
228
- // (preserving the status contract), while later iterations — already inside the 200 SSE —
229
- // surface it as an in-stream error event. A generator so the 429 key-failover loop can YIELD a
230
- // heartbeat between bounded retry fetches: the iteration-wide AbortSignal.timeout bounds the
231
- // plain-fetch path, but adapters with their own fetchResponse timeout handling could otherwise
232
- // chain silent retries past the bridge stall deadline.
233
- const runIterationEvents = async function* (forceAnswer: boolean): AsyncGenerator<AdapterEvent, { calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean }> {
229
+ interface IterationResponse {
230
+ response: Response;
231
+ responseAdapter: ProviderAdapter;
232
+ }
233
+ type IterationSplit = ReturnType<typeof scanEventsForWebSearch>;
234
+
235
+ // Acquire one iteration's final response headers. The first call is drained eagerly so an initial
236
+ // connect/header/HTTP failure stays a non-2xx JSON response. Its successful BODY is deliberately
237
+ // left unread until the downstream Responses SSE bridge exists.
238
+ const prepareIterationEvents = async function* (forceAnswer: boolean): AsyncGenerator<AdapterEvent, IterationResponse> {
234
239
  // On the forced-answer pass the synthetic web_search tool is gone, so the model MUST answer
235
240
  // from the results already in `messages`. A weak model can still produce a thin answer that
236
241
  // ignores what the search found, which reads to the user as "the search did nothing". Nudge it
@@ -240,84 +245,128 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
240
245
  ? [...messages, forcedAnswerNudge()]
241
246
  : messages;
242
247
  const iterParsed: OcxParsedRequest = {
243
- ...parsed, stream: false,
248
+ ...parsed, stream: true,
244
249
  context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools },
245
250
  };
246
- // Per-iteration deadline: routed calls elsewhere carry connectTimeoutMs; without it a hung
247
- // upstream would stall the whole loop until the client gives up.
248
- const iterationTimeout = deps.connectTimeoutMs
249
- ? signalWithTimeout(deps.connectTimeoutMs, signal)
250
- : null;
251
- const iterationSignal = iterationTimeout?.signal ?? signal;
251
+ // One cumulative header deadline spans every pool-key 429 rotation in this model iteration.
252
+ // clear() stops only its timer after final headers; the direct turn signal remains attached to
253
+ // the returned response body through AbortSignal.any().
254
+ const headerDeadline = clearableDeadline(connectTimeoutMs, signal);
252
255
  try {
253
- const fetchOnce = async (): Promise<Response> => {
254
- const request = await adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
255
- try {
256
- return adapter.fetchResponse
257
- ? await adapter.fetchResponse(request, { abortSignal: iterationSignal, ...(deps.connectTimeoutMs ? { timeoutMs: deps.connectTimeoutMs } : {}) })
258
- : await fetchWithResetRetry(
259
- () => fetch(request.url, {
260
- method: request.method,
261
- headers: request.headers,
262
- body: request.body,
263
- signal: iterationSignal,
264
- }),
265
- { abortSignal: iterationSignal, label: "web-search-loop" },
266
- );
267
- } catch (e) {
268
- if (!signal.aborted && iterationSignal.aborted) {
269
- throw new LoopError(504, `Provider timeout after ${deps.connectTimeoutMs}ms during web-search`);
270
- }
271
- throw new LoopError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
272
- }
256
+ const fetchOnce = async (requestAdapter: ProviderAdapter): Promise<IterationResponse> => {
257
+ const request = await requestAdapter.buildRequest(iterParsed, {
258
+ headers: selectedForwardHeaders,
259
+ abortSignal: headerDeadline.signal,
260
+ });
261
+ const response = requestAdapter.fetchResponse
262
+ ? await requestAdapter.fetchResponse(request, {
263
+ abortSignal: headerDeadline.signal,
264
+ timeoutMs: connectTimeoutMs,
265
+ returnRawErrors: true,
266
+ })
267
+ : await fetchWithResetRetry(
268
+ () => fetch(request.url, {
269
+ method: request.method,
270
+ headers: request.headers,
271
+ body: request.body,
272
+ signal: headerDeadline.signal,
273
+ }),
274
+ { abortSignal: headerDeadline.signal, label: "web-search-loop" },
275
+ );
276
+ return { response, responseAdapter: requestAdapter };
273
277
  };
274
- let resp = await fetchOnce();
278
+
279
+ let prepared = await fetchOnce(adapter);
275
280
  // 429 key-failover parity with the normal routed path: rotate pool keys until one responds
276
281
  // or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
277
- while (resp.status === 429 && deps.on429) {
278
- const rotated = deps.on429(resp.headers.get("retry-after"));
279
- if (!rotated?.parseResponse) break;
280
- try { void resp.body?.cancel(); } catch { /* already consumed */ }
282
+ while (prepared.response.status === 429 && deps.on429) {
283
+ const rotated = deps.on429(prepared.response.headers.get("retry-after"));
284
+ if (!rotated) break;
285
+ // Never let a broken body's cancel promise outlive the cumulative header deadline. Observe
286
+ // it, but proceed immediately to the rotated fetch under the SAME deadline signal.
287
+ try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
281
288
  adapter = rotated;
282
289
  // Stall-watchdog seam between bounded retry fetches (audit 011 B3).
283
290
  yield { type: "heartbeat" };
284
- resp = await fetchOnce();
291
+ prepared = await fetchOnce(adapter);
285
292
  }
286
- if (!resp.ok) {
287
- const t = await resp.text().catch(() => "");
288
- throw new LoopError(resp.status, `Provider error ${resp.status}: ${t.slice(0, 400)}`);
293
+
294
+ // Final headers have arrived. Clear only the deadline timer before ANY body read.
295
+ headerDeadline.clear();
296
+ if (!prepared.response.ok) {
297
+ let body: Awaited<ReturnType<typeof readBoundedResponseBody>>;
298
+ try {
299
+ body = await readBoundedResponseBody(prepared.response, { signal });
300
+ } catch {
301
+ // The response status is authoritative even when its untrusted error body fails while
302
+ // being read (including a synchronous getReader() failure). Never route that failure
303
+ // through the adapter formatter or the generic transport error, which could expose its
304
+ // raw message. A parent/client cancellation still owns the request lifecycle as 499.
305
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
306
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`);
307
+ }
308
+ let formatted = "";
309
+ if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) {
310
+ try {
311
+ formatted = prepared.responseAdapter.formatErrorBody(
312
+ prepared.response.status,
313
+ prepared.response.headers,
314
+ body.text,
315
+ ).trim();
316
+ } catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ }
317
+ }
318
+ const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
319
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
289
320
  }
290
- // The fetch above carries `signal`; when the turn is superseded/cancelled, Bun aborts the
291
- // response body stream. If parseResponse hasn't attached a reader yet, the body's pending read is
292
- // orphaned off the awaited path and surfaces as `unhandledRejection: TypeError: null is not an
293
- // object` (native-only stack). Proactively cancel the body on abort so WE settle it, and guard
294
- // the drain so a mid-decode abort/stream error ends cleanly instead of throwing.
295
- const detachBodyGuard = cancelBodyOnAbort(resp.body, signal);
296
- let events: AdapterEvent[];
297
- try {
298
- events = await adapter.parseResponse!(resp);
299
- } catch (e) {
300
- await resp.body?.cancel().catch(() => {});
301
- if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
302
- throw new LoopError(502, `Provider stream error: ${e instanceof Error ? e.message : String(e)}`);
303
- } finally {
304
- detachBodyGuard();
321
+ return prepared;
322
+ } catch (error) {
323
+ if (headerDeadline.didExpire()) {
324
+ throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
305
325
  }
306
- return scanEventsForWebSearch(events);
326
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
327
+ if (error instanceof LoopError) throw error;
328
+ throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`);
307
329
  } finally {
308
- iterationTimeout?.cleanup();
330
+ headerDeadline.clear();
309
331
  }
310
332
  };
311
333
 
312
- // Drain an iteration OUTSIDE the bridge (eager first call): the stall deadline is not armed
313
- // before bridgeToResponsesSSE exists, so seam heartbeats have nowhere to go — discard them.
314
- const runIterationDrained = async (forceAnswer: boolean): Promise<{ calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean }> => {
315
- const it = runIterationEvents(forceAnswer);
334
+ const prepareIterationDrained = async (forceAnswer: boolean): Promise<IterationResponse> => {
335
+ const it = prepareIterationEvents(forceAnswer);
316
336
  let r = await it.next();
317
337
  while (!r.done) r = await it.next();
318
338
  return r.value;
319
339
  };
320
340
 
341
+ // Consume and validate one successful response body under a resettable raw-byte inactivity guard.
342
+ // Only invisible heartbeat events escape while semantic output remains buffered for safe scanning.
343
+ const consumeIterationEvents = async function* (prepared: IterationResponse): AsyncGenerator<AdapterEvent, IterationSplit> {
344
+ const events: AdapterEvent[] = [];
345
+ try {
346
+ const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
347
+ for await (const event of parseStreamWithProgress(prepared.response, parse, {
348
+ signal,
349
+ inactivityTimeoutMs: routedModelStallTimeoutMs,
350
+ })) {
351
+ if (event.type === "heartbeat") yield event;
352
+ else events.push(event);
353
+ }
354
+ } catch (error) {
355
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
356
+ if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
357
+ if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
358
+ throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
359
+ }
360
+
361
+ const terminalIndexes = events.flatMap((event, index) => event.type === "done" || event.type === "error" ? [index] : []);
362
+ if (terminalIndexes.length !== 1 || terminalIndexes[0] !== events.length - 1) {
363
+ throw new LoopError(502, `Web-search adapter stream protocol error: expected one final terminal event, received ${terminalIndexes.length}`);
364
+ }
365
+ const terminal = events[terminalIndexes[0]!];
366
+ if (terminal.type === "error") throw new LoopError(502, terminal.message);
367
+ return scanEventsForWebSearch(events);
368
+ };
369
+
321
370
  // Execute one model-requested web_search call. The call may batch several queries (native
322
371
  // `action.search.queries`); each query runs as its own sidecar search (budget-aware), but they are
323
372
  // paired as ONE assistant toolCall + ONE aggregated toolResult so function-call pairing stays
@@ -403,11 +452,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
403
452
  }
404
453
  }
405
454
 
406
- // Eagerly run the FIRST iteration so a hard provider failure becomes a non-200 jsonError before any
407
- // streaming starts (the status contract Codex relies on). Later iterations run live inside the SSE.
408
- let first: { calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean };
455
+ // Eagerly acquire only the FIRST iteration's final headers so connect/header/HTTP failures remain
456
+ // non-2xx JSON. A successful body is consumed inside the bridge, where byte progress can keep the
457
+ // downstream turn alive and body failures are correctly in-stream.
458
+ let firstPrepared: IterationResponse;
409
459
  try {
410
- first = await runIterationDrained(false);
460
+ firstPrepared = await prepareIterationDrained(false);
411
461
  } catch (e) {
412
462
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
413
463
  if (e instanceof LoopError) return jsonError(e.status, e.message);
@@ -427,44 +477,47 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
427
477
  // real sidecar timing, the final answer's passthrough events come last — matching native ordering
428
478
  // (search cell BEFORE the assistant message). Iteration 2+ failures surface as an in-stream error.
429
479
  async function* produce(): AsyncGenerator<AdapterEvent> {
430
- let split = first;
431
- for (let i = 0; i < HARD_CAP; i++) {
432
- const forceAnswer = searchesExecuted >= maxSearches;
433
- // First loop turn reuses the eager result; subsequent turns run a fresh iteration here.
434
- if (i > 0) {
480
+ let prepared = firstPrepared;
481
+ try {
482
+ for (let i = 0; i < HARD_CAP; i++) {
483
+ const forceAnswer = searchesExecuted >= maxSearches;
435
484
  try {
436
- // Stall-watchdog seam before each in-stream iteration (placeholder search turns emit no
437
- // cell, so consecutive non-streaming iterations would otherwise be one silent span).
438
- yield { type: "heartbeat" };
439
- // Delegate so seam heartbeats reach the bridge, the scanned split returns here, and an
440
- // outer consumer close propagates into the iteration generator's cleanup.
441
- split = yield* runIterationEvents(forceAnswer);
485
+ // First loop turn reuses the eager HEADERS. Subsequent header acquisitions run here.
486
+ if (i > 0) {
487
+ yield { type: "heartbeat" };
488
+ prepared = yield* prepareIterationEvents(forceAnswer);
489
+ }
490
+ // Raw-byte progress heartbeats reach the bridge; semantic events remain buffered.
491
+ const split = yield* consumeIterationEvents(prepared);
492
+
493
+ // Loop (search + re-ask) ONLY when the model's actionable output is purely web_search. A real
494
+ // tool call (e.g. shell/apply_patch) means this turn is terminal for Codex — finalize so those
495
+ // calls reach Codex. forceAnswer also finalizes.
496
+ const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
497
+ if (!shouldLoop) {
498
+ if (executedSearchCount > 0) {
499
+ const failedCount = failedQueries.size;
500
+ console.warn(
501
+ `[web-search-loop] done — ${executedSearchCount} search${executedSearchCount > 1 ? "es" : ""}`
502
+ + (failedCount > 0 ? ` (${failedCount} failed)` : "")
503
+ + `, ${i + 1} iteration${i > 0 ? "s" : ""}, ${Date.now() - loopT0}ms`,
504
+ );
505
+ }
506
+ yield* replay(split.passthrough);
507
+ return;
508
+ }
509
+ // The thinking that led to the search belongs to the FIRST call's assistant replay turn.
510
+ const iterationThinking = extractIterationThinking(split.passthrough);
511
+ for (const [callIndex, call] of split.calls.entries()) {
512
+ yield* runSearchCall(call, callIndex === 0 ? iterationThinking : null);
513
+ }
442
514
  } catch (e) {
443
515
  yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
444
516
  return;
445
517
  }
446
518
  }
447
- // Loop (search + re-ask) ONLY when the model's actionable output is purely web_search. A real
448
- // tool call (e.g. shell/apply_patch) means this turn is terminal for Codex — finalize so those
449
- // calls reach Codex. forceAnswer also finalizes.
450
- const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
451
- if (!shouldLoop) {
452
- if (executedSearchCount > 0) {
453
- const failedCount = failedQueries.size;
454
- console.warn(
455
- `[web-search-loop] done — ${executedSearchCount} search${executedSearchCount > 1 ? "es" : ""}`
456
- + (failedCount > 0 ? ` (${failedCount} failed)` : "")
457
- + `, ${i + 1} iteration${i > 0 ? "s" : ""}, ${Date.now() - loopT0}ms`,
458
- );
459
- }
460
- yield* replay(split.passthrough);
461
- return;
462
- }
463
- // The thinking that led to the search belongs to the FIRST call's assistant replay turn.
464
- const iterationThinking = extractIterationThinking(split.passthrough);
465
- for (const [callIndex, call] of split.calls.entries()) {
466
- yield* runSearchCall(call, callIndex === 0 ? iterationThinking : null);
467
- }
519
+ } finally {
520
+ if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
468
521
  }
469
522
  }
470
523