@mono-agent/agent-runtime 0.15.2 → 0.15.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 +55 -7
- package/package.json +5 -1
- package/src/agent/tools/agent-tool.js +859 -0
- package/src/agent/tools/bash.js +241 -123
- package/src/agent/tools/exec.js +238 -0
- package/src/agent/tools/index.js +10 -3
- package/src/agent/tools/node-repl.js +231 -95
- package/src/agent/tools/pi-bridge.js +115 -24
- package/src/agent/tools/shared/process-runner.js +162 -0
- package/src/agent/tools/shared/semaphore.js +73 -0
- package/src/agent/tools/web-browser-render.js +221 -0
- package/src/agent/tools/web-controller.js +160 -0
- package/src/agent/tools/web-fetch.js +653 -68
- package/src/agent/tools/web-search.js +568 -16
- package/src/ai/providers/codex-app.js +18 -0
- package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
- package/src/ai/providers/pi-native/turn-runner.js +60 -5
- package/src/ai/providers/pi-native.js +49 -5
- package/src/ai/runtime/router.js +302 -166
- package/src/ai/types.js +52 -1
- package/src/runtime.js +51 -1
- package/types/agent/tools/agent-tool.d.ts +60 -0
- package/types/agent/tools/bash.d.ts +55 -7
- package/types/agent/tools/exec.d.ts +53 -0
- package/types/agent/tools/index.d.ts +5 -3
- package/types/agent/tools/node-repl.d.ts +28 -3
- package/types/agent/tools/pi-bridge.d.ts +6 -2
- package/types/agent/tools/shared/process-runner.d.ts +33 -0
- package/types/agent/tools/shared/semaphore.d.ts +29 -0
- package/types/agent/tools/web-browser-render.d.ts +16 -0
- package/types/agent/tools/web-controller.d.ts +20 -0
- package/types/agent/tools/web-fetch.d.ts +74 -5
- package/types/agent/tools/web-search.d.ts +81 -5
- package/types/ai/backend.d.ts +57 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +34 -2
- package/types/ai/providers/pi-native.d.ts +12 -0
- package/types/ai/registry.d.ts +1 -0
- package/types/ai/runtime/router.d.ts +23 -3
- package/types/ai/types.d.ts +163 -1
|
@@ -1,28 +1,580 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { parseHTML } from "linkedom";
|
|
1
4
|
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
2
5
|
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
3
6
|
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
4
7
|
|
|
8
|
+
const SEARCH_TIMEOUT_MS = 15_000;
|
|
9
|
+
const SEARCH_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const RRF_K = 60;
|
|
11
|
+
const TRACKING_PARAMETERS = new Set([
|
|
12
|
+
"dclid",
|
|
13
|
+
"fbclid",
|
|
14
|
+
"gclid",
|
|
15
|
+
"igshid",
|
|
16
|
+
"mc_cid",
|
|
17
|
+
"mc_eid",
|
|
18
|
+
"mkt_tok",
|
|
19
|
+
"msclkid",
|
|
20
|
+
"ref_src",
|
|
21
|
+
"s_cid",
|
|
22
|
+
"vero_conv",
|
|
23
|
+
"vero_id",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compatibility wrapper for direct callers.
|
|
28
|
+
*
|
|
29
|
+
* @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
|
|
30
|
+
* @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
|
|
31
|
+
*/
|
|
32
|
+
export async function webSearchToolImpl(params, options = {}) {
|
|
33
|
+
return (await performWebSearch(params, options)).text;
|
|
34
|
+
}
|
|
35
|
+
|
|
5
36
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
37
|
+
* Search through an operator-owned SearXNG endpoint and/or the keyless HTML
|
|
38
|
+
* fallback chain. Returns a structured internal outcome for the Pi bridge.
|
|
39
|
+
*
|
|
40
|
+
* @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
|
|
41
|
+
* @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
|
|
8
42
|
*/
|
|
9
|
-
export async function
|
|
10
|
-
|
|
11
|
-
|
|
43
|
+
export async function performWebSearch(
|
|
44
|
+
{
|
|
45
|
+
query,
|
|
46
|
+
limit = 5,
|
|
47
|
+
alternate_queries = [],
|
|
48
|
+
domains = [],
|
|
49
|
+
exclude_domains = [],
|
|
50
|
+
language,
|
|
51
|
+
time_range,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
sandboxPolicy,
|
|
55
|
+
ctx,
|
|
56
|
+
signal,
|
|
57
|
+
searchConfig,
|
|
58
|
+
fetchImpl = globalThis.fetch,
|
|
59
|
+
} = {},
|
|
60
|
+
) {
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
const normalizedQuery = typeof query === "string" ? query.trim() : "";
|
|
63
|
+
if (!normalizedQuery) {
|
|
64
|
+
return failure("Error: WebSearch query must not be empty.", "invalid_query", startedAt);
|
|
65
|
+
}
|
|
66
|
+
const max = clampInteger(limit, 1, 10, 5);
|
|
67
|
+
const includeDomains = normalizeDomains(domains);
|
|
68
|
+
const excludeDomains = normalizeDomains(exclude_domains);
|
|
69
|
+
const config = normalizeSearchConfig(searchConfig);
|
|
70
|
+
if (config.error) return failure(`Error: ${config.error}`, "invalid_search_config", startedAt);
|
|
71
|
+
|
|
12
72
|
const resolvedCtx = ctx ?? readToolRuntime();
|
|
13
73
|
const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
74
|
+
const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
|
|
75
|
+
const initialQueries = uniqueStrings([normalizedQuery, ...alternate_queries], 4);
|
|
76
|
+
/** @type {Array<Array<{title: string, url: string, snippet: string, backend: string}>>} */
|
|
77
|
+
const rankedLists = [];
|
|
78
|
+
const providerFailures = [];
|
|
79
|
+
const providersUsed = new Set();
|
|
80
|
+
let attempts = 0;
|
|
81
|
+
let anyProviderSucceeded = false;
|
|
82
|
+
|
|
83
|
+
const runQuery = async (candidate) => {
|
|
84
|
+
attempts += 1;
|
|
85
|
+
return await searchOneQuery(
|
|
86
|
+
queryWithDomains(candidate, includeDomains),
|
|
87
|
+
{
|
|
88
|
+
config,
|
|
89
|
+
language,
|
|
90
|
+
timeRange: time_range,
|
|
91
|
+
sandbox,
|
|
92
|
+
policy,
|
|
93
|
+
signal,
|
|
94
|
+
fetchImpl,
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
const recordResult = (result) => {
|
|
99
|
+
if (result.ok) {
|
|
100
|
+
anyProviderSucceeded = true;
|
|
101
|
+
providersUsed.add(result.backend);
|
|
102
|
+
rankedLists.push(filterByDomains(result.results, includeDomains, excludeDomains));
|
|
103
|
+
} else {
|
|
104
|
+
providerFailures.push(result);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const initialResults = await Promise.all(initialQueries.map(runQuery));
|
|
109
|
+
initialResults.forEach(recordResult);
|
|
110
|
+
if (signal?.aborted) {
|
|
111
|
+
return failure("Error: WebSearch was aborted.", "aborted", startedAt, {
|
|
112
|
+
attempts,
|
|
113
|
+
retryable: false,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let merged = mergeRankedResults(rankedLists, max);
|
|
118
|
+
if (merged.length < max && initialQueries.length < 4) {
|
|
119
|
+
const relaxed = relaxedQuery(normalizedQuery);
|
|
120
|
+
if (relaxed && !initialQueries.includes(relaxed)) {
|
|
121
|
+
recordResult(await runQuery(relaxed));
|
|
122
|
+
merged = mergeRankedResults(rankedLists, max);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!anyProviderSucceeded) {
|
|
127
|
+
const reason = providerFailures.map((entry) => entry.message).filter(Boolean).join("; ")
|
|
128
|
+
|| "No search backend was available.";
|
|
129
|
+
const networkDenied = providerFailures.length > 0
|
|
130
|
+
&& providerFailures.every((entry) => entry.message === "Network access denied by sandbox policy.");
|
|
131
|
+
return failure(networkDenied
|
|
132
|
+
? "Error: Network access denied by sandbox policy."
|
|
133
|
+
: `Error: WebSearch failed: ${reason}`, networkDenied ? "network_denied" : "backend_unavailable", startedAt, {
|
|
134
|
+
attempts,
|
|
135
|
+
backend: config.backend,
|
|
136
|
+
retryable: providerFailures.some((entry) => entry.retryable),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const backend = providersUsed.size === 1 ? [...providersUsed][0] : "mixed";
|
|
141
|
+
const body = merged.length === 0
|
|
142
|
+
? "No results."
|
|
143
|
+
: merged.map((result, index) => {
|
|
144
|
+
const snippet = result.snippet ? `\n ${collapseWhitespace(result.snippet)}` : "";
|
|
145
|
+
return `${index + 1}. [${escapeMarkdownLabel(result.title || result.url)}](${result.url})${snippet}`;
|
|
146
|
+
}).join("\n\n");
|
|
147
|
+
const text = [
|
|
148
|
+
"[BEGIN UNTRUSTED WEB SEARCH RESULTS]",
|
|
149
|
+
body,
|
|
150
|
+
"[END UNTRUSTED WEB SEARCH RESULTS]",
|
|
151
|
+
].join("\n");
|
|
152
|
+
return {
|
|
153
|
+
text,
|
|
154
|
+
outcome: {
|
|
155
|
+
status: "ok",
|
|
156
|
+
code: merged.length === 0 ? "no_results" : "ok",
|
|
157
|
+
retryable: false,
|
|
158
|
+
attempts,
|
|
159
|
+
backend,
|
|
160
|
+
cacheHit: false,
|
|
161
|
+
durationMs: Date.now() - startedAt,
|
|
162
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
163
|
+
truncated: false,
|
|
164
|
+
resultCount: merged.length,
|
|
165
|
+
providerFailureCount: providerFailures.length,
|
|
166
|
+
},
|
|
167
|
+
error: false,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function searchOneQuery(query, options) {
|
|
172
|
+
const { config } = options;
|
|
173
|
+
const failures = [];
|
|
174
|
+
if (options.signal?.aborted) return abortedSearch(config.backend);
|
|
175
|
+
if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
|
|
176
|
+
const result = await searchSearxng(query, options);
|
|
177
|
+
if (result.ok || config.backend === "searxng") return result;
|
|
178
|
+
failures.push(result);
|
|
179
|
+
if (options.signal?.aborted) return abortedSearch(result.backend);
|
|
180
|
+
}
|
|
181
|
+
if (config.backend === "keyless" || config.backend === "auto") {
|
|
182
|
+
const duck = await searchDuckDuckGo(query, options);
|
|
183
|
+
if (duck.ok && duck.results.length > 0) return duck;
|
|
184
|
+
if (!duck.ok) failures.push(duck);
|
|
185
|
+
if (options.signal?.aborted) return abortedSearch(duck.backend);
|
|
186
|
+
const startpage = await searchStartpage(query, options);
|
|
187
|
+
if (startpage.ok) return startpage;
|
|
188
|
+
failures.push(startpage);
|
|
189
|
+
if (duck.ok) return duck;
|
|
190
|
+
}
|
|
191
|
+
return failures[failures.length - 1] || {
|
|
192
|
+
ok: false,
|
|
193
|
+
backend: config.backend,
|
|
194
|
+
message: "No configured search backend.",
|
|
195
|
+
retryable: false,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function abortedSearch(backend) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
backend,
|
|
203
|
+
message: "WebSearch was aborted.",
|
|
204
|
+
retryable: false,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function searchSearxng(query, options) {
|
|
209
|
+
const endpoint = options.config.endpoint;
|
|
210
|
+
if (!endpoint) {
|
|
211
|
+
return { ok: false, backend: "searxng", message: "SearXNG endpoint is not configured.", retryable: false };
|
|
212
|
+
}
|
|
213
|
+
const url = `${endpoint}/search`;
|
|
214
|
+
if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
|
|
215
|
+
return { ok: false, backend: "searxng", message: "Network access denied by sandbox policy.", retryable: false };
|
|
216
|
+
}
|
|
217
|
+
const body = new URLSearchParams({ q: query, format: "json", categories: "general" });
|
|
218
|
+
if (typeof options.language === "string" && options.language.trim()) {
|
|
219
|
+
body.set("language", options.language.trim());
|
|
220
|
+
}
|
|
221
|
+
if (["day", "month", "year"].includes(options.timeRange)) {
|
|
222
|
+
body.set("time_range", options.timeRange);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const response = await options.fetchImpl(url, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
Accept: "application/json",
|
|
229
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
230
|
+
"User-Agent": "mono-agent-web/1",
|
|
231
|
+
},
|
|
232
|
+
body,
|
|
233
|
+
signal: requestSignal(options.signal),
|
|
234
|
+
redirect: "error",
|
|
235
|
+
});
|
|
236
|
+
const text = await readLimitedText(response);
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
backend: "searxng",
|
|
241
|
+
message: `SearXNG HTTP ${response.status}`,
|
|
242
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
let data;
|
|
246
|
+
try { data = JSON.parse(text); } catch {
|
|
247
|
+
return { ok: false, backend: "searxng", message: "SearXNG returned invalid JSON.", retryable: false };
|
|
248
|
+
}
|
|
249
|
+
const results = Array.isArray(data?.results)
|
|
250
|
+
? data.results.flatMap((entry) => normalizedResult(entry, "searxng"))
|
|
251
|
+
: [];
|
|
252
|
+
return { ok: true, backend: "searxng", results };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return fetchFailure("searxng", error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function searchDuckDuckGo(query, options) {
|
|
259
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
260
|
+
if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
|
|
261
|
+
return { ok: false, backend: "duckduckgo", message: "Network access denied by sandbox policy.", retryable: false };
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const response = await options.fetchImpl(url, {
|
|
265
|
+
headers: {
|
|
266
|
+
Accept: "text/html,application/xhtml+xml",
|
|
267
|
+
"Accept-Language": options.language || "en-US,en;q=0.8",
|
|
268
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
|
|
269
|
+
},
|
|
270
|
+
signal: requestSignal(options.signal),
|
|
271
|
+
redirect: "error",
|
|
272
|
+
});
|
|
273
|
+
const html = await readLimitedText(response);
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
backend: "duckduckgo",
|
|
278
|
+
message: `DuckDuckGo HTTP ${response.status}`,
|
|
279
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, backend: "duckduckgo", results: parseDuckDuckGoResults(html) };
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return fetchFailure("duckduckgo", error);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function searchStartpage(query, options) {
|
|
289
|
+
const url = `https://www.startpage.com/sp/search?query=${encodeURIComponent(query)}`;
|
|
290
|
+
if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
|
|
291
|
+
return { ok: false, backend: "startpage", message: "Network access denied by sandbox policy.", retryable: false };
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const response = await options.fetchImpl(url, {
|
|
295
|
+
headers: {
|
|
296
|
+
Accept: "text/html,application/xhtml+xml",
|
|
297
|
+
"Accept-Language": options.language || "en-US,en;q=0.8",
|
|
298
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
|
|
299
|
+
},
|
|
300
|
+
signal: requestSignal(options.signal),
|
|
301
|
+
redirect: "error",
|
|
302
|
+
});
|
|
303
|
+
const html = await readLimitedText(response);
|
|
304
|
+
if (!response.ok) {
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
backend: "startpage",
|
|
308
|
+
message: `Startpage HTTP ${response.status}`,
|
|
309
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return { ok: true, backend: "startpage", results: parseStartpageResults(html) };
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return fetchFailure("startpage", error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function parseDuckDuckGoResults(html) {
|
|
319
|
+
const { document } = parseHTML(String(html || ""));
|
|
320
|
+
const rows = [...document.querySelectorAll(".result")];
|
|
321
|
+
return rows.flatMap((row) => {
|
|
322
|
+
const link = row.querySelector("a.result__a");
|
|
323
|
+
if (!link) return [];
|
|
324
|
+
const url = canonicalizeSearchUrl(link.getAttribute("href"), "https://html.duckduckgo.com/");
|
|
325
|
+
if (!url) return [];
|
|
326
|
+
return [{
|
|
327
|
+
title: collapseWhitespace(link.textContent),
|
|
328
|
+
url,
|
|
329
|
+
snippet: collapseWhitespace(row.querySelector(".result__snippet")?.textContent),
|
|
330
|
+
backend: "duckduckgo",
|
|
331
|
+
}];
|
|
18
332
|
});
|
|
19
|
-
|
|
20
|
-
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function parseStartpageResults(html) {
|
|
336
|
+
const { document } = parseHTML(String(html || ""));
|
|
337
|
+
const selectors = [".w-gl__result", ".result", "article"];
|
|
338
|
+
const rows = selectors.flatMap((selector) => [...document.querySelectorAll(selector)]);
|
|
339
|
+
const seen = new Set();
|
|
21
340
|
const results = [];
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
341
|
+
for (const row of rows) {
|
|
342
|
+
const link = row.querySelector("a.w-gl__result-title, a.result-link, h2 a, h3 a");
|
|
343
|
+
if (!link) continue;
|
|
344
|
+
const url = canonicalizeSearchUrl(link.getAttribute("href"), "https://www.startpage.com/");
|
|
345
|
+
if (!url || seen.has(url)) continue;
|
|
346
|
+
seen.add(url);
|
|
347
|
+
results.push({
|
|
348
|
+
title: collapseWhitespace(link.textContent),
|
|
349
|
+
url,
|
|
350
|
+
snippet: collapseWhitespace(
|
|
351
|
+
row.querySelector(".w-gl__description, .result-description, p")?.textContent,
|
|
352
|
+
),
|
|
353
|
+
backend: "startpage",
|
|
354
|
+
});
|
|
26
355
|
}
|
|
27
|
-
return results
|
|
356
|
+
return results;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizedResult(entry, backend) {
|
|
360
|
+
if (!entry || typeof entry !== "object") return [];
|
|
361
|
+
const url = canonicalizeSearchUrl(entry.url);
|
|
362
|
+
if (!url) return [];
|
|
363
|
+
return [{
|
|
364
|
+
title: collapseWhitespace(entry.title) || url,
|
|
365
|
+
url,
|
|
366
|
+
snippet: collapseWhitespace(entry.content || entry.snippet),
|
|
367
|
+
backend,
|
|
368
|
+
}];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function canonicalizeSearchUrl(value, base) {
|
|
372
|
+
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
373
|
+
let parsed;
|
|
374
|
+
try { parsed = new URL(value, base); } catch { return null; }
|
|
375
|
+
const wrapped = ["uddg", "url", "u", "target"].map((key) => parsed.searchParams.get(key)).find(Boolean);
|
|
376
|
+
if (wrapped && (
|
|
377
|
+
parsed.hostname.endsWith("duckduckgo.com")
|
|
378
|
+
|| parsed.hostname.endsWith("startpage.com")
|
|
379
|
+
)) {
|
|
380
|
+
try { parsed = new URL(wrapped); } catch { /* keep the wrapper URL */ }
|
|
381
|
+
}
|
|
382
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
383
|
+
parsed.username = "";
|
|
384
|
+
parsed.password = "";
|
|
385
|
+
parsed.hash = "";
|
|
386
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
387
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
388
|
+
if (key.toLowerCase().startsWith("utm_") || TRACKING_PARAMETERS.has(key.toLowerCase())) {
|
|
389
|
+
parsed.searchParams.delete(key);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
parsed.searchParams.sort();
|
|
393
|
+
if ((parsed.protocol === "https:" && parsed.port === "443") || (parsed.protocol === "http:" && parsed.port === "80")) {
|
|
394
|
+
parsed.port = "";
|
|
395
|
+
}
|
|
396
|
+
if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
|
|
397
|
+
return parsed.href;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function mergeRankedResults(rankedLists, limit = 10) {
|
|
401
|
+
const merged = new Map();
|
|
402
|
+
for (const list of rankedLists) {
|
|
403
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
404
|
+
const result = list[index];
|
|
405
|
+
const url = canonicalizeSearchUrl(result.url);
|
|
406
|
+
if (!url) continue;
|
|
407
|
+
const existing = merged.get(url);
|
|
408
|
+
const score = 1 / (RRF_K + index + 1);
|
|
409
|
+
if (existing) {
|
|
410
|
+
existing.score += score;
|
|
411
|
+
if (!existing.snippet && result.snippet) existing.snippet = result.snippet;
|
|
412
|
+
} else {
|
|
413
|
+
merged.set(url, { ...result, url, score });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return [...merged.values()]
|
|
418
|
+
.sort((left, right) => right.score - left.score || left.url.localeCompare(right.url))
|
|
419
|
+
.slice(0, clampInteger(limit, 1, 10, 10))
|
|
420
|
+
.map(({ score: _score, ...result }) => result);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function normalizeSearchConfig(input) {
|
|
424
|
+
const backend = input?.backend ?? "auto";
|
|
425
|
+
if (!["auto", "searxng", "keyless"].includes(backend)) {
|
|
426
|
+
return { error: "Web search backend must be auto, searxng, or keyless." };
|
|
427
|
+
}
|
|
428
|
+
let endpoint;
|
|
429
|
+
if (input?.endpoint !== undefined && String(input.endpoint).trim()) {
|
|
430
|
+
try {
|
|
431
|
+
const parsed = new URL(String(input.endpoint));
|
|
432
|
+
if (parsed.protocol !== "http:" || !isLoopbackHost(parsed.hostname) || parsed.username || parsed.password) {
|
|
433
|
+
return { error: "SearXNG endpoint must be an unauthenticated loopback http URL." };
|
|
434
|
+
}
|
|
435
|
+
if (parsed.search || parsed.hash) {
|
|
436
|
+
return { error: "SearXNG endpoint must not contain a query string or fragment." };
|
|
437
|
+
}
|
|
438
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
|
|
439
|
+
endpoint = parsed.href.replace(/\/+$/u, "");
|
|
440
|
+
} catch {
|
|
441
|
+
return { error: "SearXNG endpoint must be a valid loopback http URL." };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (backend === "searxng" && !endpoint) {
|
|
445
|
+
return { error: "SearXNG backend requires tools.web.search.endpoint." };
|
|
446
|
+
}
|
|
447
|
+
return { backend, endpoint };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isLoopbackHost(hostname) {
|
|
451
|
+
const value = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
|
|
452
|
+
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function normalizeDomains(values) {
|
|
456
|
+
if (!Array.isArray(values)) return [];
|
|
457
|
+
const out = [];
|
|
458
|
+
for (const value of values) {
|
|
459
|
+
if (typeof value !== "string") continue;
|
|
460
|
+
const normalized = value.trim().toLowerCase().replace(/^\*\./u, "").replace(/\.$/u, "");
|
|
461
|
+
if (!/^[a-z0-9.-]+$/u.test(normalized) || normalized.includes("..")) continue;
|
|
462
|
+
if (!out.includes(normalized)) out.push(normalized);
|
|
463
|
+
if (out.length >= 10) break;
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function filterByDomains(results, include, exclude) {
|
|
469
|
+
return results.filter((result) => {
|
|
470
|
+
let host;
|
|
471
|
+
try { host = new URL(result.url).hostname.toLowerCase(); } catch { return false; }
|
|
472
|
+
if (exclude.some((domain) => domainMatches(host, domain))) return false;
|
|
473
|
+
return include.length === 0 || include.some((domain) => domainMatches(host, domain));
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function domainMatches(host, domain) {
|
|
478
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function queryWithDomains(query, domains) {
|
|
482
|
+
if (domains.length === 0) return query;
|
|
483
|
+
return `${query} (${domains.map((domain) => `site:${domain}`).join(" OR ")})`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function relaxedQuery(query) {
|
|
487
|
+
const relaxed = query
|
|
488
|
+
.replace(/"([^"]+)"/gu, "$1")
|
|
489
|
+
.replace(/\bsite:\S+/giu, " ")
|
|
490
|
+
.replace(/\s+/gu, " ")
|
|
491
|
+
.trim();
|
|
492
|
+
return relaxed && relaxed !== query ? relaxed : null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function uniqueStrings(values, limit) {
|
|
496
|
+
const out = [];
|
|
497
|
+
for (const value of Array.isArray(values) ? values : []) {
|
|
498
|
+
if (typeof value !== "string") continue;
|
|
499
|
+
const normalized = value.trim();
|
|
500
|
+
if (!normalized || out.includes(normalized)) continue;
|
|
501
|
+
out.push(normalized);
|
|
502
|
+
if (out.length >= limit) break;
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function collapseWhitespace(value) {
|
|
508
|
+
return String(value || "").replace(/\s+/gu, " ").trim();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function escapeMarkdownLabel(value) {
|
|
512
|
+
return collapseWhitespace(value).replace(/[[\]\\]/gu, "\\$&");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function requestSignal(signal) {
|
|
516
|
+
const timeout = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
|
|
517
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function readLimitedText(response) {
|
|
521
|
+
const reader = response.body?.getReader?.();
|
|
522
|
+
if (!reader) {
|
|
523
|
+
const text = await response.text();
|
|
524
|
+
if (Buffer.byteLength(text, "utf8") > SEARCH_RESPONSE_MAX_BYTES) {
|
|
525
|
+
throw new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`);
|
|
526
|
+
}
|
|
527
|
+
return text;
|
|
528
|
+
}
|
|
529
|
+
const chunks = [];
|
|
530
|
+
let bytes = 0;
|
|
531
|
+
while (true) {
|
|
532
|
+
const next = await reader.read();
|
|
533
|
+
if (next.done) break;
|
|
534
|
+
bytes += next.value.byteLength;
|
|
535
|
+
if (bytes > SEARCH_RESPONSE_MAX_BYTES) {
|
|
536
|
+
try { await reader.cancel(); } catch { /* best effort */ }
|
|
537
|
+
throw new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`);
|
|
538
|
+
}
|
|
539
|
+
chunks.push(Buffer.from(next.value));
|
|
540
|
+
}
|
|
541
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function fetchFailure(backend, error) {
|
|
545
|
+
const name = error?.name;
|
|
546
|
+
const retryable = name === "AbortError"
|
|
547
|
+
|| name === "TimeoutError"
|
|
548
|
+
|| ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code ?? error?.cause?.code);
|
|
549
|
+
return {
|
|
550
|
+
ok: false,
|
|
551
|
+
backend,
|
|
552
|
+
message: `${backend} request failed: ${error?.message || String(error)}`,
|
|
553
|
+
retryable,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function failure(text, code, startedAt, extra = {}) {
|
|
558
|
+
return {
|
|
559
|
+
text,
|
|
560
|
+
outcome: {
|
|
561
|
+
status: "error",
|
|
562
|
+
code,
|
|
563
|
+
retryable: false,
|
|
564
|
+
attempts: 0,
|
|
565
|
+
backend: "none",
|
|
566
|
+
cacheHit: false,
|
|
567
|
+
durationMs: Date.now() - startedAt,
|
|
568
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
569
|
+
truncated: false,
|
|
570
|
+
...extra,
|
|
571
|
+
},
|
|
572
|
+
error: true,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function clampInteger(value, min, max, fallback) {
|
|
577
|
+
const number = Number(value);
|
|
578
|
+
if (!Number.isFinite(number)) return fallback;
|
|
579
|
+
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
28
580
|
}
|
|
@@ -617,6 +617,19 @@ function codexMcpConfig(mcpServers = {}) {
|
|
|
617
617
|
return servers;
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
function codexConfiguredMcpApprovalResponse(request, configuredMcpServerNames) {
|
|
621
|
+
const params = request?.params;
|
|
622
|
+
if (
|
|
623
|
+
request?.method !== "mcpServer/elicitation/request"
|
|
624
|
+
|| params?._meta?.codex_approval_kind !== "mcp_tool_call"
|
|
625
|
+
|| typeof params?.serverName !== "string"
|
|
626
|
+
|| !configuredMcpServerNames.has(params.serverName)
|
|
627
|
+
) {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
return { action: "accept", content: {}, _meta: null };
|
|
631
|
+
}
|
|
632
|
+
|
|
620
633
|
function codexErrorMessage(error) {
|
|
621
634
|
if (!error) return "Codex app-server error";
|
|
622
635
|
if (typeof error === "string") return error;
|
|
@@ -1109,6 +1122,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
|
|
|
1109
1122
|
const makeClient = options.codexClientFactory || createCodexAppServerClient;
|
|
1110
1123
|
const keepAlive = options.sessionKeepAlive === true;
|
|
1111
1124
|
const noToolsProbe = options.codexNoToolsProbe === true;
|
|
1125
|
+
const configuredMcpServerNames = new Set(Object.keys(codexMcpConfig(options.mcpServers)));
|
|
1112
1126
|
// The bridge TTL is a backstop behind the host's session policy; the grace
|
|
1113
1127
|
// keeps the host's lazy expiry firing first so eviction stays host-driven.
|
|
1114
1128
|
const sessionTtlMs = Number.isFinite(Number(options.sessionIdleTimeoutMs))
|
|
@@ -1446,6 +1460,10 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
|
|
|
1446
1460
|
),
|
|
1447
1461
|
onServerRequest: (request) => {
|
|
1448
1462
|
const method = typeof request?.method === "string" ? request.method : "unknown";
|
|
1463
|
+
const approval = noToolsProbe
|
|
1464
|
+
? null
|
|
1465
|
+
: codexConfiguredMcpApprovalResponse(request, configuredMcpServerNames);
|
|
1466
|
+
if (approval) return approval;
|
|
1449
1467
|
failUnsupportedServerRequest(method);
|
|
1450
1468
|
throw new Error(`Unsupported Codex app-server request: ${method}`);
|
|
1451
1469
|
},
|
|
@@ -22,6 +22,41 @@ function toolResultFileChange(result) {
|
|
|
22
22
|
: null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function toolResultOutcome(result) {
|
|
26
|
+
const source = result?.details?.outcome;
|
|
27
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return null;
|
|
28
|
+
const bounded = {};
|
|
29
|
+
const strings = {
|
|
30
|
+
status: "status",
|
|
31
|
+
code: "code",
|
|
32
|
+
backend: "backend",
|
|
33
|
+
signal: "signal",
|
|
34
|
+
};
|
|
35
|
+
const numbers = {
|
|
36
|
+
attempts: "attempts",
|
|
37
|
+
bytes: "bytes",
|
|
38
|
+
exitCode: "exit_code",
|
|
39
|
+
statusCode: "status_code",
|
|
40
|
+
};
|
|
41
|
+
const booleans = {
|
|
42
|
+
retryable: "retryable",
|
|
43
|
+
cacheHit: "cache_hit",
|
|
44
|
+
truncated: "truncated",
|
|
45
|
+
timedOut: "timed_out",
|
|
46
|
+
rendered: "rendered",
|
|
47
|
+
};
|
|
48
|
+
for (const [input, output] of Object.entries(strings)) {
|
|
49
|
+
if (typeof source[input] === "string") bounded[output] = source[input].slice(0, 120);
|
|
50
|
+
}
|
|
51
|
+
for (const [input, output] of Object.entries(numbers)) {
|
|
52
|
+
if (Number.isFinite(Number(source[input]))) bounded[output] = Number(source[input]);
|
|
53
|
+
}
|
|
54
|
+
for (const [input, output] of Object.entries(booleans)) {
|
|
55
|
+
if (typeof source[input] === "boolean") bounded[output] = source[input];
|
|
56
|
+
}
|
|
57
|
+
return bounded;
|
|
58
|
+
}
|
|
59
|
+
|
|
25
60
|
/**
|
|
26
61
|
* The slice of run state the stream subscriber reads and mutates. A structural
|
|
27
62
|
* subset of the orchestrator's runState.
|
|
@@ -107,6 +142,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
107
142
|
} else if (event.type === "tool_execution_end") {
|
|
108
143
|
const resultContent = toolResultContent(event.result);
|
|
109
144
|
const fileChange = toolResultFileChange(event.result);
|
|
145
|
+
const outcome = toolResultOutcome(event.result);
|
|
110
146
|
if (!event.isError) runState.toolResultsSeen += 1;
|
|
111
147
|
const startedAt = runState.toolStartTimes.get(event.toolCallId);
|
|
112
148
|
if (startedAt !== undefined) {
|
|
@@ -117,6 +153,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
117
153
|
name: event.toolName,
|
|
118
154
|
execution_ms: Date.now() - startedAt,
|
|
119
155
|
is_error: !!event.isError,
|
|
156
|
+
...(outcome || {}),
|
|
120
157
|
});
|
|
121
158
|
}
|
|
122
159
|
onEvent({
|