@bitkyc08/opencodex 2.7.0 → 2.7.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.
@@ -3,7 +3,7 @@ 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 } from "../lib/abort";
6
+ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
7
7
  import { fetchWithResetRetry } from "../lib/upstream-retry";
8
8
  import { formatWebSearchResults } from "./format-result";
9
9
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
@@ -170,6 +170,13 @@ export interface WebSearchLoopDeps {
170
170
  recordSidecarOutcome?: SidecarOutcomeRecorder;
171
171
  /** Per-iteration deadline for routed model calls (mirrors the normal path's connectTimeoutMs). */
172
172
  connectTimeoutMs?: number;
173
+ /**
174
+ * 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.
178
+ */
179
+ stallTimeoutSec?: number;
173
180
  /**
174
181
  * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter,
175
182
  * or null when the pool is exhausted (same semantics as the normal routed path).
@@ -190,6 +197,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
190
197
  if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
191
198
 
192
199
  const messages: OcxMessage[] = [...parsed.context.messages];
200
+ const loopT0 = Date.now();
193
201
  const allTools = parsed.context.tools ?? [];
194
202
  // For the forced-answer pass we drop the synthetic web_search tool so the model MUST answer from the
195
203
  // results already in `messages` (can't search again) — this guarantees a non-empty final answer.
@@ -214,11 +222,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
214
222
  // Hard iteration bound (termination safety net); forceAnswer normally ends the loop sooner.
215
223
  const HARD_CAP = maxSearches + 2;
216
224
 
217
- // Run one model iteration: build the request, fetch it, parse to adapter events. Returns the
218
- // scanned split. Throws `LoopError` on a hard provider/parse failure so the EAGER first call can
219
- // turn it into a non-200 jsonError (preserving the status contract), while later iterations —
220
- // already inside the 200 SSEsurface it as an in-stream error event.
221
- const runIteration = async (forceAnswer: boolean): Promise<{ calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean }> => {
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 }> {
222
234
  // On the forced-answer pass the synthetic web_search tool is gone, so the model MUST answer
223
235
  // from the results already in `messages`. A weak model can still produce a thin answer that
224
236
  // ignores what the search found, which reads to the user as "the search did nothing". Nudge it
@@ -233,61 +245,77 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
233
245
  };
234
246
  // Per-iteration deadline: routed calls elsewhere carry connectTimeoutMs; without it a hung
235
247
  // upstream would stall the whole loop until the client gives up.
236
- const iterationSignal = deps.connectTimeoutMs
237
- ? AbortSignal.any([signal, AbortSignal.timeout(deps.connectTimeoutMs)])
238
- : signal;
239
- const fetchOnce = async (): Promise<Response> => {
240
- const request = await adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
248
+ const iterationTimeout = deps.connectTimeoutMs
249
+ ? signalWithTimeout(deps.connectTimeoutMs, signal)
250
+ : null;
251
+ const iterationSignal = iterationTimeout?.signal ?? signal;
252
+ 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
+ }
273
+ };
274
+ let resp = await fetchOnce();
275
+ // 429 key-failover parity with the normal routed path: rotate pool keys until one responds
276
+ // 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 */ }
281
+ adapter = rotated;
282
+ // Stall-watchdog seam between bounded retry fetches (audit 011 B3).
283
+ yield { type: "heartbeat" };
284
+ resp = await fetchOnce();
285
+ }
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)}`);
289
+ }
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[];
241
297
  try {
242
- return adapter.fetchResponse
243
- ? await adapter.fetchResponse(request, { abortSignal: iterationSignal, ...(deps.connectTimeoutMs ? { timeoutMs: deps.connectTimeoutMs } : {}) })
244
- : await fetchWithResetRetry(
245
- () => fetch(request.url, {
246
- method: request.method,
247
- headers: request.headers,
248
- body: request.body,
249
- signal: iterationSignal,
250
- }),
251
- { abortSignal: iterationSignal, label: "web-search-loop" },
252
- );
298
+ events = await adapter.parseResponse!(resp);
253
299
  } catch (e) {
254
- if (!signal.aborted && iterationSignal.aborted) {
255
- throw new LoopError(504, `Provider timeout after ${deps.connectTimeoutMs}ms during web-search`);
256
- }
257
- throw new LoopError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(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();
258
305
  }
259
- };
260
- let resp = await fetchOnce();
261
- // 429 key-failover parity with the normal routed path: rotate pool keys until one responds
262
- // or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
263
- while (resp.status === 429 && deps.on429) {
264
- const rotated = deps.on429(resp.headers.get("retry-after"));
265
- if (!rotated?.parseResponse) break;
266
- try { void resp.body?.cancel(); } catch { /* already consumed */ }
267
- adapter = rotated;
268
- resp = await fetchOnce();
269
- }
270
- if (!resp.ok) {
271
- const t = await resp.text().catch(() => "");
272
- throw new LoopError(resp.status, `Provider error ${resp.status}: ${t.slice(0, 400)}`);
273
- }
274
- // The fetch above carries `signal`; when the turn is superseded/cancelled, Bun aborts the
275
- // response body stream. If parseResponse hasn't attached a reader yet, the body's pending read is
276
- // orphaned off the awaited path and surfaces as `unhandledRejection: TypeError: null is not an
277
- // object` (native-only stack). Proactively cancel the body on abort so WE settle it, and guard
278
- // the drain so a mid-decode abort/stream error ends cleanly instead of throwing.
279
- const detachBodyGuard = cancelBodyOnAbort(resp.body, signal);
280
- let events: AdapterEvent[];
281
- try {
282
- events = await adapter.parseResponse!(resp);
283
- } catch (e) {
284
- await resp.body?.cancel().catch(() => {});
285
- if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
286
- throw new LoopError(502, `Provider stream error: ${e instanceof Error ? e.message : String(e)}`);
306
+ return scanEventsForWebSearch(events);
287
307
  } finally {
288
- detachBodyGuard();
308
+ iterationTimeout?.cleanup();
289
309
  }
