@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.
- package/gui/dist/assets/{index-PZN0Edav.js → index-Cs6p42GR.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/kiro-tools.ts +13 -4
- package/src/adapters/kiro-wire.ts +37 -0
- package/src/adapters/kiro.ts +18 -9
- package/src/bridge.ts +114 -3
- package/src/types.ts +18 -0
- package/src/web-search/executor.ts +2 -1
- package/src/web-search/format-result.ts +36 -0
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +202 -54
- package/src/web-search/parse.ts +54 -2
- package/src/web-search/synthetic-tool.ts +7 -2
package/src/web-search/loop.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
207
|
+
signal,
|
|
143
208
|
});
|
|
144
209
|
} catch (e) {
|
|
145
|
-
|
|
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
|
-
|
|
214
|
+
throw new LoopError(resp.status, `Provider error ${resp.status}: ${t.slice(0, 400)}`);
|
|
150
215
|
}
|
|
151
|
-
// The fetch above carries `
|
|
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,
|
|
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 (
|
|
163
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
+
executedSearchCount++;
|
|
266
|
+
if (outcome.error) failedQueries.add(normalizeQuery(query));
|
|
193
267
|
}
|
|
194
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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,
|
package/src/web-search/parse.ts
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
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: "
|
|
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
|
-
|
|
43
|
+
// Either `query` or `queries` is accepted; the proxy normalizes them.
|
|
39
44
|
},
|
|
40
45
|
webSearch: true,
|
|
41
46
|
};
|