@mono-agent/agent-runtime 0.20.2 → 0.20.4
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.md +4 -1
- package/package.json +1 -1
- package/src/agent/tools/codex-subscription-search.js +435 -0
- package/src/agent/tools/index.js +5 -0
- package/src/agent/tools/pi-bridge.js +12 -8
- package/src/agent/tools/shared/process-jobs.js +11 -1
- package/src/agent/tools/web-controller.js +3 -1
- package/src/agent/tools/web-search.js +175 -41
- package/src/ai/failure.js +16 -3
- package/src/ai/types.js +1 -1
- package/types/agent/tools/codex-subscription-search.d.ts +31 -0
- package/types/agent/tools/index.d.ts +1 -0
- package/types/agent/tools/web-controller.d.ts +3 -2
- package/types/agent/tools/web-search.d.ts +13 -4
- package/types/ai/types.d.ts +5 -2
package/README.md
CHANGED
|
@@ -331,6 +331,7 @@ inferSkillsRoot
|
|
|
331
331
|
**`@mono-agent/agent-runtime/agent/tools/index.js`**
|
|
332
332
|
|
|
333
333
|
```text
|
|
334
|
+
DEFAULT_CODEX_SEARCH_MODEL
|
|
334
335
|
bashToolImpl
|
|
335
336
|
bashToolRun
|
|
336
337
|
createWebToolController
|
|
@@ -339,6 +340,7 @@ execToolImpl
|
|
|
339
340
|
execToolRun
|
|
340
341
|
globToolImpl
|
|
341
342
|
grepToolImpl
|
|
343
|
+
inspectCodexSubscriptionSearch
|
|
342
344
|
isPathAllowed
|
|
343
345
|
isWorkdirAllowed
|
|
344
346
|
normalizeBashTimeoutMs
|
|
@@ -347,6 +349,7 @@ performWebFetch
|
|
|
347
349
|
performWebSearch
|
|
348
350
|
readToolImpl
|
|
349
351
|
resolveRgPath
|
|
352
|
+
searchCodexSubscription
|
|
350
353
|
webFetchToolImpl
|
|
351
354
|
webSearchToolImpl
|
|
352
355
|
writeToolImpl
|
|
@@ -824,7 +827,7 @@ Per-call options (a non-exhaustive selection):
|
|
|
824
827
|
| `codexSandboxNetworkAccess` | `boolean` | Code-only Codex app-server per-turn network control. Only strict `true` enables it for plan/default/acceptEdits; omitted or any other value disables it. |
|
|
825
828
|
| `mcpServers` | `Record<string, McpServerConfig>` | Configured MCP servers (stdio / sse / http); on direct Codex, each forwarded server authorizes its own tool calls. |
|
|
826
829
|
| `sandboxPolicy` | `SandboxPolicy` | Optional fail-closed sandbox policy for built-in tools and stdio MCP process startup. |
|
|
827
|
-
| `webSearchConfig` | `{ backend?, endpoint? }` | Run-scoped local SearXNG
|
|
830
|
+
| `webSearchConfig` | `{ backend?, endpoint?, codex?: { model? } }` | Run-scoped local SearXNG, ChatGPT-subscription Codex, and keyless WebSearch backend selection. |
|
|
828
831
|
| `webFetchConfig` | `{ render?, browserCommand? }` | Run-scoped static extraction and optional isolated browser-render policy. |
|
|
829
832
|
| `piToolExecutionMode` | `"safe-parallel" \| "sequential"` | Pi built-in scheduling. Safe parallelism is the default; stateful/mutating and MCP tools stay sequential. |
|
|
830
833
|
| `maxTurns` | `number` | Hard cap on agent turns. |
|
package/package.json
CHANGED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { createCodexAppServerClient } from "../../ai/providers/codex-app.js";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CODEX_SEARCH_MODEL = "gpt-5.6-luna";
|
|
10
|
+
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
12
|
+
const IDLE_CLOSE_MS = 1_000;
|
|
13
|
+
const MAX_MODEL_PAGES = 10;
|
|
14
|
+
const MAX_RESULTS = 100;
|
|
15
|
+
const SEARCH_ONLY_INSTRUCTIONS = [
|
|
16
|
+
"You are a search transport, not a general assistant.",
|
|
17
|
+
"Run exactly one live web search for the user's query, preserving quoted phrases and site: operators exactly.",
|
|
18
|
+
"Do not use shell, filesystem, MCP, apps, subagents, image, or any other tool.",
|
|
19
|
+
"Do not ask questions. The host consumes only the structured webSearch results and ignores your prose.",
|
|
20
|
+
].join(" ");
|
|
21
|
+
|
|
22
|
+
/** @type {{client: any, directory: string, models: Set<string>} | null} */
|
|
23
|
+
let broker = null;
|
|
24
|
+
/** @type {Promise<any> | null} */
|
|
25
|
+
let brokerOpening = null;
|
|
26
|
+
/** @type {Promise<void>} */
|
|
27
|
+
let serial = Promise.resolve();
|
|
28
|
+
/** @type {NodeJS.Timeout | null} */
|
|
29
|
+
let idleTimer = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Inspect the installed Codex app-server without reading or exporting tokens.
|
|
33
|
+
*
|
|
34
|
+
* @param {{model?: string, clientFactory?: typeof createCodexAppServerClient}} [options]
|
|
35
|
+
*/
|
|
36
|
+
export async function inspectCodexSubscriptionSearch(options = {}) {
|
|
37
|
+
const model = normalizeModel(options.model);
|
|
38
|
+
let owned;
|
|
39
|
+
try {
|
|
40
|
+
owned = await openBroker(options.clientFactory);
|
|
41
|
+
const ready = await inspectClient(owned.client, model);
|
|
42
|
+
const { models: _models, ...publicReadiness } = ready;
|
|
43
|
+
return publicReadiness;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
code: "codex_unavailable",
|
|
48
|
+
reason: publicReason(error),
|
|
49
|
+
model,
|
|
50
|
+
};
|
|
51
|
+
} finally {
|
|
52
|
+
if (owned) await closeOwnedBroker(owned);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Execute one subscription-backed live search through Codex app-server.
|
|
58
|
+
* Calls are serialized process-wide so one agent cannot fan out subscription
|
|
59
|
+
* turns or cross-wire app-server notifications between requests.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} query
|
|
62
|
+
* @param {{model?: string, signal?: AbortSignal, clientFactory?: typeof createCodexAppServerClient}} [options]
|
|
63
|
+
*/
|
|
64
|
+
export function searchCodexSubscription(query, options = {}) {
|
|
65
|
+
return enqueue(async () => {
|
|
66
|
+
if (options.signal?.aborted) return abortedResult();
|
|
67
|
+
const model = normalizeModel(options.model);
|
|
68
|
+
let current;
|
|
69
|
+
try {
|
|
70
|
+
current = await getBroker(model, options.clientFactory);
|
|
71
|
+
const result = await runSearch(current, query, model, options.signal);
|
|
72
|
+
scheduleIdleClose();
|
|
73
|
+
return result;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
await closeBroker();
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
backend: "codex",
|
|
79
|
+
message: `Codex subscription search unavailable: ${publicReason(error)}`,
|
|
80
|
+
retryable: isRetryable(error),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function enqueue(task) {
|
|
87
|
+
const result = serial.then(task, task);
|
|
88
|
+
serial = result.then(() => {}, () => {});
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function getBroker(model, clientFactory) {
|
|
93
|
+
clearIdleTimer();
|
|
94
|
+
if (broker?.models.has(model)) return broker;
|
|
95
|
+
if (broker && !broker.models.has(model)) await closeBroker();
|
|
96
|
+
if (!brokerOpening) {
|
|
97
|
+
brokerOpening = (async () => {
|
|
98
|
+
const owned = await openBroker(clientFactory);
|
|
99
|
+
const ready = await inspectClient(owned.client, model);
|
|
100
|
+
if (!ready.ok) {
|
|
101
|
+
await closeOwnedBroker(owned);
|
|
102
|
+
throw new Error(ready.reason);
|
|
103
|
+
}
|
|
104
|
+
owned.models = ready.models;
|
|
105
|
+
broker = owned;
|
|
106
|
+
return owned;
|
|
107
|
+
})().finally(() => { brokerOpening = null; });
|
|
108
|
+
}
|
|
109
|
+
return await brokerOpening;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function openBroker(clientFactory = createCodexAppServerClient) {
|
|
113
|
+
const directory = await mkdtemp(join(tmpdir(), "mono-agent-codex-search-"));
|
|
114
|
+
/** @type {{handler: (message: any) => void}} */
|
|
115
|
+
const target = { handler: () => {} };
|
|
116
|
+
let client;
|
|
117
|
+
try {
|
|
118
|
+
client = clientFactory({
|
|
119
|
+
cwd: directory,
|
|
120
|
+
onNotification: (message) => target.handler(message),
|
|
121
|
+
onServerRequest: (message) => {
|
|
122
|
+
target.handler(message);
|
|
123
|
+
throw new Error("Codex subscription search rejected an unexpected server request.");
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
await client.request("initialize", {
|
|
127
|
+
clientInfo: { name: "mono-agent-web-search", title: "mono-agent WebSearch", version: "0" },
|
|
128
|
+
capabilities: { experimentalApi: true },
|
|
129
|
+
}, { timeoutMs: REQUEST_TIMEOUT_MS });
|
|
130
|
+
return { client, directory, models: new Set(), target };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await Promise.resolve(client?.close?.()).catch(() => {});
|
|
133
|
+
await rm(directory, { recursive: true, force: true }).catch(() => {});
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function inspectClient(client, model) {
|
|
139
|
+
const account = await client.request("account/read", { refreshToken: false }, { timeoutMs: REQUEST_TIMEOUT_MS });
|
|
140
|
+
if (account?.account?.type !== "chatgpt") {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
code: "codex_chatgpt_login_required",
|
|
144
|
+
reason: "Codex must be signed in with ChatGPT subscription access.",
|
|
145
|
+
model,
|
|
146
|
+
models: new Set(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const capabilities = await client.request(
|
|
150
|
+
"modelProvider/capabilities/read",
|
|
151
|
+
{},
|
|
152
|
+
{ timeoutMs: REQUEST_TIMEOUT_MS },
|
|
153
|
+
);
|
|
154
|
+
if (capabilities?.webSearch !== true) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
code: "codex_web_search_unavailable",
|
|
158
|
+
reason: "The signed-in Codex account does not expose web search.",
|
|
159
|
+
model,
|
|
160
|
+
models: new Set(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const models = await readModels(client);
|
|
164
|
+
if (!models.has(model)) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
code: "codex_model_unavailable",
|
|
168
|
+
reason: `Codex model ${model} is not available to the signed-in account.`,
|
|
169
|
+
model,
|
|
170
|
+
models,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
code: "ok",
|
|
176
|
+
reason: "",
|
|
177
|
+
model,
|
|
178
|
+
models,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function readModels(client) {
|
|
183
|
+
const models = new Set();
|
|
184
|
+
let cursor = null;
|
|
185
|
+
for (let page = 0; page < MAX_MODEL_PAGES; page += 1) {
|
|
186
|
+
const response = await client.request("model/list", {
|
|
187
|
+
includeHidden: false,
|
|
188
|
+
limit: 100,
|
|
189
|
+
...(cursor === null ? {} : { cursor }),
|
|
190
|
+
}, { timeoutMs: REQUEST_TIMEOUT_MS });
|
|
191
|
+
if (!Array.isArray(response?.data)) throw new Error("Codex returned an invalid model catalog.");
|
|
192
|
+
for (const row of response.data) {
|
|
193
|
+
if (typeof row?.id === "string" && row.id.trim()) models.add(row.id.trim());
|
|
194
|
+
}
|
|
195
|
+
cursor = typeof response?.nextCursor === "string" && response.nextCursor ? response.nextCursor : null;
|
|
196
|
+
if (cursor === null) return models;
|
|
197
|
+
}
|
|
198
|
+
throw new Error("Codex model catalog exceeded the pagination bound.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runSearch(current, query, model, signal) {
|
|
202
|
+
const state = /** @type {any} */ ({
|
|
203
|
+
threadId: "",
|
|
204
|
+
turnId: "",
|
|
205
|
+
completed: false,
|
|
206
|
+
violation: "",
|
|
207
|
+
webSearchItems: [],
|
|
208
|
+
resolve: () => {},
|
|
209
|
+
});
|
|
210
|
+
const completion = new Promise((resolve) => { state.resolve = resolve; });
|
|
211
|
+
current.target.handler = (message) => handleNotification(message, state, current.client);
|
|
212
|
+
const onAbort = () => {
|
|
213
|
+
if (state.threadId && state.turnId) {
|
|
214
|
+
void current.client.request("turn/interrupt", {
|
|
215
|
+
threadId: state.threadId,
|
|
216
|
+
turnId: state.turnId,
|
|
217
|
+
}).catch(() => {});
|
|
218
|
+
}
|
|
219
|
+
state.violation ||= "WebSearch was aborted.";
|
|
220
|
+
state.resolve();
|
|
221
|
+
};
|
|
222
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
223
|
+
try {
|
|
224
|
+
const thread = await current.client.request("thread/start", {
|
|
225
|
+
model,
|
|
226
|
+
modelProvider: "openai",
|
|
227
|
+
allowProviderModelFallback: false,
|
|
228
|
+
cwd: current.directory,
|
|
229
|
+
runtimeWorkspaceRoots: [current.directory],
|
|
230
|
+
approvalPolicy: "untrusted",
|
|
231
|
+
sandbox: "read-only",
|
|
232
|
+
config: {
|
|
233
|
+
web_search: "live",
|
|
234
|
+
project_doc_max_bytes: 0,
|
|
235
|
+
mcp_servers: {},
|
|
236
|
+
},
|
|
237
|
+
developerInstructions: SEARCH_ONLY_INSTRUCTIONS,
|
|
238
|
+
ephemeral: true,
|
|
239
|
+
sessionStartSource: "startup",
|
|
240
|
+
environments: [],
|
|
241
|
+
dynamicTools: [],
|
|
242
|
+
selectedCapabilityRoots: [],
|
|
243
|
+
experimentalRawEvents: false,
|
|
244
|
+
}, { timeoutMs: REQUEST_TIMEOUT_MS });
|
|
245
|
+
state.threadId = thread?.thread?.id || "";
|
|
246
|
+
if (!state.threadId) throw new Error("Codex did not return a search thread id.");
|
|
247
|
+
const turn = await current.client.request("turn/start", {
|
|
248
|
+
threadId: state.threadId,
|
|
249
|
+
input: [{ type: "text", text: String(query), text_elements: [] }],
|
|
250
|
+
cwd: current.directory,
|
|
251
|
+
runtimeWorkspaceRoots: [current.directory],
|
|
252
|
+
approvalPolicy: "untrusted",
|
|
253
|
+
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
|
254
|
+
model,
|
|
255
|
+
effort: "low",
|
|
256
|
+
summary: "none",
|
|
257
|
+
environments: [],
|
|
258
|
+
}, { timeoutMs: REQUEST_TIMEOUT_MS });
|
|
259
|
+
state.turnId = turn?.turn?.id || state.turnId;
|
|
260
|
+
if (state.violation && state.turnId) {
|
|
261
|
+
await current.client.request("turn/interrupt", {
|
|
262
|
+
threadId: state.threadId,
|
|
263
|
+
turnId: state.turnId,
|
|
264
|
+
}).catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
await waitForCompletion(completion, signal);
|
|
267
|
+
if (state.violation) throw new Error(state.violation);
|
|
268
|
+
if (!state.completed) throw new Error("Codex search turn did not complete.");
|
|
269
|
+
if (state.webSearchItems.length !== 1) {
|
|
270
|
+
throw new Error(`Codex search turn produced ${state.webSearchItems.length} web search items; expected exactly one.`);
|
|
271
|
+
}
|
|
272
|
+
const item = state.webSearchItems[0];
|
|
273
|
+
const actualQuery = typeof item.query === "string" && item.query.trim()
|
|
274
|
+
? item.query.trim()
|
|
275
|
+
: String(query);
|
|
276
|
+
if (actualQuery !== String(query)) {
|
|
277
|
+
throw new Error("Codex changed the exact web search query.");
|
|
278
|
+
}
|
|
279
|
+
const results = normalizeResults(item.results);
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
backend: "codex",
|
|
283
|
+
results,
|
|
284
|
+
actualQuery,
|
|
285
|
+
};
|
|
286
|
+
} finally {
|
|
287
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
288
|
+
current.target.handler = () => {};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function handleNotification(message, state, client) {
|
|
293
|
+
const method = typeof message?.method === "string" ? message.method : "";
|
|
294
|
+
if (method === "turn/started" && message?.params?.threadId === state.threadId) {
|
|
295
|
+
state.turnId ||= message?.params?.turn?.id || "";
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if ((method === "item/started" || method === "item/completed")
|
|
299
|
+
&& message?.params?.threadId === state.threadId) {
|
|
300
|
+
const item = message?.params?.item;
|
|
301
|
+
if (item?.type === "webSearch") {
|
|
302
|
+
if (method === "item/completed") state.webSearchItems.push(item);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (["userMessage", "agentMessage", "reasoning", "plan"].includes(item?.type)) return;
|
|
306
|
+
state.violation ||= `Codex subscription search attempted unsupported item ${String(item?.type || "unknown")}.`;
|
|
307
|
+
if (state.threadId && state.turnId) {
|
|
308
|
+
void client.request("turn/interrupt", { threadId: state.threadId, turnId: state.turnId }).catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
state.resolve();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (method === "turn/completed" && message?.params?.threadId === state.threadId) {
|
|
314
|
+
const turn = message?.params?.turn;
|
|
315
|
+
state.turnId ||= turn?.id || "";
|
|
316
|
+
state.completed = turn?.status === "completed";
|
|
317
|
+
if (!state.completed) state.violation ||= "Codex search turn failed.";
|
|
318
|
+
state.resolve();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (method === "error") {
|
|
322
|
+
state.violation ||= "Codex search turn reported an error.";
|
|
323
|
+
state.resolve();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (Object.prototype.hasOwnProperty.call(message || {}, "id") && method) {
|
|
327
|
+
state.violation ||= "Codex subscription search attempted an unsupported server request.";
|
|
328
|
+
state.resolve();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeResults(rows) {
|
|
333
|
+
if (!Array.isArray(rows)) return [];
|
|
334
|
+
const results = [];
|
|
335
|
+
for (const row of rows) {
|
|
336
|
+
if (!row || typeof row !== "object" || typeof row.url !== "string") continue;
|
|
337
|
+
results.push({
|
|
338
|
+
title: boundedText(row.title, 500),
|
|
339
|
+
url: row.url,
|
|
340
|
+
snippet: boundedText(row.snippet, 4_000),
|
|
341
|
+
provenance: boundedText(row.domain || row.ref_id || row.type, 300),
|
|
342
|
+
backend: "codex",
|
|
343
|
+
});
|
|
344
|
+
if (results.length >= MAX_RESULTS) break;
|
|
345
|
+
}
|
|
346
|
+
return results;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function waitForCompletion(completion, signal) {
|
|
350
|
+
let timer;
|
|
351
|
+
const timeout = new Promise((_, reject) => {
|
|
352
|
+
timer = setTimeout(() => reject(Object.assign(new Error("Codex search turn timed out."), {
|
|
353
|
+
code: "CODEX_SEARCH_TIMEOUT",
|
|
354
|
+
})), REQUEST_TIMEOUT_MS);
|
|
355
|
+
});
|
|
356
|
+
try {
|
|
357
|
+
if (signal?.aborted) throw Object.assign(new Error("WebSearch was aborted."), { name: "AbortError" });
|
|
358
|
+
await Promise.race([completion, timeout]);
|
|
359
|
+
} finally {
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function scheduleIdleClose() {
|
|
365
|
+
clearIdleTimer();
|
|
366
|
+
idleTimer = setTimeout(() => {
|
|
367
|
+
idleTimer = null;
|
|
368
|
+
void enqueue(async () => { await closeBroker(); });
|
|
369
|
+
}, IDLE_CLOSE_MS);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function clearIdleTimer() {
|
|
373
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
374
|
+
idleTimer = null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function closeBroker() {
|
|
378
|
+
clearIdleTimer();
|
|
379
|
+
const owned = broker;
|
|
380
|
+
broker = null;
|
|
381
|
+
if (owned) await closeOwnedBroker(owned);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function closeOwnedBroker(owned) {
|
|
385
|
+
await Promise.resolve(owned.client?.close?.()).catch(() => {});
|
|
386
|
+
await rm(owned.directory, { recursive: true, force: true }).catch(() => {});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function normalizeModel(value) {
|
|
390
|
+
return typeof value === "string" && value.trim() ? value.trim() : DEFAULT_CODEX_SEARCH_MODEL;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function boundedText(value, max) {
|
|
394
|
+
return typeof value === "string" ? value.replace(/\s+/gu, " ").trim().slice(0, max) : "";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function safeReason(error) {
|
|
398
|
+
if (error?.name === "AbortError") return "WebSearch was aborted.";
|
|
399
|
+
const message = error instanceof Error ? error.message : String(error || "unknown error");
|
|
400
|
+
return message.replace(/[\r\n\t]+/gu, " ").slice(0, 500) || "unknown error";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function publicReason(error) {
|
|
404
|
+
const reason = safeReason(error);
|
|
405
|
+
if (reason === "WebSearch was aborted.") return reason;
|
|
406
|
+
if (/timed out/iu.test(reason)) return "Codex app-server timed out.";
|
|
407
|
+
if (/signed in with ChatGPT subscription access/iu.test(reason)) return reason;
|
|
408
|
+
if (/does not expose web search/iu.test(reason)) return reason;
|
|
409
|
+
if (/Codex model .* is not available/iu.test(reason)) return reason;
|
|
410
|
+
if (/attempted unsupported item/iu.test(reason)) return "Codex attempted a non-search tool and the request was rejected.";
|
|
411
|
+
if (/unsupported server request/iu.test(reason)) return "Codex requested an unsupported interaction and the request was rejected.";
|
|
412
|
+
if (/produced .* web search items/iu.test(reason)) return "Codex did not produce exactly one structured web search result set.";
|
|
413
|
+
if (/changed the exact web search query/iu.test(reason)) return "Codex did not preserve the exact web search query.";
|
|
414
|
+
return "Codex app-server is not ready for subscription web search.";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isRetryable(error) {
|
|
418
|
+
return error?.name === "AbortError" || error?.code === "CODEX_SEARCH_TIMEOUT"
|
|
419
|
+
|| error?.code === "CODEX_APP_SERVER_REQUEST_TIMEOUT";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function abortedResult() {
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
backend: "codex",
|
|
426
|
+
message: "WebSearch was aborted.",
|
|
427
|
+
retryable: false,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Test hook for process-shared broker state. */
|
|
432
|
+
export async function __resetCodexSubscriptionSearchForTests() {
|
|
433
|
+
await enqueue(async () => { await closeBroker(); });
|
|
434
|
+
brokerOpening = null;
|
|
435
|
+
}
|
package/src/agent/tools/index.js
CHANGED
|
@@ -18,6 +18,11 @@ export {
|
|
|
18
18
|
export { execToolImpl, execToolRun } from "./exec.js";
|
|
19
19
|
export { webFetchToolImpl, performWebFetch } from "./web-fetch.js";
|
|
20
20
|
export { webSearchToolImpl, performWebSearch } from "./web-search.js";
|
|
21
|
+
export {
|
|
22
|
+
DEFAULT_CODEX_SEARCH_MODEL,
|
|
23
|
+
inspectCodexSubscriptionSearch,
|
|
24
|
+
searchCodexSubscription,
|
|
25
|
+
} from "./codex-subscription-search.js";
|
|
21
26
|
export { createWebToolController } from "./web-controller.js";
|
|
22
27
|
|
|
23
28
|
export { isPathAllowed, isWorkdirAllowed } from "./shared/path-resolver.js";
|
|
@@ -389,7 +389,7 @@ function readSkillTool(skillNames = [], { skillsRoot, dataDir, skills = [] } = {
|
|
|
389
389
|
return {
|
|
390
390
|
name: "ReadSkill",
|
|
391
391
|
label: "Read Skill",
|
|
392
|
-
description: "Load the complete instructions for a named skill. Use ReadSkill instead of Read for SKILL.md files.",
|
|
392
|
+
description: "Load the complete instructions for a named skill. Use ReadSkill instead of Read for SKILL.md files. If a skill's instructions are already present in this conversation, apply those instead of loading it again.",
|
|
393
393
|
parameters: objectSchema({ name: { type: "string", enum: enumNames } }, ["name"]),
|
|
394
394
|
async execute(_toolCallId, { name }) {
|
|
395
395
|
if (sharedRoot) {
|
|
@@ -473,6 +473,14 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
473
473
|
minimum: 1,
|
|
474
474
|
description: "Exact timeout in milliseconds.",
|
|
475
475
|
};
|
|
476
|
+
// Shared by Exec and Bash, and injected only when the host supplies a
|
|
477
|
+
// process-job controller. House style for a tool description is
|
|
478
|
+
// capability + when-to-prefer + caveat, so the middle sentence is what tells
|
|
479
|
+
// the model which commands belong here rather than in the foreground.
|
|
480
|
+
const backgroundSchema = {
|
|
481
|
+
type: "boolean",
|
|
482
|
+
description: "Run as a durable background process job and notify this conversation when it finishes. Prefer this for work that outlives a reply — builds, full test suites, long installs, migrations, long-running watchers — and leave it off whenever you need the output to answer right now. Do not use for commands that daemonize into another POSIX process group or session.",
|
|
483
|
+
};
|
|
476
484
|
// Per-tool closure config (cwd/event sink/limits/policy) plus the per-instance
|
|
477
485
|
// ToolContext `ctx` that the tool impls and shared helpers read from.
|
|
478
486
|
const toolContext = {
|
|
@@ -533,9 +541,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
533
541
|
timeout_ms: processTimeoutSchema,
|
|
534
542
|
timeout: legacyBashTimeoutSchema,
|
|
535
543
|
max_output_chars: bashLimitSchema,
|
|
536
|
-
...(processJobsController ? {
|
|
537
|
-
background: { type: "boolean", description: "Run as a durable background process job and notify this conversation when it finishes. Do not use for commands that daemonize into another POSIX process group or session." },
|
|
538
|
-
} : {}),
|
|
544
|
+
...(processJobsController ? { background: backgroundSchema } : {}),
|
|
539
545
|
}, ["command"]), bashToolRun, toolContext),
|
|
540
546
|
Exec: createBuiltinTool("Exec", "Exec", "Execute one program directly from an argv array without shell parsing. Prefer this for ordinary commands; use Bash only when shell syntax is required.", objectSchema({
|
|
541
547
|
executable: { type: "string", minLength: 1 },
|
|
@@ -543,9 +549,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
543
549
|
workdir: { type: "string" },
|
|
544
550
|
timeout_ms: processTimeoutSchema,
|
|
545
551
|
max_output_chars: bashLimitSchema,
|
|
546
|
-
...(processJobsController ? {
|
|
547
|
-
background: { type: "boolean", description: "Run as a durable background process job and notify this conversation when it finishes. Do not use for commands that daemonize into another POSIX process group or session." },
|
|
548
|
-
} : {}),
|
|
552
|
+
...(processJobsController ? { background: backgroundSchema } : {}),
|
|
549
553
|
}, ["executable"]), execToolRun, toolContext),
|
|
550
554
|
NodeRepl: nodeReplController
|
|
551
555
|
? createBuiltinTool(
|
|
@@ -575,7 +579,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
575
579
|
outcome: { status: "error", code: "controller_unavailable", retryable: false, attempts: 0 },
|
|
576
580
|
error: true,
|
|
577
581
|
}), toolContext),
|
|
578
|
-
WebSearch: createBuiltinTool("WebSearch", "Web Search", "Search the public web
|
|
582
|
+
WebSearch: createBuiltinTool("WebSearch", "Web Search", "Search the public web through local SearXNG, ChatGPT-subscription Codex search, and keyless fallbacks according to the configured backend, then return relevance-filtered deduplicated results.", objectSchema({
|
|
579
583
|
query: { type: "string" },
|
|
580
584
|
limit: { type: "integer" },
|
|
581
585
|
alternate_queries: { type: "array", items: { type: "string" }, maxItems: 3 },
|
|
@@ -84,7 +84,7 @@ export async function handOffProcessJob({
|
|
|
84
84
|
started_at: result.startedAt,
|
|
85
85
|
};
|
|
86
86
|
return {
|
|
87
|
-
text: JSON.stringify(payload)
|
|
87
|
+
text: `${BACKGROUND_START_GUIDANCE}\n${JSON.stringify(payload)}`,
|
|
88
88
|
outcome: {
|
|
89
89
|
status: "ok",
|
|
90
90
|
code: "background_started",
|
|
@@ -111,6 +111,16 @@ export async function handOffProcessJob({
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A bare id/state payload leaves the model to guess what happens next, and the
|
|
116
|
+
* cheapest wrong guess is a polling loop. Completion delivers its own turn, so
|
|
117
|
+
* the result says so itself rather than relying on the schema line alone. No
|
|
118
|
+
* operator command is named on purpose: the model has a shell, and naming a
|
|
119
|
+
* status command invites exactly the polling this forbids.
|
|
120
|
+
*/
|
|
121
|
+
const BACKGROUND_START_GUIDANCE =
|
|
122
|
+
"Background process job started (tool-authored guidance): this conversation is woken with a new turn when the job reaches a terminal state, and its output arrives with that turn. Do not poll, sleep, wait on it, or re-run the command to check progress, and do not report the work as finished yet.";
|
|
123
|
+
|
|
114
124
|
const PUBLIC_BACKGROUND_START_FAILURES = Object.freeze({
|
|
115
125
|
background_unsupported: "Background process jobs are unsupported for this tool call.",
|
|
116
126
|
background_unsupported_channel: "Background process jobs are unsupported for this channel.",
|
|
@@ -28,7 +28,7 @@ const sharedSearchCache = new Map();
|
|
|
28
28
|
* cleanup. Search results are the exception: they live in the process-wide
|
|
29
29
|
* cache above so sibling subagents and later turns can reuse them.
|
|
30
30
|
*
|
|
31
|
-
* @param {{searchConfig?: any, fetchConfig?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, fetchImpl?: typeof fetch, browserRenderer?: any}} [options]
|
|
31
|
+
* @param {{searchConfig?: any, fetchConfig?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, fetchImpl?: typeof fetch, browserRenderer?: any, codexSearch?: any}} [options]
|
|
32
32
|
*/
|
|
33
33
|
export function createWebToolController({
|
|
34
34
|
searchConfig,
|
|
@@ -38,6 +38,7 @@ export function createWebToolController({
|
|
|
38
38
|
ctx,
|
|
39
39
|
fetchImpl,
|
|
40
40
|
browserRenderer,
|
|
41
|
+
codexSearch,
|
|
41
42
|
} = {}) {
|
|
42
43
|
const namespace = `mono-agent-web-${randomUUID()}`;
|
|
43
44
|
const fetchCache = new Map();
|
|
@@ -137,6 +138,7 @@ export function createWebToolController({
|
|
|
137
138
|
sandboxPolicy: policy,
|
|
138
139
|
ctx: resolvedCtx,
|
|
139
140
|
fetchImpl,
|
|
141
|
+
codexSearch,
|
|
140
142
|
signal: execution.signal,
|
|
141
143
|
}));
|
|
142
144
|
},
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { parseHTML } from "linkedom";
|
|
4
4
|
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
5
|
+
import { searchCodexSubscription } from "./codex-subscription-search.js";
|
|
5
6
|
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
6
7
|
import { createCountingSemaphore } from "./shared/semaphore.js";
|
|
7
8
|
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
@@ -81,11 +82,12 @@ export async function webSearchToolImpl(params, options = {}) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
|
-
* Search through an operator-owned SearXNG endpoint
|
|
85
|
-
* fallback chain. Returns a structured
|
|
85
|
+
* Search through an operator-owned SearXNG endpoint, ChatGPT-subscription
|
|
86
|
+
* Codex search, and/or the keyless HTML fallback chain. Returns a structured
|
|
87
|
+
* internal outcome for the Pi bridge.
|
|
86
88
|
*
|
|
87
89
|
* @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
|
|
88
|
-
* @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
|
|
90
|
+
* @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch, codexSearch?: typeof searchCodexSubscription}} [options]
|
|
89
91
|
*/
|
|
90
92
|
export async function performWebSearch(
|
|
91
93
|
{
|
|
@@ -103,6 +105,7 @@ export async function performWebSearch(
|
|
|
103
105
|
signal,
|
|
104
106
|
searchConfig,
|
|
105
107
|
fetchImpl = globalThis.fetch,
|
|
108
|
+
codexSearch = searchCodexSubscription,
|
|
106
109
|
} = {},
|
|
107
110
|
) {
|
|
108
111
|
const startedAt = Date.now();
|
|
@@ -111,7 +114,8 @@ export async function performWebSearch(
|
|
|
111
114
|
return failure("Error: WebSearch query must not be empty.", "invalid_query", startedAt);
|
|
112
115
|
}
|
|
113
116
|
const max = clampInteger(limit, 1, 10, 5);
|
|
114
|
-
const
|
|
117
|
+
const explicitDomains = normalizeDomains(Array.isArray(domains) ? domains : []);
|
|
118
|
+
const includeDomains = normalizeDomains([...explicitDomains, ...querySiteDomains(normalizedQuery)]);
|
|
115
119
|
const excludeDomains = normalizeDomains(exclude_domains);
|
|
116
120
|
const config = normalizeSearchConfig(searchConfig);
|
|
117
121
|
if (config.error) return failure(`Error: ${config.error}`, "invalid_search_config", startedAt);
|
|
@@ -119,26 +123,33 @@ export async function performWebSearch(
|
|
|
119
123
|
const resolvedCtx = ctx ?? readToolRuntime();
|
|
120
124
|
const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
|
|
121
125
|
const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
|
|
126
|
+
// Operators, quotes, and site: constraints are never relaxed or rewritten by
|
|
127
|
+
// the host. Alternate queries are explicit model input, not host-generated
|
|
128
|
+
// substitutions for the user's exact primary query.
|
|
122
129
|
const initialQueries = uniqueStrings([normalizedQuery, ...alternate_queries], 4);
|
|
123
130
|
/** @type {Array<Array<{title: string, url: string, snippet: string, backend: string}>>} */
|
|
124
131
|
const rankedLists = [];
|
|
125
132
|
const providerFailures = [];
|
|
126
133
|
const providersUsed = new Set();
|
|
134
|
+
const attemptedBackends = new Set();
|
|
135
|
+
const actualQueries = [];
|
|
127
136
|
let attempts = 0;
|
|
128
137
|
let anyProviderSucceeded = false;
|
|
129
138
|
|
|
130
|
-
const runQuery = async (candidate) => {
|
|
139
|
+
const runQuery = async (candidate, backend) => {
|
|
131
140
|
attempts += 1;
|
|
141
|
+
attemptedBackends.add(backend);
|
|
132
142
|
return await searchOneQuery(
|
|
133
|
-
queryWithDomains(candidate,
|
|
143
|
+
queryWithDomains(candidate, explicitDomains),
|
|
134
144
|
{
|
|
135
|
-
config,
|
|
145
|
+
config: { ...config, backend },
|
|
136
146
|
language,
|
|
137
147
|
timeRange: time_range,
|
|
138
148
|
sandbox,
|
|
139
149
|
policy,
|
|
140
150
|
signal,
|
|
141
151
|
fetchImpl,
|
|
152
|
+
codexSearch,
|
|
142
153
|
},
|
|
143
154
|
);
|
|
144
155
|
};
|
|
@@ -147,16 +158,51 @@ export async function performWebSearch(
|
|
|
147
158
|
// so a silent degradation to the fallback is still visible in the outcome.
|
|
148
159
|
if (result.failures?.length) providerFailures.push(...result.failures);
|
|
149
160
|
if (result.ok) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
const filtered = filterRelevantResults(
|
|
162
|
+
filterByDomains(result.results, includeDomains, excludeDomains),
|
|
163
|
+
normalizedQuery,
|
|
164
|
+
);
|
|
165
|
+
if (filtered.length > 0) {
|
|
166
|
+
anyProviderSucceeded = true;
|
|
167
|
+
providersUsed.add(result.backend);
|
|
168
|
+
rankedLists.push(filtered);
|
|
169
|
+
} else if (result.results.length === 0) {
|
|
170
|
+
anyProviderSucceeded = true;
|
|
171
|
+
} else {
|
|
172
|
+
providerFailures.push({
|
|
173
|
+
ok: false,
|
|
174
|
+
backend: result.backend,
|
|
175
|
+
message: `${result.backend} returned no relevant results.`,
|
|
176
|
+
retryable: false,
|
|
177
|
+
relevance: true,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (typeof result.actualQuery === "string" && result.actualQuery.trim()) {
|
|
181
|
+
actualQueries.push(result.actualQuery.trim());
|
|
182
|
+
}
|
|
153
183
|
} else if (!result.failures?.length) {
|
|
154
184
|
providerFailures.push(result);
|
|
155
185
|
}
|
|
156
186
|
};
|
|
157
187
|
|
|
158
|
-
const
|
|
159
|
-
|
|
188
|
+
const runStage = async (backend, candidates) => {
|
|
189
|
+
const results = await Promise.all(candidates.map((candidate) => runQuery(candidate, backend)));
|
|
190
|
+
results.forEach(recordResult);
|
|
191
|
+
return mergeRankedResults(rankedLists, max);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
let merged = [];
|
|
195
|
+
if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
|
|
196
|
+
merged = await runStage("searxng", initialQueries);
|
|
197
|
+
}
|
|
198
|
+
if (config.backend === "codex" || (config.backend === "auto" && merged.length === 0)) {
|
|
199
|
+
// Exactly one subscription turn per WebSearch call. Alternate queries still
|
|
200
|
+
// help local/keyless rank fusion but never multiply paid subscription work.
|
|
201
|
+
merged = await runStage("codex", [normalizedQuery]);
|
|
202
|
+
}
|
|
203
|
+
if (config.backend === "keyless" || (config.backend === "auto" && merged.length === 0)) {
|
|
204
|
+
merged = await runStage("keyless", initialQueries);
|
|
205
|
+
}
|
|
160
206
|
if (signal?.aborted) {
|
|
161
207
|
return failure("Error: WebSearch was aborted.", "aborted", startedAt, {
|
|
162
208
|
attempts,
|
|
@@ -164,15 +210,6 @@ export async function performWebSearch(
|
|
|
164
210
|
});
|
|
165
211
|
}
|
|
166
212
|
|
|
167
|
-
let merged = mergeRankedResults(rankedLists, max);
|
|
168
|
-
if (merged.length < max && initialQueries.length < 4) {
|
|
169
|
-
const relaxed = relaxedQuery(normalizedQuery);
|
|
170
|
-
if (relaxed && !initialQueries.includes(relaxed)) {
|
|
171
|
-
recordResult(await runQuery(relaxed));
|
|
172
|
-
merged = mergeRankedResults(rankedLists, max);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
213
|
if (!anyProviderSucceeded) {
|
|
177
214
|
// Four query variants against two backends produce the same handful of
|
|
178
215
|
// messages over and over; dedupe so the reason stays readable.
|
|
@@ -190,10 +227,14 @@ export async function performWebSearch(
|
|
|
190
227
|
retryable: providerFailures.some((entry) => entry.retryable),
|
|
191
228
|
rateLimited: throttled,
|
|
192
229
|
cooldownBackends: [...backendCooldownUntil.keys()],
|
|
230
|
+
attemptedBackends: [...attemptedBackends],
|
|
231
|
+
failureMetadata: sanitizeFailureMetadata(providerFailures),
|
|
193
232
|
});
|
|
194
233
|
}
|
|
195
234
|
|
|
196
|
-
const backend = providersUsed.size === 1
|
|
235
|
+
const backend = providersUsed.size === 1
|
|
236
|
+
? [...providersUsed][0]
|
|
237
|
+
: providersUsed.size > 1 ? "mixed" : config.backend;
|
|
197
238
|
const body = merged.length === 0
|
|
198
239
|
? "No results."
|
|
199
240
|
: merged.map((result, index) => {
|
|
@@ -202,6 +243,12 @@ export async function performWebSearch(
|
|
|
202
243
|
}).join("\n\n");
|
|
203
244
|
const text = [
|
|
204
245
|
"[BEGIN UNTRUSTED WEB SEARCH RESULTS]",
|
|
246
|
+
searchMetadataLine({
|
|
247
|
+
backend,
|
|
248
|
+
attemptedBackends: [...attemptedBackends],
|
|
249
|
+
query: actualQueries[0] || normalizedQuery,
|
|
250
|
+
providerFailures,
|
|
251
|
+
}),
|
|
205
252
|
body,
|
|
206
253
|
"[END UNTRUSTED WEB SEARCH RESULTS]",
|
|
207
254
|
].join("\n");
|
|
@@ -221,6 +268,9 @@ export async function performWebSearch(
|
|
|
221
268
|
providerFailureCount: providerFailures.length,
|
|
222
269
|
rateLimited: providerFailures.some((entry) => entry.rateLimited || entry.cooldown),
|
|
223
270
|
cooldownBackends: [...backendCooldownUntil.keys()],
|
|
271
|
+
attemptedBackends: [...attemptedBackends],
|
|
272
|
+
actualQueries: uniqueStrings(actualQueries.length > 0 ? actualQueries : [normalizedQuery], 4),
|
|
273
|
+
failureMetadata: sanitizeFailureMetadata(providerFailures),
|
|
224
274
|
},
|
|
225
275
|
error: false,
|
|
226
276
|
};
|
|
@@ -243,16 +293,27 @@ async function searchOneQuery(query, options) {
|
|
|
243
293
|
// circuit `auto` outright, so the fallbacks below could never rescue a query.
|
|
244
294
|
let emptySuccess = null;
|
|
245
295
|
if (options.signal?.aborted) return abortedSearch(config.backend, failures);
|
|
246
|
-
if (config.backend === "searxng"
|
|
296
|
+
if (config.backend === "searxng") {
|
|
247
297
|
const result = await searchSearxng(query, options);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
298
|
+
return { ...result, failures };
|
|
299
|
+
}
|
|
300
|
+
if (config.backend === "codex") {
|
|
301
|
+
if (!options.sandbox.networkAllowsUrl(options.policy, "https://chatgpt.com")) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
backend: "codex",
|
|
305
|
+
message: "Network access denied by sandbox policy.",
|
|
306
|
+
retryable: false,
|
|
307
|
+
failures,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const result = await options.codexSearch(query, {
|
|
311
|
+
model: config.codex.model,
|
|
312
|
+
signal: options.signal,
|
|
313
|
+
});
|
|
314
|
+
return { ...result, failures };
|
|
315
|
+
}
|
|
316
|
+
if (config.backend === "keyless") {
|
|
256
317
|
for (const backend of KEYLESS_BACKENDS) {
|
|
257
318
|
if (options.signal?.aborted) return abortedSearch(backend, failures);
|
|
258
319
|
if (backendInCooldown(backend)) {
|
|
@@ -694,8 +755,8 @@ export function mergeRankedResults(rankedLists, limit = 10) {
|
|
|
694
755
|
|
|
695
756
|
function normalizeSearchConfig(input) {
|
|
696
757
|
const backend = input?.backend ?? "auto";
|
|
697
|
-
if (!["auto", "searxng", "keyless"].includes(backend)) {
|
|
698
|
-
return { error: "Web search backend must be auto, searxng, or keyless." };
|
|
758
|
+
if (!["auto", "searxng", "codex", "keyless"].includes(backend)) {
|
|
759
|
+
return { error: "Web search backend must be auto, searxng, codex, or keyless." };
|
|
699
760
|
}
|
|
700
761
|
let endpoint;
|
|
701
762
|
if (input?.endpoint !== undefined && String(input.endpoint).trim()) {
|
|
@@ -716,7 +777,13 @@ function normalizeSearchConfig(input) {
|
|
|
716
777
|
if (backend === "searxng" && !endpoint) {
|
|
717
778
|
return { error: "SearXNG backend requires tools.web.search.endpoint." };
|
|
718
779
|
}
|
|
719
|
-
|
|
780
|
+
const model = typeof input?.codex?.model === "string" && input.codex.model.trim()
|
|
781
|
+
? input.codex.model.trim()
|
|
782
|
+
: "gpt-5.6-luna";
|
|
783
|
+
if (model.length > 160 || /[\u0000-\u001f\u007f]/u.test(model)) {
|
|
784
|
+
return { error: "Codex web search model must be a valid model id." };
|
|
785
|
+
}
|
|
786
|
+
return { backend, endpoint, codex: { model } };
|
|
720
787
|
}
|
|
721
788
|
|
|
722
789
|
function isLoopbackHost(hostname) {
|
|
@@ -746,6 +813,48 @@ function filterByDomains(results, include, exclude) {
|
|
|
746
813
|
});
|
|
747
814
|
}
|
|
748
815
|
|
|
816
|
+
const RELEVANCE_STOP_WORDS = new Set([
|
|
817
|
+
"about", "after", "before", "best", "find", "from", "into", "latest",
|
|
818
|
+
"near", "news", "that", "the", "their", "this", "time", "what", "when",
|
|
819
|
+
"where", "which", "with", "your",
|
|
820
|
+
]);
|
|
821
|
+
|
|
822
|
+
function filterRelevantResults(results, query) {
|
|
823
|
+
const phrases = [...String(query).matchAll(/"([^"]{2,})"/gu)]
|
|
824
|
+
.map((match) => comparableText(match[1]))
|
|
825
|
+
.filter(Boolean);
|
|
826
|
+
const terms = uniqueStrings(
|
|
827
|
+
comparableText(String(query)
|
|
828
|
+
.replace(/"[^"]*"/gu, " ")
|
|
829
|
+
.replace(/\bsite:\S+/giu, " "))
|
|
830
|
+
.split(" ")
|
|
831
|
+
.filter((term) => term.length >= 3 && !RELEVANCE_STOP_WORDS.has(term)),
|
|
832
|
+
20,
|
|
833
|
+
);
|
|
834
|
+
if (phrases.length === 0 && terms.length === 0) return results;
|
|
835
|
+
const requiredTerms = Math.min(terms.length, terms.length >= 3 ? 2 : 1);
|
|
836
|
+
return results.filter((result) => {
|
|
837
|
+
const haystack = comparableText(`${result.title} ${result.snippet} ${result.url}`);
|
|
838
|
+
if (phrases.some((phrase) => !haystack.includes(phrase))) return false;
|
|
839
|
+
if (requiredTerms === 0) return true;
|
|
840
|
+
let matches = 0;
|
|
841
|
+
for (const term of terms) {
|
|
842
|
+
if (haystack.includes(term)) matches += 1;
|
|
843
|
+
if (matches >= requiredTerms) return true;
|
|
844
|
+
}
|
|
845
|
+
return false;
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function comparableText(value) {
|
|
850
|
+
return String(value || "")
|
|
851
|
+
.normalize("NFKC")
|
|
852
|
+
.toLowerCase()
|
|
853
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
854
|
+
.replace(/\s+/gu, " ")
|
|
855
|
+
.trim();
|
|
856
|
+
}
|
|
857
|
+
|
|
749
858
|
function domainMatches(host, domain) {
|
|
750
859
|
return host === domain || host.endsWith(`.${domain}`);
|
|
751
860
|
}
|
|
@@ -755,13 +864,38 @@ function queryWithDomains(query, domains) {
|
|
|
755
864
|
return `${query} (${domains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
|
756
865
|
}
|
|
757
866
|
|
|
758
|
-
function
|
|
759
|
-
|
|
760
|
-
.
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
867
|
+
function querySiteDomains(query) {
|
|
868
|
+
return [...String(query).matchAll(/\bsite:([a-z0-9.-]+)(?:\/\S*)?/giu)]
|
|
869
|
+
.map((match) => match[1]);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function sanitizeFailureMetadata(failures) {
|
|
873
|
+
const seen = new Set();
|
|
874
|
+
const metadata = [];
|
|
875
|
+
for (const failureEntry of failures) {
|
|
876
|
+
const backend = collapseWhitespace(failureEntry?.backend).slice(0, 40) || "unknown";
|
|
877
|
+
const code = failureEntry?.relevance
|
|
878
|
+
? "no_relevant_results"
|
|
879
|
+
: failureEntry?.rateLimited ? "rate_limited"
|
|
880
|
+
: failureEntry?.cooldown ? "cooldown"
|
|
881
|
+
: failureEntry?.message === "Network access denied by sandbox policy."
|
|
882
|
+
? "network_denied"
|
|
883
|
+
: "unavailable";
|
|
884
|
+
const key = `${backend}:${code}`;
|
|
885
|
+
if (seen.has(key)) continue;
|
|
886
|
+
seen.add(key);
|
|
887
|
+
metadata.push({ backend, code });
|
|
888
|
+
if (metadata.length >= 12) break;
|
|
889
|
+
}
|
|
890
|
+
return metadata;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function searchMetadataLine({ backend, attemptedBackends, query, providerFailures }) {
|
|
894
|
+
const attempted = attemptedBackends.join(",") || "none";
|
|
895
|
+
const failures = sanitizeFailureMetadata(providerFailures)
|
|
896
|
+
.map((entry) => `${entry.backend}:${entry.code}`)
|
|
897
|
+
.join(",") || "none";
|
|
898
|
+
return `[Search metadata: backend=${backend}; attempted=${attempted}; actual_query=${JSON.stringify(collapseWhitespace(query).slice(0, 500))}; fallback=${failures}]`;
|
|
765
899
|
}
|
|
766
900
|
|
|
767
901
|
function uniqueStrings(values, limit) {
|
package/src/ai/failure.js
CHANGED
|
@@ -84,7 +84,7 @@ const PROVIDER_AUTH_RE = /(no api key|missing api key|api key required|invalid a
|
|
|
84
84
|
// like worklab's coordinator, independent of retryableProviderFailureInfo) maps
|
|
85
85
|
// that same terse text to the generic "spawn" kind instead of
|
|
86
86
|
// "provider_unavailable".
|
|
87
|
-
const PROVIDER_UNAVAILABLE_RE = /(econn|enotfound|etimedout|timed? ?out|service unavailable|503|502|gateway|fetch failed|network|websocket|\bconnection (?:error|refused|failed)\b|\bcould not connect\b)/i;
|
|
87
|
+
const PROVIDER_UNAVAILABLE_RE = /(econn|enotfound|etimedout|timed? ?out|service unavailable|503|502|gateway|fetch failed|network|websocket|\bconnection (?:error|refused|failed)\b|\bcould not connect\b|\bstream ended without finish_reason\b)/i;
|
|
88
88
|
const TOOL_FAILURE_RE = /(tool .* failed|mcp tool|permission denied|EACCES|read-only file system)/i;
|
|
89
89
|
const NON_RETRYABLE_PROVIDER_RE = /(invalid[_ ]request|unknown parameter|no api key|missing api key|api key required|invalid api key|incorrect api key|provider is not configured:|authentication|authorization|not authorized|forbidden|billing|insufficient[_ ]quota|quota exceeded|model[_ ]not[_ ]found|unsupported model|permission denied|bad request|401|403|404)/i;
|
|
90
90
|
// pi 0.80's openai-client-style bridge collapses a connection-refused/unreachable
|
|
@@ -92,7 +92,15 @@ const NON_RETRYABLE_PROVIDER_RE = /(invalid[_ ]request|unknown parameter|no api
|
|
|
92
92
|
// no fetch failed) — the `\bconnection (?:error|refused|failed)\b|\bcould not connect\b`
|
|
93
93
|
// alternation below is the motivating fix so that case still fails over instead of
|
|
94
94
|
// being classified as non-retryable.
|
|
95
|
-
|
|
95
|
+
// pi-ai 0.83.0 adds a second such bare sentence: openai-completions.js throws
|
|
96
|
+
// "Stream ended without finish_reason" when an SSE stream terminates without a
|
|
97
|
+
// finish_reason chunk. Because pi-models.js pins every custom/OpenAI-compatible
|
|
98
|
+
// provider to the openai-completions api, a truncated gateway response was terminal
|
|
99
|
+
// on the first occurrence — a configured fallback chain never advanced off the
|
|
100
|
+
// primary. Matched as the full sentence on purpose: mono-agent's own TUI/web
|
|
101
|
+
// transports emit a similar "Stream ended without a finish or error frame." that is a
|
|
102
|
+
// local protocol fault and must NOT trigger a provider failover.
|
|
103
|
+
const RETRYABLE_PROVIDER_RE = /(currently overloaded|server(?:s)? (?:is |are )?overloaded|try again later|retry your request|request id|service unavailable|temporar(?:y|ily)|timed? ?out|stream disconnected|fetch failed|econnreset|econnrefused|eai_again|enotfound|etimedout|network|429|too many requests|500|502|503|504|gateway|internal server error|\bconnection (?:error|refused|failed)\b|\bcould not connect\b|\bstream ended without finish_reason\b)/i;
|
|
96
104
|
export const PROVIDER_ABORT_RE = /\b(?:terminated|aborted before final output|aborted before final|stream aborted|stream was aborted|stream disconnected|websocket (?:error|disconnected|closed)|socket hang up|und_err_socket|econnreset|premature close)\b/i;
|
|
97
105
|
|
|
98
106
|
/**
|
|
@@ -129,7 +137,12 @@ function retryableProviderSubkind(text) {
|
|
|
129
137
|
if (/timed? ?out|etimedout/i.test(text)) return "timeout";
|
|
130
138
|
// pi 0.80's terse "Connection error." (no ECONNREFUSED/fetch-failed detail) still
|
|
131
139
|
// needs to land in the "network" subkind so a down provider fails over.
|
|
132
|
-
|
|
140
|
+
// pi-ai 0.83.0's openai-completions adapter throws the bare sentence
|
|
141
|
+
// "Stream ended without finish_reason" when an OpenAI-compatible SSE stream closes
|
|
142
|
+
// cleanly but no chunk ever carried a truthy finish_reason — i.e. a gateway
|
|
143
|
+
// truncated the response. That is a provider-side outage, so it belongs in the same
|
|
144
|
+
// "network" subkind as the terse connection error above.
|
|
145
|
+
if (/stream disconnected|fetch failed|econnreset|econnrefused|eai_again|enotfound|network|\bconnection (?:error|refused|failed)\b|\bcould not connect\b|\bstream ended without finish_reason\b/i.test(text)) return "network";
|
|
133
146
|
if (/500|502|503|504|service unavailable|gateway|internal server error/i.test(text)) return "server_error";
|
|
134
147
|
if (/retry your request|try again later|request id|processing your request/i.test(text)) return "retryable_request";
|
|
135
148
|
return null;
|
package/src/ai/types.js
CHANGED
|
@@ -287,7 +287,7 @@
|
|
|
287
287
|
* @property {import('./providers/acp-client.js').AcpClientHostOptions["resolveAcpProfile"]} [resolveAcpProfile] Per-run ACP profile resolver; wins over the host default.
|
|
288
288
|
* @property {import('./providers/acp-client.js').AcpClientHostOptions["onAcpInteractionRequest"]} [onAcpInteractionRequest] Per-run ACP permission/elicitation callback; wins over the host default.
|
|
289
289
|
* @property {Uint8Array} [acpSessionTokenKey] Host-owned 32-byte key for confidential authenticated ACP session handles. Required for every ACP task run.
|
|
290
|
-
* @property {{backend?: "auto"|"searxng"|"keyless", endpoint?: string}} [webSearchConfig] Run-scoped WebSearch backend configuration.
|
|
290
|
+
* @property {{backend?: "auto"|"searxng"|"codex"|"keyless", endpoint?: string, codex?: {model?: string}}} [webSearchConfig] Run-scoped WebSearch backend configuration.
|
|
291
291
|
* @property {{render?: "never"|"auto", browserCommand?: string}} [webFetchConfig] Run-scoped WebFetch extraction/render configuration.
|
|
292
292
|
* @property {"sequential"|"safe-parallel"} [piToolExecutionMode] Pi built-in tool scheduling mode. Safe parallelism is the default.
|
|
293
293
|
* @property {"one-at-a-time"|"all"} [piToolParallelismMode] DEPRECATED. Compatibility alias mapped to piToolExecutionMode.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect the installed Codex app-server without reading or exporting tokens.
|
|
3
|
+
*
|
|
4
|
+
* @param {{model?: string, clientFactory?: typeof createCodexAppServerClient}} [options]
|
|
5
|
+
*/
|
|
6
|
+
export function inspectCodexSubscriptionSearch(options?: {
|
|
7
|
+
model?: string;
|
|
8
|
+
clientFactory?: typeof createCodexAppServerClient;
|
|
9
|
+
}): Promise<{
|
|
10
|
+
ok: boolean;
|
|
11
|
+
code: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
model: any;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* Execute one subscription-backed live search through Codex app-server.
|
|
17
|
+
* Calls are serialized process-wide so one agent cannot fan out subscription
|
|
18
|
+
* turns or cross-wire app-server notifications between requests.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} query
|
|
21
|
+
* @param {{model?: string, signal?: AbortSignal, clientFactory?: typeof createCodexAppServerClient}} [options]
|
|
22
|
+
*/
|
|
23
|
+
export function searchCodexSubscription(query: string, options?: {
|
|
24
|
+
model?: string;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
clientFactory?: typeof createCodexAppServerClient;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
/** Test hook for process-shared broker state. */
|
|
29
|
+
export function __resetCodexSubscriptionSearchForTests(): Promise<void>;
|
|
30
|
+
export const DEFAULT_CODEX_SEARCH_MODEL: "gpt-5.6-luna";
|
|
31
|
+
import { createCodexAppServerClient } from "../../ai/providers/codex-app.js";
|
|
@@ -9,4 +9,5 @@ export { bashToolImpl, bashToolRun, normalizeBashTimeoutMs, normalizeProcessTime
|
|
|
9
9
|
export { execToolImpl, execToolRun } from "./exec.js";
|
|
10
10
|
export { webFetchToolImpl, performWebFetch } from "./web-fetch.js";
|
|
11
11
|
export { webSearchToolImpl, performWebSearch } from "./web-search.js";
|
|
12
|
+
export { DEFAULT_CODEX_SEARCH_MODEL, inspectCodexSubscriptionSearch, searchCodexSubscription } from "./codex-subscription-search.js";
|
|
12
13
|
export { isPathAllowed, isWorkdirAllowed } from "./shared/path-resolver.js";
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* cleanup. Search results are the exception: they live in the process-wide
|
|
5
5
|
* cache above so sibling subagents and later turns can reuse them.
|
|
6
6
|
*
|
|
7
|
-
* @param {{searchConfig?: any, fetchConfig?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, fetchImpl?: typeof fetch, browserRenderer?: any}} [options]
|
|
7
|
+
* @param {{searchConfig?: any, fetchConfig?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, fetchImpl?: typeof fetch, browserRenderer?: any, codexSearch?: any}} [options]
|
|
8
8
|
*/
|
|
9
|
-
export function createWebToolController({ searchConfig, fetchConfig, sandboxPolicy, sandboxEngine, ctx, fetchImpl, browserRenderer, }?: {
|
|
9
|
+
export function createWebToolController({ searchConfig, fetchConfig, sandboxPolicy, sandboxEngine, ctx, fetchImpl, browserRenderer, codexSearch, }?: {
|
|
10
10
|
searchConfig?: any;
|
|
11
11
|
fetchConfig?: any;
|
|
12
12
|
sandboxPolicy?: any;
|
|
@@ -14,6 +14,7 @@ export function createWebToolController({ searchConfig, fetchConfig, sandboxPoli
|
|
|
14
14
|
ctx?: any;
|
|
15
15
|
fetchImpl?: typeof fetch;
|
|
16
16
|
browserRenderer?: any;
|
|
17
|
+
codexSearch?: any;
|
|
17
18
|
}): {
|
|
18
19
|
namespace: string;
|
|
19
20
|
search(params: any, execution?: {}): Promise<any>;
|
|
@@ -20,11 +20,12 @@ export function webSearchToolImpl(params: {
|
|
|
20
20
|
fetchImpl?: typeof fetch;
|
|
21
21
|
}): Promise<any>;
|
|
22
22
|
/**
|
|
23
|
-
* Search through an operator-owned SearXNG endpoint
|
|
24
|
-
* fallback chain. Returns a structured
|
|
23
|
+
* Search through an operator-owned SearXNG endpoint, ChatGPT-subscription
|
|
24
|
+
* Codex search, and/or the keyless HTML fallback chain. Returns a structured
|
|
25
|
+
* internal outcome for the Pi bridge.
|
|
25
26
|
*
|
|
26
27
|
* @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
|
|
27
|
-
* @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
|
|
28
|
+
* @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch, codexSearch?: typeof searchCodexSubscription}} [options]
|
|
28
29
|
*/
|
|
29
30
|
export function performWebSearch({ query, limit, alternate_queries, domains, exclude_domains, language, time_range, }: {
|
|
30
31
|
query: string;
|
|
@@ -34,12 +35,13 @@ export function performWebSearch({ query, limit, alternate_queries, domains, exc
|
|
|
34
35
|
exclude_domains?: string[];
|
|
35
36
|
language?: string;
|
|
36
37
|
time_range?: string;
|
|
37
|
-
}, { sandboxPolicy, ctx, signal, searchConfig, fetchImpl, }?: {
|
|
38
|
+
}, { sandboxPolicy, ctx, signal, searchConfig, fetchImpl, codexSearch, }?: {
|
|
38
39
|
sandboxPolicy?: any;
|
|
39
40
|
ctx?: any;
|
|
40
41
|
signal?: AbortSignal;
|
|
41
42
|
searchConfig?: any;
|
|
42
43
|
fetchImpl?: typeof fetch;
|
|
44
|
+
codexSearch?: typeof searchCodexSubscription;
|
|
43
45
|
}): Promise<{
|
|
44
46
|
text: any;
|
|
45
47
|
outcome: {
|
|
@@ -70,6 +72,12 @@ export function performWebSearch({ query, limit, alternate_queries, domains, exc
|
|
|
70
72
|
providerFailureCount: number;
|
|
71
73
|
rateLimited: boolean;
|
|
72
74
|
cooldownBackends: string[];
|
|
75
|
+
attemptedBackends: any[];
|
|
76
|
+
actualQueries: string[];
|
|
77
|
+
failureMetadata: {
|
|
78
|
+
backend: string;
|
|
79
|
+
code: string;
|
|
80
|
+
}[];
|
|
73
81
|
};
|
|
74
82
|
error: boolean;
|
|
75
83
|
}>;
|
|
@@ -98,3 +106,4 @@ export function parseStartpageResults(html: any): {
|
|
|
98
106
|
}[];
|
|
99
107
|
export function canonicalizeSearchUrl(value: any, base: any): string;
|
|
100
108
|
export function mergeRankedResults(rankedLists: any, limit?: number): any[];
|
|
109
|
+
import { searchCodexSubscription } from "./codex-subscription-search.js";
|
package/types/ai/types.d.ts
CHANGED
|
@@ -247,7 +247,7 @@
|
|
|
247
247
|
* @property {import('./providers/acp-client.js').AcpClientHostOptions["resolveAcpProfile"]} [resolveAcpProfile] Per-run ACP profile resolver; wins over the host default.
|
|
248
248
|
* @property {import('./providers/acp-client.js').AcpClientHostOptions["onAcpInteractionRequest"]} [onAcpInteractionRequest] Per-run ACP permission/elicitation callback; wins over the host default.
|
|
249
249
|
* @property {Uint8Array} [acpSessionTokenKey] Host-owned 32-byte key for confidential authenticated ACP session handles. Required for every ACP task run.
|
|
250
|
-
* @property {{backend?: "auto"|"searxng"|"keyless", endpoint?: string}} [webSearchConfig] Run-scoped WebSearch backend configuration.
|
|
250
|
+
* @property {{backend?: "auto"|"searxng"|"codex"|"keyless", endpoint?: string, codex?: {model?: string}}} [webSearchConfig] Run-scoped WebSearch backend configuration.
|
|
251
251
|
* @property {{render?: "never"|"auto", browserCommand?: string}} [webFetchConfig] Run-scoped WebFetch extraction/render configuration.
|
|
252
252
|
* @property {"sequential"|"safe-parallel"} [piToolExecutionMode] Pi built-in tool scheduling mode. Safe parallelism is the default.
|
|
253
253
|
* @property {"one-at-a-time"|"all"} [piToolParallelismMode] DEPRECATED. Compatibility alias mapped to piToolExecutionMode.
|
|
@@ -941,8 +941,11 @@ export type RuntimeRunOptions = {
|
|
|
941
941
|
* Run-scoped WebSearch backend configuration.
|
|
942
942
|
*/
|
|
943
943
|
webSearchConfig?: {
|
|
944
|
-
backend?: "auto" | "searxng" | "keyless";
|
|
944
|
+
backend?: "auto" | "searxng" | "codex" | "keyless";
|
|
945
945
|
endpoint?: string;
|
|
946
|
+
codex?: {
|
|
947
|
+
model?: string;
|
|
948
|
+
};
|
|
946
949
|
};
|
|
947
950
|
/**
|
|
948
951
|
* Run-scoped WebFetch extraction/render configuration.
|