@bitkyc08/opencodex 2.6.9 → 2.6.10

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.
@@ -2,9 +2,9 @@ import type { ProviderAdapter } from "../adapters/base";
2
2
  import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../types";
3
3
  import { namespacedToolName } from "../types";
4
4
  import { bridgeToResponsesSSE } from "../bridge";
5
- import { runWebSearch, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
5
+ import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
6
6
  import { cancelBodyOnAbort } from "../abort";
7
- import { formatWebSearchResult } from "./format-result";
7
+ import { formatWebSearchResults } from "./format-result";
8
8
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
9
9
 
10
10
  const SSE_HEADERS = {
@@ -16,7 +16,29 @@ const SSE_HEADERS = {
16
16
 
17
17
  interface WebSearchCall {
18
18
  id: string;
19
- query: string;
19
+ // One or more queries the model batched into a single web_search call. Always length >= 0; an
20
+ // empty array means the model called the tool with neither `query` nor `queries` (handled as an
21
+ // empty-query placeholder).
22
+ queries: string[];
23
+ }
24
+
25
+ /**
26
+ * Normalize a web_search tool call's raw JSON args into a canonical `queries[]`. Accepts native
27
+ * plural `queries: string[]` or singular `query: string` (the model may send either). Non-string /
28
+ * empty entries are dropped; malformed JSON yields `[]` (handled downstream as an empty-query call).
29
+ */
30
+ function parseQueries(argsBuf: string): string[] {
31
+ try {
32
+ const o: unknown = JSON.parse(argsBuf || "{}");
33
+ if (!o || typeof o !== "object") return [];
34
+ const obj = o as { query?: unknown; queries?: unknown };
35
+ if (Array.isArray(obj.queries)) {
36
+ const qs = obj.queries.filter((q): q is string => typeof q === "string" && q.trim() !== "");
37
+ if (qs.length > 0) return qs;
38
+ }
39
+ if (typeof obj.query === "string" && obj.query.trim() !== "") return [obj.query];
40
+ } catch { /* malformed args → empty */ }
41
+ return [];
20
42
  }
21
43
 
22
44
  /**
@@ -51,14 +73,7 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): {
51
73
  } else if (e.type === "tool_call_end" && pending) {
52
74
  pending.events.push(e);
53
75
  if (pending.name === WEB_SEARCH_TOOL_NAME) {
54
- let query = "";
55
- try {
56
- const o: unknown = JSON.parse(pending.argsBuf || "{}");
57
- if (o && typeof o === "object" && typeof (o as { query?: unknown }).query === "string") {
58
- query = (o as { query: string }).query;
59
- }
60
- } catch { /* malformed args → empty query */ }
61
- calls.push({ id: pending.id, query });
76
+ calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf) });
62
77
  } else {
63
78
  passthrough.push(...pending.events);
64
79
  hasRealToolCall = true;
@@ -81,6 +96,24 @@ function normalizeQuery(q: string): string {
81
96
  return q.trim().toLowerCase().replace(/\s+/g, " ");
82
97
  }
83
98
 
99
+ /**
100
+ * Transient developer-role nudge appended ONLY to the forced-answer pass's request (never the
101
+ * persisted `messages`). It tells the model to ground its final answer in the web results already
102
+ * gathered this turn. Citation wording is conditional — a failed/empty search still wants an answer,
103
+ * just without fabricated sources.
104
+ */
105
+ function forcedAnswerNudge(): OcxMessage {
106
+ return {
107
+ role: "developer",
108
+ content:
109
+ "Answer the user's question now using the web search results already gathered above. " +
110
+ "Ground your answer in what those results actually say, and reference the relevant sources " +
111
+ "when they are available. Do not claim you lack information that the results contain, and do " +
112
+ "not invent sources that were not returned.",
113
+ timestamp: Date.now(),
114
+ };
115
+ }
116
+
84
117
  function jsonError(status: number, message: string): Response {
85
118
  return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
86
119
  status,
@@ -88,6 +121,15 @@ function jsonError(status: number, message: string): Response {
88
121
  });
89
122
  }