290
- return scanEventsForWebSearch(events);
310
+ };
311
+
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);
316
+ let r = await it.next();
317
+ while (!r.done) r = await it.next();
318
+ return r.value;
291
319
  };
292
320
 
293
321
  // Execute one model-requested web_search call. The call may batch several queries (native
@@ -306,6 +334,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
306
334
  results.push({ query: "", outcome: { text: "", sources: [], error: "the model called web_search with an empty query" } });
307
335
  }
308
336
  for (const query of call.queries) {
337
+ // Stall-watchdog seam: batched queries run sequentially inside ONE begin/end cell, and
338
+ // placeholder outcomes (repeat/limit) emit no cell at all — without this, consecutive
339
+ // bounded units chain into one silent span past the stall deadline (audit 011 B1).
340
+ yield { type: "heartbeat" };
309
341
  let outcome: SidecarOutcome;
310
342
  if (failedQueries.has(normalizeQuery(query))) {
311
343
  // Already failed this turn — don't spend another real search on it.
@@ -375,7 +407,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
375
407
  // streaming starts (the status contract Codex relies on). Later iterations run live inside the SSE.
376
408
  let first: { calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean };
377
409
  try {
378
- first = await runIteration(false);
410
+ first = await runIterationDrained(false);
379
411
  } catch (e) {
380
412
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
381
413
  if (e instanceof LoopError) return jsonError(e.status, e.message);
@@ -401,7 +433,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
401
433
  // First loop turn reuses the eager result; subsequent turns run a fresh iteration here.
402
434
  if (i > 0) {
403
435
  try {
404
- split = await runIteration(forceAnswer);
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);
405
442
  } catch (e) {
406
443
  yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
407
444
  return;
@@ -411,7 +448,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
411
448
  // tool call (e.g. shell/apply_patch) means this turn is terminal for Codex — finalize so those
412
449
  // calls reach Codex. forceAnswer also finalizes.
413
450
  const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
414
- if (!shouldLoop) {
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
+ }
415
460
  yield* replay(split.passthrough);
416
461
  return;
417
462
  }
@@ -424,11 +469,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
424
469
  }
425
470
 
426
471
  const sse = bridgeToResponsesSSE(
427
- produce(), parsed.modelId, toolNsMap, freeform, toolSearch,
428
- () => internalAbort.abort("client closed responses stream"), undefined,
472
+ produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
473
+ const elapsed = Date.now() - loopT0;
474
+ if (executedSearchCount > 0 || searchesExecuted > 0) {
475
+ console.warn(`[web-search-loop] cancelled — ${executedSearchCount} real searches, ${searchesExecuted - executedSearchCount} placeholders, ${elapsed}ms`);
476
+ }
477
+ internalAbort.abort("client closed responses stream");
478
+ }, undefined,
429
479
  {
430
480
  ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
431
481
  hideThinkingSummary: parsed.options.hideThinkingSummary,
482
+ ...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),
432
483
  },
433
484
  );
434
485
  return new Response(sse, { headers: SSE_HEADERS });
@@ -154,7 +154,10 @@ export async function parseSidecarSSE(response: Response): Promise<WebSearchResu
154
154
  const handle = (payload: string): void => {
155
155
  if (!payload || payload === "[DONE]") return;
156
156
  let data: Record<string, unknown>;
157
- try { data = JSON.parse(payload) as Record<string, unknown>; } catch { return; }
157
+ try { data = JSON.parse(payload) as Record<string, unknown>; } catch {
158
+ console.warn(`[web-search-parse] malformed SSE JSON (${payload.length} chars): ${payload.slice(0, 120)}`);
159
+ return;
160
+ }
158
161
  const type = data.type as string | undefined;
159
162
  if (type === "response.output_text.delta" && typeof data.delta === "string") {
160
163
  acc.deltaText += data.delta;