@bitkyc08/opencodex 2.7.4 → 2.7.6
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.
- package/README.ko.md +6 -3
- package/README.md +6 -4
- package/README.zh-CN.md +4 -3
- package/gui/dist/assets/{index-66dPs6l_.js → index-mEjIne-M.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +30 -2
- package/src/adapters/base.ts +10 -0
- package/src/adapters/google-http.ts +37 -13
- package/src/adapters/google-tool-schema.ts +5 -0
- package/src/adapters/google.ts +3 -0
- package/src/adapters/kiro-retry.ts +33 -13
- package/src/adapters/kiro-tools.ts +4 -0
- package/src/adapters/kiro.ts +5 -1
- package/src/codex/catalog.ts +64 -6
- package/src/lib/abort.ts +40 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/providers/registry.ts +4 -1
- package/src/server/auth-cors.ts +16 -13
- package/src/server/images.ts +218 -0
- package/src/server/index.ts +29 -2
- package/src/server/responses.ts +2 -1
- package/src/types.ts +12 -0
- package/src/web-search/index.ts +44 -10
- package/src/web-search/loop.ts +165 -112
- package/src/web-search/progress-stream.ts +329 -0
package/src/web-search/index.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
52
|
+
routedModelStallTimeoutMs: number,
|
|
53
|
+
sidecarTimeoutMs: number = routedModelStallTimeoutMs,
|
|
31
54
|
): number {
|
|
32
|
-
|
|
33
|
-
configuredSec
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
120
|
+
routedModelStallTimeoutMs,
|
|
121
|
+
stallTimeoutSec: webSearchStallTimeoutSec(
|
|
122
|
+
config.stallTimeoutSec,
|
|
123
|
+
connectTimeoutMs,
|
|
124
|
+
routedModelStallTimeoutMs,
|
|
125
|
+
timeoutMs,
|
|
126
|
+
),
|
|
93
127
|
};
|
|
94
128
|
}
|
package/src/web-search/loop.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
176
|
-
*
|
|
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
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
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:
|
|
248
|
+
...parsed, stream: true,
|
|
244
249
|
context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools },
|
|
245
250
|
};
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
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<
|
|
254
|
-
const request = await
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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 (
|
|
278
|
-
const rotated = deps.on429(
|
|
279
|
-
if (!rotated
|
|
280
|
-
|
|
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
|
-
|
|
291
|
+
prepared = await fetchOnce(adapter);
|
|
285
292
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
+
headerDeadline.clear();
|
|
309
331
|
}
|
|
310
332
|
};
|
|
311
333
|
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
407
|
-
//
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|