@oai404iao/pi-codex-web-search 0.1.0-alpha.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.
- package/LICENSE +28 -0
- package/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/OpenAI-Codex-NOTICE.txt +6 -0
- package/README.md +26 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/package.json +80 -0
- package/provenance/openai-codex-eb9dceba-reserved-tools.json +140 -0
- package/src/index.ts +23 -0
- package/src/tools/web-search/activity.ts +195 -0
- package/src/tools/web-search/capture.ts +44 -0
- package/src/tools/web-search/render.ts +48 -0
- package/src/tools/web-search/schema.ts +171 -0
- package/src/tools/web-search.ts +389 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { webSearchToolSchema, type WebSearchInput } from "./web-search/schema.js";
|
|
2
|
+
export { webSearchToolSchema } from "./web-search/schema.js";
|
|
3
|
+
export type { SearchQuery, WebSearchInput } from "./web-search/schema.js";
|
|
4
|
+
import { buildSessionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import {
|
|
8
|
+
buildCodexJsonHeaders,
|
|
9
|
+
hasCodexRequestAuth,
|
|
10
|
+
resolveCodexApiEndpoint,
|
|
11
|
+
} from "@oai404iao/pi-codex-runtime/internal/codex-http";
|
|
12
|
+
import { glyphs, truncateText } from "@oai404iao/pi-codex-runtime/internal/glyphs";
|
|
13
|
+
import { loadModelSettings } from "@oai404iao/pi-codex-runtime/internal/model-catalog/runtime";
|
|
14
|
+
import {
|
|
15
|
+
resolveCodexRequestIdentity,
|
|
16
|
+
type CodexRequestIdentity,
|
|
17
|
+
} from "@oai404iao/pi-codex-runtime/internal/codex-wire-identity";
|
|
18
|
+
|
|
19
|
+
interface WebSearchToolContext {
|
|
20
|
+
cwd: string;
|
|
21
|
+
model?: Model<Api>;
|
|
22
|
+
modelRegistry?: {
|
|
23
|
+
getApiKeyAndHeaders(model: Model<Api>): Promise<
|
|
24
|
+
| { ok: true; apiKey?: string; headers?: ProviderHeaders }
|
|
25
|
+
| { ok: false; error: string }
|
|
26
|
+
>;
|
|
27
|
+
};
|
|
28
|
+
sessionManager?: {
|
|
29
|
+
getSessionId(): string;
|
|
30
|
+
getBranch?(): SessionEntry[];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StandaloneSearchResponse {
|
|
35
|
+
encrypted_output?: string | null;
|
|
36
|
+
output?: string;
|
|
37
|
+
results?: unknown[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StandaloneWebSearchResult {
|
|
41
|
+
type?: string;
|
|
42
|
+
domain?: string;
|
|
43
|
+
ref_id?: string;
|
|
44
|
+
snippet?: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface StandaloneWebSearchDetails {
|
|
50
|
+
mode: "standalone";
|
|
51
|
+
results: StandaloneWebSearchResult[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StandaloneWebSearchInvocation {
|
|
55
|
+
turnId?: string;
|
|
56
|
+
identity?: CodexRequestIdentity;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CODEX_STANDALONE_SEARCH_OUTPUT_TOKEN_LIMIT = 10_000;
|
|
60
|
+
const SEARCH_OPERATION_KEYS = [
|
|
61
|
+
"search_query",
|
|
62
|
+
"image_query",
|
|
63
|
+
"open",
|
|
64
|
+
"click",
|
|
65
|
+
"find",
|
|
66
|
+
"screenshot",
|
|
67
|
+
"finance",
|
|
68
|
+
"weather",
|
|
69
|
+
"sports",
|
|
70
|
+
"time",
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
function visibleMessageText(content: unknown): string {
|
|
74
|
+
if (typeof content === "string") return content;
|
|
75
|
+
if (!Array.isArray(content)) return "";
|
|
76
|
+
return content
|
|
77
|
+
.filter((item): item is { type: "text"; text: string } =>
|
|
78
|
+
Boolean(item)
|
|
79
|
+
&& typeof item === "object"
|
|
80
|
+
&& (item as { type?: unknown }).type === "text"
|
|
81
|
+
&& typeof (item as { text?: unknown }).text === "string")
|
|
82
|
+
.map((item) => item.text)
|
|
83
|
+
.join("\n")
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recentSearchInput(ctx: WebSearchToolContext, turnId?: string): unknown[] | undefined {
|
|
88
|
+
if (!ctx.sessionManager?.getBranch) return undefined;
|
|
89
|
+
const visible: Array<{ role: "user" | "assistant"; text: string }> = [];
|
|
90
|
+
for (const message of buildSessionContext(ctx.sessionManager.getBranch()).messages) {
|
|
91
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
92
|
+
const text = visibleMessageText(message.content);
|
|
93
|
+
if (!text || (message.role === "user" && /^<environment_context>[\s\S]*<\/environment_context>$/i.test(text))) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
visible.push({ role: message.role, text });
|
|
97
|
+
}
|
|
98
|
+
const userIndexes = visible
|
|
99
|
+
.map((message, index) => message.role === "user" ? index : -1)
|
|
100
|
+
.filter((index) => index >= 0);
|
|
101
|
+
const start = userIndexes.length > 1 ? userIndexes[userIndexes.length - 2]! : userIndexes[0] ?? 0;
|
|
102
|
+
const tail = visible.slice(start);
|
|
103
|
+
let currentUserIndex = -1;
|
|
104
|
+
for (let index = 0; index < tail.length; index++) {
|
|
105
|
+
if (tail[index]?.role === "user") currentUserIndex = index;
|
|
106
|
+
}
|
|
107
|
+
let assistantBudget = 4_000;
|
|
108
|
+
return tail.map((message, index) => {
|
|
109
|
+
let text = message.text;
|
|
110
|
+
if (message.role === "assistant") {
|
|
111
|
+
text = text.slice(0, Math.max(0, assistantBudget));
|
|
112
|
+
assistantBudget -= text.length;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
type: "message",
|
|
116
|
+
role: message.role,
|
|
117
|
+
content: [{
|
|
118
|
+
type: message.role === "assistant" ? "output_text" : "input_text",
|
|
119
|
+
text,
|
|
120
|
+
}],
|
|
121
|
+
...(turnId && index === currentUserIndex
|
|
122
|
+
? { internal_chat_message_metadata_passthrough: { turn_id: turnId } }
|
|
123
|
+
: {}),
|
|
124
|
+
};
|
|
125
|
+
}).filter((message) => message.content[0]!.text.length > 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function searchOperationLabel(input: WebSearchInput): string {
|
|
129
|
+
const operations = SEARCH_OPERATION_KEYS.filter((key) => (input[key]?.length ?? 0) > 0);
|
|
130
|
+
return operations.length > 0 ? operations.join(", ") : "commands";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertStandaloneSearchOutput(output: string, input: WebSearchInput): void {
|
|
134
|
+
const normalized = output.trim();
|
|
135
|
+
if (/^Found no tool response\b[\s\S]*arguments you provided were not valid\.?$/i.test(normalized)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Standalone web search backend returned no tool response for ${searchOperationLabel(input)}. `
|
|
138
|
+
+ "The endpoint accepted the request but could not execute it; retry with search_query or another supported operation.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (/^Error parsing function call\b/i.test(normalized)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Standalone web search backend rejected ${searchOperationLabel(input)}: ${normalized}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function searchInputSummary(input: WebSearchInput): string {
|
|
149
|
+
const queries = [
|
|
150
|
+
...(input.search_query ?? []).map((query) => query.q),
|
|
151
|
+
...(input.image_query ?? []).map((query) => query.q),
|
|
152
|
+
].map((query) => query.trim()).filter(Boolean);
|
|
153
|
+
if (queries.length > 0) {
|
|
154
|
+
return queries.length > 1 ? `${queries[0]} +${queries.length - 1}` : queries[0]!;
|
|
155
|
+
}
|
|
156
|
+
const open = input.open?.[0]?.ref_id?.trim();
|
|
157
|
+
if (open) return open;
|
|
158
|
+
const find = input.find?.[0];
|
|
159
|
+
if (find?.pattern?.trim()) return find.pattern.trim();
|
|
160
|
+
const weather = input.weather?.[0]?.location?.trim();
|
|
161
|
+
if (weather) return weather;
|
|
162
|
+
const finance = input.finance?.[0]?.ticker?.trim();
|
|
163
|
+
if (finance) return finance;
|
|
164
|
+
const sports = input.sports?.[0];
|
|
165
|
+
if (sports) return [sports.league, sports.team, sports.fn].filter(Boolean).join(" ");
|
|
166
|
+
const time = input.time?.[0]?.utc_offset?.trim();
|
|
167
|
+
if (time) return time;
|
|
168
|
+
return searchOperationLabel(input);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resultHost(result: StandaloneWebSearchResult): string | undefined {
|
|
172
|
+
const domain = result.domain?.trim().replace(/^www\./i, "");
|
|
173
|
+
if (domain) return domain;
|
|
174
|
+
if (!result.url) return undefined;
|
|
175
|
+
try {
|
|
176
|
+
return new URL(result.url).hostname.replace(/^www\./i, "") || undefined;
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function standaloneWebSearchHosts(results: readonly StandaloneWebSearchResult[]): string[] {
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
const hosts: string[] = [];
|
|
185
|
+
for (const result of results) {
|
|
186
|
+
const host = resultHost(result);
|
|
187
|
+
const key = host?.toLowerCase();
|
|
188
|
+
if (!host || !key || seen.has(key)) continue;
|
|
189
|
+
seen.add(key);
|
|
190
|
+
hosts.push(host);
|
|
191
|
+
}
|
|
192
|
+
return hosts;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderHostTags(
|
|
196
|
+
results: readonly StandaloneWebSearchResult[],
|
|
197
|
+
theme: any,
|
|
198
|
+
cwd?: string,
|
|
199
|
+
): string {
|
|
200
|
+
const hosts = standaloneWebSearchHosts(results);
|
|
201
|
+
if (hosts.length === 0) return "";
|
|
202
|
+
const shown = hosts.slice(0, 8);
|
|
203
|
+
const separator = theme.fg("dim", glyphs(cwd).dot);
|
|
204
|
+
const tags = shown.map((host) => theme.fg("accent", host));
|
|
205
|
+
if (hosts.length > shown.length) tags.push(theme.fg("dim", `+${hosts.length - shown.length}`));
|
|
206
|
+
return tags.join(separator);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderStandaloneWebSearchCall(input: WebSearchInput, theme: any, cwd?: string): Text {
|
|
210
|
+
const summary = truncateText(searchInputSummary(input), 96, cwd);
|
|
211
|
+
const text = `${theme.fg("accent", glyphs(cwd).bullet)}`
|
|
212
|
+
+ theme.fg("text", theme.bold("Web Search"))
|
|
213
|
+
+ (summary ? theme.fg("dim", ` ${summary}`) : "");
|
|
214
|
+
return new Text(text, 0, 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderStandaloneWebSearchResult(
|
|
218
|
+
result: { content?: Array<{ type?: string; text?: string }>; details?: StandaloneWebSearchDetails },
|
|
219
|
+
options: { expanded?: boolean; isPartial?: boolean },
|
|
220
|
+
theme: any,
|
|
221
|
+
context: { cwd?: string; isError?: boolean },
|
|
222
|
+
): Text {
|
|
223
|
+
if (options.isPartial) return new Text("", 0, 0);
|
|
224
|
+
const text = result.content
|
|
225
|
+
?.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
226
|
+
.map((part) => part.text)
|
|
227
|
+
.join("\n") ?? "";
|
|
228
|
+
if (context.isError) return new Text(theme.fg("error", text || "Web search failed"), 0, 0);
|
|
229
|
+
|
|
230
|
+
const results = result.details?.mode === "standalone" ? result.details.results : [];
|
|
231
|
+
const hosts = renderHostTags(results, theme, context.cwd);
|
|
232
|
+
const count = results.length;
|
|
233
|
+
let rendered = count > 0 ? `${hosts ? `${hosts} ` : ""}${theme.fg("dim", `(${count})`)}` : theme.fg("muted", "Search complete");
|
|
234
|
+
if (options.expanded && text) rendered += `\n\n${theme.fg("toolOutput", text)}`;
|
|
235
|
+
return new Text(rendered, 0, 0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function standaloneWebSearch(
|
|
239
|
+
input: WebSearchInput,
|
|
240
|
+
ctx: WebSearchToolContext,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
invocation: StandaloneWebSearchInvocation = {},
|
|
243
|
+
) {
|
|
244
|
+
const model = ctx.model;
|
|
245
|
+
if (!model || !ctx.modelRegistry) throw new Error("No active model is available for standalone web search.");
|
|
246
|
+
const settings = loadModelSettings(model, ctx.cwd);
|
|
247
|
+
if (!settings.enabled) throw new Error("pi-codex-minimal-tools is disabled.");
|
|
248
|
+
if (settings.webSearchImplementation !== "standalone") {
|
|
249
|
+
throw new Error(`Standalone web search is not enabled for ${model.provider}/${model.id}.`);
|
|
250
|
+
}
|
|
251
|
+
const contentTypes = settings.modelProfile?.effective.tools.webSearch
|
|
252
|
+
? settings.modelProfile.effective.tools.webSearch.contentTypes ?? ["text"]
|
|
253
|
+
: [];
|
|
254
|
+
if (input.search_query?.length && !contentTypes.includes("text")) {
|
|
255
|
+
throw new Error("Text search is disabled by the current model profile.");
|
|
256
|
+
}
|
|
257
|
+
if (input.image_query?.length && !contentTypes.includes("image")) {
|
|
258
|
+
throw new Error("Image search is disabled by the current model profile.");
|
|
259
|
+
}
|
|
260
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
261
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
262
|
+
if (!hasCodexRequestAuth({
|
|
263
|
+
modelHeaders: model.headers,
|
|
264
|
+
auth: { apiKey: auth.apiKey, headers: auth.headers },
|
|
265
|
+
})) {
|
|
266
|
+
throw new Error(`No request authentication for provider: ${model.provider}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const url = resolveCodexApiEndpoint(model.baseUrl, settings.apiKeyMode, "alpha/search");
|
|
270
|
+
const piSessionId = ctx.sessionManager?.getSessionId();
|
|
271
|
+
const identity = invocation.identity
|
|
272
|
+
?? resolveCodexRequestIdentity(
|
|
273
|
+
piSessionId,
|
|
274
|
+
invocation.turnId ? { turn_id: invocation.turnId } : undefined,
|
|
275
|
+
"turn",
|
|
276
|
+
);
|
|
277
|
+
const turnId = identity?.turnId || invocation.turnId;
|
|
278
|
+
const searchInput = recentSearchInput(ctx, turnId);
|
|
279
|
+
const turnMetadata = identity && turnId
|
|
280
|
+
? JSON.stringify({
|
|
281
|
+
session_id: identity.sessionId,
|
|
282
|
+
thread_id: identity.threadId,
|
|
283
|
+
turn_id: turnId,
|
|
284
|
+
...(identity.forkedFromThreadId
|
|
285
|
+
? {
|
|
286
|
+
forked_from_thread_id:
|
|
287
|
+
identity.forkedFromThreadId,
|
|
288
|
+
}
|
|
289
|
+
: {}),
|
|
290
|
+
...(identity.parentThreadId
|
|
291
|
+
? { parent_thread_id: identity.parentThreadId }
|
|
292
|
+
: {}),
|
|
293
|
+
model: model.id,
|
|
294
|
+
})
|
|
295
|
+
: undefined;
|
|
296
|
+
const response = await fetch(url, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: buildCodexJsonHeaders({
|
|
299
|
+
modelHeaders: model.headers,
|
|
300
|
+
auth: { apiKey: auth.apiKey, headers: auth.headers },
|
|
301
|
+
apiKeyMode: settings.apiKeyMode,
|
|
302
|
+
...(turnMetadata
|
|
303
|
+
? { extraHeaders: { "x-codex-turn-metadata": turnMetadata } }
|
|
304
|
+
: {}),
|
|
305
|
+
}),
|
|
306
|
+
body: JSON.stringify({
|
|
307
|
+
id: identity?.sessionId
|
|
308
|
+
?? piSessionId
|
|
309
|
+
?? `pi-search-${Date.now()}`,
|
|
310
|
+
model: model.id,
|
|
311
|
+
...(searchInput ? { input: searchInput } : {}),
|
|
312
|
+
commands: input,
|
|
313
|
+
settings: {
|
|
314
|
+
allowed_callers: ["direct"],
|
|
315
|
+
external_web_access: true,
|
|
316
|
+
},
|
|
317
|
+
max_output_tokens: CODEX_STANDALONE_SEARCH_OUTPUT_TOKEN_LIMIT,
|
|
318
|
+
}),
|
|
319
|
+
signal,
|
|
320
|
+
});
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
throw new Error(`Standalone web search failed: HTTP ${response.status}: ${await response.text()}`);
|
|
323
|
+
}
|
|
324
|
+
const result = await response.json() as StandaloneSearchResponse;
|
|
325
|
+
if (typeof result.output !== "string" || !result.output.trim()) {
|
|
326
|
+
throw new Error("Standalone web search returned no output.");
|
|
327
|
+
}
|
|
328
|
+
assertStandaloneSearchOutput(result.output, input);
|
|
329
|
+
return {
|
|
330
|
+
content: [{ type: "text", text: result.output }],
|
|
331
|
+
details: {
|
|
332
|
+
mode: "standalone",
|
|
333
|
+
results: (result.results ?? []) as StandaloneWebSearchResult[],
|
|
334
|
+
} satisfies StandaloneWebSearchDetails,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function createWebSearchToolDefinition(options: {
|
|
339
|
+
hasProviderRuntime?: () => boolean;
|
|
340
|
+
getCurrentTurnId?: (sessionId: string | undefined) => string | undefined;
|
|
341
|
+
getRequestIdentity?: (
|
|
342
|
+
sessionId: string | undefined,
|
|
343
|
+
) => CodexRequestIdentity | undefined;
|
|
344
|
+
} = {}) {
|
|
345
|
+
return {
|
|
346
|
+
name: "web_search",
|
|
347
|
+
label: "Web Search",
|
|
348
|
+
description: "Search the web using the implementation selected by the current model profile. Hosted profiles are rewritten into the OpenAI Responses web_search tool; standalone profiles call the Codex alpha/search endpoint.",
|
|
349
|
+
promptSnippet: "Search the web when current information or citations are needed.",
|
|
350
|
+
promptGuidelines: ["Use web_search when current web information or cited sources are needed."],
|
|
351
|
+
parameters: webSearchToolSchema,
|
|
352
|
+
renderCall(input: WebSearchInput, theme: any, context: { cwd?: string }) {
|
|
353
|
+
return renderStandaloneWebSearchCall(input ?? {}, theme, context?.cwd);
|
|
354
|
+
},
|
|
355
|
+
renderResult(
|
|
356
|
+
result: { content?: Array<{ type?: string; text?: string }>; details?: StandaloneWebSearchDetails },
|
|
357
|
+
renderOptions: { expanded?: boolean; isPartial?: boolean },
|
|
358
|
+
theme: any,
|
|
359
|
+
context: { cwd?: string; isError?: boolean },
|
|
360
|
+
) {
|
|
361
|
+
return renderStandaloneWebSearchResult(result, renderOptions, theme, context);
|
|
362
|
+
},
|
|
363
|
+
async execute(
|
|
364
|
+
_toolCallId: string,
|
|
365
|
+
input: WebSearchInput,
|
|
366
|
+
signal: AbortSignal | undefined,
|
|
367
|
+
_onUpdate: unknown,
|
|
368
|
+
ctx: WebSearchToolContext,
|
|
369
|
+
) {
|
|
370
|
+
const settings = loadModelSettings(ctx.model, ctx.cwd);
|
|
371
|
+
if (settings.webSearchImplementation === "standalone") {
|
|
372
|
+
const sessionId = ctx.sessionManager?.getSessionId();
|
|
373
|
+
const identity = options.getRequestIdentity?.(sessionId);
|
|
374
|
+
return standaloneWebSearch(input, ctx, signal, {
|
|
375
|
+
turnId: identity?.turnId
|
|
376
|
+
?? options.getCurrentTurnId?.(sessionId),
|
|
377
|
+
identity,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (options.hasProviderRuntime?.() === false) {
|
|
381
|
+
throw new Error("Hosted web_search requires pi-codex-core. Use a catalog-supported standalone profile for independent execution.");
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
content: [{ type: "text", text: "web_search is hosted-provider-first for this model profile and should be rewritten before execution." }],
|
|
385
|
+
details: { phase: "native-provider", nativeTool: "web_search" },
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|