90
123
 
124
+ /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a
125
+ * non-200 jsonError; later (already-streaming) iterations surface it as an in-stream error event. */
126
+ class LoopError extends Error {
127
+ constructor(readonly status: number, message: string) {
128
+ super(message);
129
+ this.name = "LoopError";
130
+ }
131
+ }
132
+
91
133
  export interface WebSearchLoopDeps {
92
134
  parsed: OcxParsedRequest;
93
135
  adapter: ProviderAdapter;
@@ -117,90 +159,165 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
117
159
  // results already in `messages` (can't search again) — this guarantees a non-empty final answer.
118
160
  const toolsNoWebSearch = allTools.filter(t => !t.webSearch);
119
161
  let searchesExecuted = 0;
120
- let finalEvents: AdapterEvent[] = [];
162
+ let executedSearchCount = 0;
121
163
  // Queries whose search already failed this turn — repeats are short-circuited so a model that keeps
122
164
  // re-asking the same failing query doesn't burn the whole search budget on it.
123
165
  const failedQueries = new Set<string>();
124
166
 
167
+ // Link an internal AbortController to the turn signal so a client cancel of the SSE body (bridge
168
+ // `onCancel`) aborts in-flight model fetches AND the sidecar — the work now runs INSIDE the stream,
169
+ // so without this a cancelled turn would leak fetches and keep draining tokens.
170
+ const internalAbort = new AbortController();
171
+ const linkAbort = (): void => internalAbort.abort(abortSignal?.reason);
172
+ if (abortSignal) {
173
+ if (abortSignal.aborted) linkAbort();
174
+ else abortSignal.addEventListener("abort", linkAbort, { once: true });
175
+ }
176
+ const signal = internalAbort.signal;
177
+
125
178
  // Hard iteration bound (termination safety net); forceAnswer normally ends the loop sooner.
126
179
  const HARD_CAP = maxSearches + 2;
127
- for (let i = 0; i < HARD_CAP; i++) {
128
- const forceAnswer = searchesExecuted >= maxSearches;
180
+
181
+ // Run one model iteration: build the request, fetch it, parse to adapter events. Returns the
182
+ // scanned split. Throws `LoopError` on a hard provider/parse failure so the EAGER first call can
183
+ // turn it into a non-200 jsonError (preserving the status contract), while later iterations —
184
+ // already inside the 200 SSE — surface it as an in-stream error event.
185
+ const runIteration = async (forceAnswer: boolean): Promise<{ calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean }> => {
186
+ // On the forced-answer pass the synthetic web_search tool is gone, so the model MUST answer
187
+ // from the results already in `messages`. A weak model can still produce a thin answer that
188
+ // ignores what the search found, which reads to the user as "the search did nothing". Nudge it
189
+ // (iteration-locally — never mutate the shared `messages`) to actually use the gathered results.
190
+ // Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders).
191
+ const iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
192
+ ? [...messages, forcedAnswerNudge()]
193
+ : messages;
129
194
  const iterParsed: OcxParsedRequest = {
130
195
  ...parsed, stream: false,
131
- context: { ...parsed.context, messages, tools: forceAnswer ? toolsNoWebSearch : allTools },
196
+ context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools },
132
197
  };
133
198
  const request = await adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
134
199
  let resp: Response;
135
200
  try {
136
201
  resp = adapter.fetchResponse
137
- ? await adapter.fetchResponse(request, { abortSignal })
202
+ ? await adapter.fetchResponse(request, { abortSignal: signal })
138
203
  : await fetch(request.url, {
139
204
  method: request.method,
140
205
  headers: request.headers,
141
206
  body: request.body,
142
- signal: abortSignal,
207
+ signal,
143
208
  });
144
209
  } catch (e) {
145
- return jsonError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
210
+ throw new LoopError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
146
211
  }
147
212
  if (!resp.ok) {
148
213
  const t = await resp.text().catch(() => "");
149
- return jsonError(resp.status, `Provider error ${resp.status}: ${t.slice(0, 400)}`);
214
+ throw new LoopError(resp.status, `Provider error ${resp.status}: ${t.slice(0, 400)}`);
150
215
  }
151
- // The fetch above carries `abortSignal`; when the turn is superseded/cancelled, Bun aborts the
216
+ // The fetch above carries `signal`; when the turn is superseded/cancelled, Bun aborts the
152
217
  // response body stream. If parseResponse hasn't attached a reader yet, the body's pending read is
153
218
  // orphaned off the awaited path and surfaces as `unhandledRejection: TypeError: null is not an
154
219
  // object` (native-only stack). Proactively cancel the body on abort so WE settle it, and guard
155
220
  // the drain so a mid-decode abort/stream error ends cleanly instead of throwing.
156
- const detachBodyGuard = cancelBodyOnAbort(resp.body, abortSignal);
221
+ const detachBodyGuard = cancelBodyOnAbort(resp.body, signal);
157
222
  let events: AdapterEvent[];
158
223
  try {
159
- events = await adapter.parseResponse(resp);
224
+ events = await adapter.parseResponse!(resp);
160
225
  } catch (e) {
161
226
  await resp.body?.cancel().catch(() => {});
162
- if (abortSignal?.aborted) {
163
- return jsonError(499, "client closed request during web-search");
164
- }
165
- return jsonError(502, `Provider stream error: ${e instanceof Error ? e.message : String(e)}`);
227
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
228
+ throw new LoopError(502, `Provider stream error: ${e instanceof Error ? e.message : String(e)}`);
166
229
  } finally {
167
230
  detachBodyGuard();
168
231
  }
169
- const { calls, passthrough, hasRealToolCall } = scanEventsForWebSearch(events);
170
- // Loop (search + re-ask) ONLY when the model's actionable output is purely web_search. A real
171
- // tool call (e.g. shell/apply_patch) means this turn is terminal for Codex — finalize so those
172
- // calls reach Codex instead of being discarded. forceAnswer also finalizes.
173
- const shouldLoop = calls.length > 0 && !hasRealToolCall && !forceAnswer;
174
- if (!shouldLoop) {
175
- finalEvents = passthrough;
176
- break;
232
+ return scanEventsForWebSearch(events);
233
+ };
234
+
235
+ // Execute one model-requested web_search call. The call may batch several queries (native
236
+ // `action.search.queries`); each query runs as its own sidecar search (budget-aware), but they are
237
+ // paired as ONE assistant toolCall + ONE aggregated toolResult so function-call pairing stays
238
+ // valid, and surface as ONE search cell carrying every attempted query. A real search (one that
239
+ // hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
240
+ // emit a cell (matching the prior single-query behavior).
241
+ async function* runSearchCall(call: WebSearchCall): AsyncGenerator<AdapterEvent> {
242
+ const results: { query: string; outcome: SidecarOutcome }[] = [];
243
+ let beganCell = false;
244
+ if (call.queries.length === 0) {
245
+ // The model called web_search with neither query nor queries — count it against the budget
246
+ // (loop-bounding) exactly as the old empty-query placeholder did, but emit no cell.
247
+ searchesExecuted++;
248
+ results.push({ query: "", outcome: { text: "", sources: [], error: "the model called web_search with an empty query" } });
177
249
  }
178
- const now = Date.now();
179
- for (const call of calls) {
180
- let outcome: { text: string; sources: { url: string; title?: string }[]; error?: string };
181
- if (call.query && failedQueries.has(normalizeQuery(call.query))) {
250
+ for (const query of call.queries) {
251
+ let outcome: SidecarOutcome;
252
+ if (failedQueries.has(normalizeQuery(query))) {
182
253
  // Already failed this turn — don't spend another real search on it.
183
254
  outcome = { text: "", sources: [], error: "this query already failed earlier in the turn — do not call web_search again for it; answer from existing context" };
184
255
  } else if (searchesExecuted >= maxSearches) {
185
256
  outcome = { text: "", sources: [], error: "web search limit reached for this turn — answer from results already gathered" };
186
- } else if (!call.query) {
187
- outcome = { text: "", sources: [], error: "the model called web_search with an empty query" };
188
- searchesExecuted++;
189
257
  } else {
190
- outcome = await runWebSearch(call.query, hostedTool, forwardProvider, selectedForwardHeaders, settings, abortSignal, recordSidecarOutcome);
258
+ // Real sidecar search. Open the cell once, before the first real query runs.
259
+ if (!beganCell) {
260
+ beganCell = true;
261
+ yield { type: "web_search_call_begin", id: call.id };
262
+ }
263
+ outcome = await runWebSearch(query, hostedTool, forwardProvider, selectedForwardHeaders, settings, signal, recordSidecarOutcome);
191
264
  searchesExecuted++;
192
- if (outcome.error) failedQueries.add(normalizeQuery(call.query));
265
+ executedSearchCount++;
266
+ if (outcome.error) failedQueries.add(normalizeQuery(query));
193
267
  }
194
- messages.push({
195
- role: "assistant",
196
- content: [{ type: "toolCall", id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: { query: call.query } }],
197
- timestamp: now,
198
- });
199
- messages.push({
200
- role: "toolResult", toolCallId: call.id, toolName: WEB_SEARCH_TOOL_NAME,
201
- content: formatWebSearchResult(call.query, outcome, !!parsed._structuredOutput), isError: !!outcome.error, timestamp: now,
202
- });
268
+ results.push({ query, outcome });
203
269
  }
270
+ const now = Date.now();
271
+ // Preserve the singular `{query}` arg shape for a single-query call (avoids prompt-history drift);
272
+ // use `{queries}` only when the model actually batched several.
273
+ const callArgs: Record<string, unknown> = call.queries.length > 1
274
+ ? { queries: call.queries }
275
+ : { query: call.queries[0] ?? "" };
276
+ messages.push({
277
+ role: "assistant",
278
+ content: [{ type: "toolCall", id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: callArgs }],
279
+ timestamp: now,
280
+ });
281
+ // One aggregated tool result. isError only when EVERY query failed (a partial success is usable).
282
+ const allFailed = results.every(r => !!r.outcome.error);
283
+ messages.push({
284
+ role: "toolResult", toolCallId: call.id, toolName: WEB_SEARCH_TOOL_NAME,
285
+ content: formatWebSearchResults(results, !!parsed._structuredOutput),
286
+ isError: allFailed, timestamp: now,
287
+ });
288
+ if (beganCell) {
289
+ // The cell is "completed" if any query produced a usable result, else "failed". `queries`
290
+ // carries every attempted query so Codex renders the native plural label.
291
+ const anySuccess = results.some(r => !r.outcome.error);
292
+ // Collect the citations backing this batch (dedup by URL), so the bridge can attach them as
293
+ // url_citation annotations on the following assistant message → the app's Sources chip.
294
+ const sources: { url: string; title?: string }[] = [];
295
+ const seenSrc = new Set<string>();
296
+ for (const r of results) {
297
+ for (const s of r.outcome.sources) {
298
+ if (seenSrc.has(s.url)) continue;
299
+ seenSrc.add(s.url);
300
+ sources.push(s.title ? { url: s.url, title: s.title } : { url: s.url });
301
+ }
302
+ }
303
+ yield {
304
+ type: "web_search_call_end", id: call.id,
305
+ queries: call.queries,
306
+ status: anySuccess ? "completed" : "failed",
307
+ ...(sources.length > 0 ? { sources } : {}),
308
+ };
309
+ }
310
+ }
311
+
312
+ // Eagerly run the FIRST iteration so a hard provider failure becomes a non-200 jsonError before any
313
+ // streaming starts (the status contract Codex relies on). Later iterations run live inside the SSE.
314
+ let first: { calls: WebSearchCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean };
315
+ try {
316
+ first = await runIteration(false);
317
+ } catch (e) {
318
+ if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
319
+ if (e instanceof LoopError) return jsonError(e.status, e.message);
320
+ throw e;
204
321
  }
205
322
 
206
323
  const toolNsMap = new Map<string, { namespace: string; name: string }>();
@@ -211,9 +328,40 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
211
328
  if (t.freeform) freeform.add(t.name);
212
329
  if (t.toolSearch) toolSearch.add(t.name);
213
330
  }
331
+
332
+ // Drive the remaining iterations live. Search cells (begin/end) are yielded interleaved with the
333
+ // real sidecar timing, the final answer's passthrough events come last — matching native ordering
334
+ // (search cell BEFORE the assistant message). Iteration 2+ failures surface as an in-stream error.
335
+ async function* produce(): AsyncGenerator<AdapterEvent> {
336
+ let split = first;
337
+ for (let i = 0; i < HARD_CAP; i++) {
338
+ const forceAnswer = searchesExecuted >= maxSearches;
339
+ // First loop turn reuses the eager result; subsequent turns run a fresh iteration here.
340
+ if (i > 0) {
341
+ try {
342
+ split = await runIteration(forceAnswer);
343
+ } catch (e) {
344
+ yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
345
+ return;
346
+ }
347
+ }
348
+ // Loop (search + re-ask) ONLY when the model's actionable output is purely web_search. A real
349
+ // tool call (e.g. shell/apply_patch) means this turn is terminal for Codex — finalize so those
350
+ // calls reach Codex. forceAnswer also finalizes.
351
+ const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
352
+ if (!shouldLoop) {
353
+ yield* replay(split.passthrough);
354
+ return;
355
+ }
356
+ for (const call of split.calls) {
357
+ yield* runSearchCall(call);
358
+ }
359
+ }
360
+ }
361
+
214
362
  const sse = bridgeToResponsesSSE(
215
- replay(finalEvents), parsed.modelId, toolNsMap, freeform, toolSearch,
216
- undefined, undefined,
363
+ produce(), parsed.modelId, toolNsMap, freeform, toolSearch,
364
+ () => internalAbort.abort("client closed responses stream"), undefined,
217
365
  {
218
366
  ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
219
367
  hideThinkingSummary: parsed.options.hideThinkingSummary,
@@ -34,6 +34,47 @@ function collectAnnotation(ann: AnnotationLike | undefined, sources: WebSearchSo
34
34
  sources.push({ url: ann.url, ...(ann.title ? { title: ann.title } : {}) });
35
35
  }
36
36
 
37
+ /**
38
+ * Hosted web_search (gpt-mini) rarely emits structured `url_citation` annotations; instead it ends
39
+ * its answer with a markdown `Sources:` section. Extract sources from that TRAILING section only
40
+ * (a whole-body URL scan would false-positive on URLs the model merely mentions), and return the
41
+ * answer text with that section stripped so the tool_result renderer doesn't double-print sources.
42
+ *
43
+ * Handles the per-line forms seen from the backend: `- title: url`, `- title (url)`,
44
+ * `- [title](url)`, `- <url>`, `- url`, and numbered `1. ...` variants.
45
+ */
46
+ const URL_RE = /https?:\/\/[^\s<>()\]]+/;
47
+ function extractTrailingSources(text: string): { text: string; sources: WebSearchSource[] } {
48
+ const lines = text.split("\n");
49
+ // Find the LAST line that is just a "Sources:" / "Source:" header (case-insensitive).
50
+ let headerIdx = -1;
51
+ for (let i = lines.length - 1; i >= 0; i--) {
52
+ if (/^\s*\**\s*sources?\s*:?\s*\**\s*$/i.test(lines[i])) { headerIdx = i; break; }
53
+ }
54
+ if (headerIdx === -1) return { text, sources: [] };
55
+ const sources: WebSearchSource[] = [];
56
+ const seen = new Set<string>();
57
+ for (let i = headerIdx + 1; i < lines.length; i++) {
58
+ const raw = lines[i].trim();
59
+ if (raw === "") continue;
60
+ // Only consume list-ish lines that carry a URL; stop at the first non-source line so we don't
61
+ // swallow trailing prose after the section.
62
+ const m = raw.match(URL_RE);
63
+ if (!m) break;
64
+ const url = m[0].replace(/[).,]+$/, "");
65
+ if (seen.has(url)) continue;
66
+ seen.add(url);
67
+ // Derive a title from the text before the URL: strip list markers, [md](), and separators.
68
+ let title = raw.slice(0, m.index).replace(/^[-*\d.)\s]+/, "").trim();
69
+ title = title.replace(/^\[/, "").replace(/\]\(?$/, "").replace(/[:\-—(]\s*$/, "").trim();
70
+ sources.push(title ? { url, title } : { url });
71
+ }
72
+ if (sources.length === 0) return { text, sources: [] };
73
+ // Strip the Sources section (header + consumed lines) from the answer text.
74
+ const stripped = lines.slice(0, headerIdx).join("\n").replace(/\s+$/, "");
75
+ return { text: stripped, sources };
76
+ }
77
+
37
78
  /** Pull final text + url_citation sources from a completed Responses `output[]` array. */
38
79
  function fromOutputArray(output: OutputItem[], seen: Set<string>): WebSearchResult {
39
80
  let text = "";
@@ -123,6 +164,17 @@ export async function parseSidecarSSE(response: Response): Promise<WebSearchResu
123
164
  for (const s of acc.streamSources) {
124
165
  if (!seenMerge.has(s.url)) { seenMerge.add(s.url); sources.push(s); }
125
166
  }
126
- if (!text.trim() && acc.error) return { text: "", sources, error: acc.error };
127
- return { text, sources };
167
+ // Hosted web_search usually omits url_citation annotations and lists sources in a trailing
168
+ // `Sources:` markdown block instead. Pull those out (and strip the block from the answer so the
169
+ // tool_result renderer doesn't print sources twice). Annotation titles win; text-block titles
170
+ // only fill a gap. URL-deduped against annotation sources.
171
+ const { text: body, sources: textSources } = extractTrailingSources(typeof text === "string" ? text : "");
172
+ for (const s of textSources) {
173
+ if (seenMerge.has(s.url)) continue;
174
+ seenMerge.add(s.url);
175
+ sources.push(s);
176
+ }
177
+ const finalText = textSources.length > 0 ? body : (typeof text === "string" ? text : "");
178
+ if (!finalText.trim() && acc.error) return { text: "", sources, error: acc.error };
179
+ return { text: finalText, sources };
128
180
  }
@@ -33,9 +33,14 @@ export function buildWebSearchTool(): OcxTool {
33
33
  parameters: {
34
34
  type: "object",
35
35
  properties: {
36
- query: { type: "string", description: "The search query — a focused natural-language question or keywords." },
36
+ query: { type: "string", description: "A single search query — a focused natural-language question or keywords." },
37
+ queries: {
38
+ type: "array",
39
+ items: { type: "string" },
40
+ description: "Optional: run several related queries together in one call. Use instead of `query` to batch independent searches.",
41
+ },
37
42
  },
38
- required: ["query"],
43
+ // Either `query` or `queries` is accepted; the proxy normalizes them.
39
44
  },
40
45
  webSearch: true,
41
46
  };