min-agent 0.3.0 → 0.4.0
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 +111 -28
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/tools/web_search.js
CHANGED
|
@@ -1,78 +1,17 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import { truncateToolOutput } from "../tool-output.js";
|
|
3
2
|
import { readBodyLimited } from "../http.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
/** SearXNG is used when explicitly configured (webSearchURL / MIN_AGENT_SEARXNG_URL). */
|
|
11
|
-
export const SEARXNG_BACKEND = {
|
|
12
|
-
envName: "MIN_AGENT_SEARXNG_URL",
|
|
13
|
-
configKey: "webSearchURL",
|
|
14
|
-
fallback: "https://searxng.xc.lonae.com",
|
|
15
|
-
};
|
|
16
|
-
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
17
|
-
const SERPER_TIMEOUT_MS = 35000;
|
|
18
|
-
const SEARCH_TIMEOUT_MS = 20000;
|
|
3
|
+
import { describeFetchError } from "./backend.js";
|
|
4
|
+
import { getEffectiveSandboxPolicy, networkDeniedMessage } from "../sandbox.js";
|
|
5
|
+
import { runSerper } from "./search-serper.js";
|
|
6
|
+
import { runSearxng, searxngConfigured } from "./search-searxng.js";
|
|
7
|
+
export { SEARXNG_BACKEND } from "./search-searxng.js";
|
|
8
|
+
export const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
19
9
|
const DEFAULT_MAX_RESULTS = 10;
|
|
20
10
|
const MAX_RESULTS_LIMIT = 30;
|
|
21
11
|
const SNIPPET_MAX_CHARS = 300;
|
|
22
|
-
const
|
|
23
|
-
/** SearXNG's built-in category names. */
|
|
24
|
-
const VALID_CATEGORIES = new Set([
|
|
25
|
-
"general",
|
|
26
|
-
"images",
|
|
27
|
-
"videos",
|
|
28
|
-
"news",
|
|
29
|
-
"map",
|
|
30
|
-
"music",
|
|
31
|
-
"it",
|
|
32
|
-
"science",
|
|
33
|
-
"files",
|
|
34
|
-
"social media",
|
|
35
|
-
]);
|
|
36
|
-
/** Common near-misses models produce, mapped onto real category names. */
|
|
37
|
-
const CATEGORY_ALIASES = {
|
|
38
|
-
web: "general",
|
|
39
|
-
text: "general",
|
|
40
|
-
default: "general",
|
|
41
|
-
image: "images",
|
|
42
|
-
picture: "images",
|
|
43
|
-
pictures: "images",
|
|
44
|
-
photo: "images",
|
|
45
|
-
photos: "images",
|
|
46
|
-
video: "videos",
|
|
47
|
-
movie: "videos",
|
|
48
|
-
movies: "videos",
|
|
49
|
-
maps: "map",
|
|
50
|
-
social: "social media",
|
|
51
|
-
socialmedia: "social media",
|
|
52
|
-
social_media: "social media",
|
|
53
|
-
"social-media": "social media",
|
|
54
|
-
tech: "it",
|
|
55
|
-
technology: "it",
|
|
56
|
-
code: "it",
|
|
57
|
-
programming: "it",
|
|
58
|
-
file: "files",
|
|
59
|
-
torrent: "files",
|
|
60
|
-
torrents: "files",
|
|
61
|
-
paper: "science",
|
|
62
|
-
papers: "science",
|
|
63
|
-
academic: "science",
|
|
64
|
-
scholar: "science",
|
|
65
|
-
};
|
|
66
|
-
const VALID_TIME_RANGES = new Set(["day", "week", "month", "year"]);
|
|
67
|
-
/** Google's tbs time filter for each supported time_range value. */
|
|
68
|
-
const TBS_BY_RANGE = {
|
|
69
|
-
day: "qdr:d",
|
|
70
|
-
week: "qdr:w",
|
|
71
|
-
month: "qdr:m",
|
|
72
|
-
year: "qdr:y",
|
|
73
|
-
};
|
|
12
|
+
export const VALID_TIME_RANGES = new Set(["day", "week", "month", "year"]);
|
|
74
13
|
/** ISO 639-1/2 with an optional region subtag, or SearXNG's "all". */
|
|
75
|
-
const LANGUAGE_RE = /^(all|[a-z]{2,3}([-_][a-zA-Z]{2,4})?)$/;
|
|
14
|
+
export const LANGUAGE_RE = /^(all|[a-z]{2,3}([-_][a-zA-Z]{2,4})?)$/;
|
|
76
15
|
const EXACT_TRACKING_PARAMS = new Set([
|
|
77
16
|
"ref",
|
|
78
17
|
"ref_src",
|
|
@@ -86,296 +25,19 @@ const EXACT_TRACKING_PARAMS = new Set([
|
|
|
86
25
|
"spm",
|
|
87
26
|
"source",
|
|
88
27
|
]);
|
|
89
|
-
|
|
90
|
-
results: [],
|
|
91
|
-
answers: [],
|
|
92
|
-
suggestions: [],
|
|
93
|
-
corrections: [],
|
|
94
|
-
infoboxes: [],
|
|
95
|
-
unresponsive_engines: [],
|
|
96
|
-
});
|
|
97
|
-
function isString(v) {
|
|
28
|
+
export function isString(v) {
|
|
98
29
|
return typeof v === "string";
|
|
99
30
|
}
|
|
100
|
-
function
|
|
101
|
-
if (typeof r !== "object" || r === null)
|
|
102
|
-
return null;
|
|
103
|
-
const o = r;
|
|
104
|
-
if (!isString(o.url))
|
|
105
|
-
return null;
|
|
106
|
-
return {
|
|
107
|
-
title: isString(o.title) ? o.title : "",
|
|
108
|
-
url: o.url,
|
|
109
|
-
content: isString(o.content) ? o.content : "",
|
|
110
|
-
engine: isString(o.engine) ? o.engine : "",
|
|
111
|
-
score: typeof o.score === "number" ? o.score : 0,
|
|
112
|
-
publishedDate: isString(o.publishedDate) ? o.publishedDate : "",
|
|
113
|
-
imgSrc: isString(o.img_src) ? o.img_src : isString(o.thumbnail) ? o.thumbnail : "",
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
function parseResponse(data) {
|
|
117
|
-
if (typeof data !== "object" || data === null)
|
|
118
|
-
return emptyResponse();
|
|
119
|
-
const o = data;
|
|
120
|
-
const results = (Array.isArray(o.results) ? o.results : [])
|
|
121
|
-
.map(asResult)
|
|
122
|
-
.filter((r) => r !== null);
|
|
123
|
-
// Some engines return answers as objects ({ answer, url }) rather than plain strings.
|
|
124
|
-
const answers = (Array.isArray(o.answers) ? o.answers : [])
|
|
125
|
-
.map((a) => {
|
|
126
|
-
if (isString(a))
|
|
127
|
-
return a;
|
|
128
|
-
if (typeof a === "object" && a !== null) {
|
|
129
|
-
const rec = a;
|
|
130
|
-
if (isString(rec.answer))
|
|
131
|
-
return rec.answer;
|
|
132
|
-
if (isString(rec.content))
|
|
133
|
-
return rec.content;
|
|
134
|
-
}
|
|
135
|
-
return "";
|
|
136
|
-
})
|
|
137
|
-
.filter((a) => a !== "");
|
|
138
|
-
const suggestions = Array.isArray(o.suggestions) ? o.suggestions.filter(isString) : [];
|
|
139
|
-
const corrections = Array.isArray(o.corrections) ? o.corrections.filter(isString) : [];
|
|
140
|
-
const infoboxes = (Array.isArray(o.infoboxes) ? o.infoboxes : [])
|
|
141
|
-
.map((b) => {
|
|
142
|
-
if (typeof b !== "object" || b === null)
|
|
143
|
-
return null;
|
|
144
|
-
const box = b;
|
|
145
|
-
return {
|
|
146
|
-
infobox: isString(box.infobox) ? box.infobox : "",
|
|
147
|
-
content: isString(box.content) ? box.content : "",
|
|
148
|
-
};
|
|
149
|
-
})
|
|
150
|
-
.filter((b) => b !== null && (b.infobox !== "" || b.content !== ""));
|
|
151
|
-
const unresponsive = (Array.isArray(o.unresponsive_engines) ? o.unresponsive_engines : [])
|
|
152
|
-
.map((e) => {
|
|
153
|
-
if (!Array.isArray(e) || !isString(e[0]))
|
|
154
|
-
return null;
|
|
155
|
-
return [e[0], isString(e[1]) ? e[1] : ""];
|
|
156
|
-
})
|
|
157
|
-
.filter((e) => e !== null);
|
|
158
|
-
return { results, answers, suggestions, corrections, infoboxes, unresponsive_engines: unresponsive };
|
|
159
|
-
}
|
|
160
|
-
/** SearXNG reports parameter problems as {"error": "..."} — sometimes with HTTP 200. */
|
|
161
|
-
function backendErrorMessage(data) {
|
|
162
|
-
if (typeof data !== "object" || data === null)
|
|
163
|
-
return "";
|
|
164
|
-
const o = data;
|
|
165
|
-
for (const key of ["error", "detail", "message"]) {
|
|
166
|
-
const v = o[key];
|
|
167
|
-
if (isString(v) && v.trim())
|
|
168
|
-
return collapse(v);
|
|
169
|
-
if (typeof v === "object" && v !== null) {
|
|
170
|
-
const nested = v.message;
|
|
171
|
-
if (isString(nested) && nested.trim())
|
|
172
|
-
return collapse(nested);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return "";
|
|
176
|
-
}
|
|
177
|
-
function serperKey() {
|
|
178
|
-
const env = process.env[SERPER_KEY_ENV];
|
|
179
|
-
if (env && env.trim())
|
|
180
|
-
return env.trim();
|
|
181
|
-
const configured = loadConfig().serperApiKey;
|
|
182
|
-
if (configured && configured.trim())
|
|
183
|
-
return configured.trim();
|
|
184
|
-
return DEFAULT_SERPER_API_KEY;
|
|
185
|
-
}
|
|
186
|
-
/** True when the built-in shared key is in use (no env var, no config key). */
|
|
187
|
-
function usingBuiltInSerperKey() {
|
|
188
|
-
const env = process.env[SERPER_KEY_ENV];
|
|
189
|
-
if (env && env.trim())
|
|
190
|
-
return false;
|
|
191
|
-
const configured = loadConfig().serperApiKey;
|
|
192
|
-
return !(configured && configured.trim());
|
|
193
|
-
}
|
|
194
|
-
function serperHint() {
|
|
195
|
-
return `Override the built-in key via "serperApiKey" in ~/.min-agent/config.json or the ${SERPER_KEY_ENV} env var.`;
|
|
196
|
-
}
|
|
197
|
-
function asSerperResult(r) {
|
|
198
|
-
if (typeof r !== "object" || r === null)
|
|
199
|
-
return null;
|
|
200
|
-
const o = r;
|
|
201
|
-
if (!isString(o.link) || o.link.trim() === "")
|
|
202
|
-
return null;
|
|
203
|
-
return {
|
|
204
|
-
title: isString(o.title) ? o.title : "",
|
|
205
|
-
url: o.link,
|
|
206
|
-
content: isString(o.snippet) ? o.snippet : "",
|
|
207
|
-
engine: hostOf(o.link),
|
|
208
|
-
score: 0,
|
|
209
|
-
publishedDate: isString(o.date) ? o.date : "",
|
|
210
|
-
imgSrc: "",
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
function emptySerper() {
|
|
214
|
-
return { ...emptyResponse(), questions: [] };
|
|
215
|
-
}
|
|
216
|
-
function stringList(v, key) {
|
|
217
|
-
return (Array.isArray(v) ? v : [])
|
|
218
|
-
.map((item) => {
|
|
219
|
-
if (typeof item === "object" && item !== null) {
|
|
220
|
-
const rec = item;
|
|
221
|
-
if (isString(rec[key]))
|
|
222
|
-
return rec[key];
|
|
223
|
-
}
|
|
224
|
-
return "";
|
|
225
|
-
})
|
|
226
|
-
.filter((s) => s.trim() !== "");
|
|
227
|
-
}
|
|
228
|
-
function parseSerper(data) {
|
|
229
|
-
if (typeof data !== "object" || data === null)
|
|
230
|
-
return emptySerper();
|
|
231
|
-
const o = data;
|
|
232
|
-
const results = (Array.isArray(o.organic) ? o.organic : [])
|
|
233
|
-
.map(asSerperResult)
|
|
234
|
-
.filter((r) => r !== null);
|
|
235
|
-
const answers = [];
|
|
236
|
-
const ab = o.answerBox;
|
|
237
|
-
if (typeof ab === "object" && ab !== null) {
|
|
238
|
-
const rec = ab;
|
|
239
|
-
for (const key of ["answer", "snippet", "title"]) {
|
|
240
|
-
if (isString(rec[key]) && rec[key].trim()) {
|
|
241
|
-
answers.push(collapse(rec[key]));
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
const suggestions = stringList(o.relatedSearches, "query");
|
|
247
|
-
const questions = stringList(o.peopleAlsoAsk, "question");
|
|
248
|
-
const infoboxes = [];
|
|
249
|
-
const kg = o.knowledgeGraph;
|
|
250
|
-
if (typeof kg === "object" && kg !== null) {
|
|
251
|
-
const rec = kg;
|
|
252
|
-
const title = isString(rec.title) ? rec.title : "";
|
|
253
|
-
const description = isString(rec.description) ? rec.description : "";
|
|
254
|
-
if (title || description)
|
|
255
|
-
infoboxes.push({ infobox: title, content: description });
|
|
256
|
-
}
|
|
257
|
-
return { results, answers, suggestions, corrections: [], infoboxes, unresponsive_engines: [], questions };
|
|
258
|
-
}
|
|
259
|
-
/** Serper reports problems as {"message": "...", "statusCode": N}. */
|
|
260
|
-
function serperErrorMessage(data) {
|
|
261
|
-
return backendErrorMessage(data);
|
|
262
|
-
}
|
|
263
|
-
async function serperSearch(payload) {
|
|
264
|
-
let response;
|
|
265
|
-
try {
|
|
266
|
-
response = await fetch(SERPER_API, {
|
|
267
|
-
method: "POST",
|
|
268
|
-
headers: {
|
|
269
|
-
"X-API-KEY": serperKey(),
|
|
270
|
-
"Content-Type": "application/json",
|
|
271
|
-
Accept: "application/json",
|
|
272
|
-
},
|
|
273
|
-
body: JSON.stringify(payload),
|
|
274
|
-
signal: AbortSignal.timeout(SERPER_TIMEOUT_MS),
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
catch (err) {
|
|
278
|
-
return { kind: "network", message: describeFetchError(err) };
|
|
279
|
-
}
|
|
280
|
-
let text;
|
|
281
|
-
let bodyTooLarge;
|
|
282
|
-
try {
|
|
283
|
-
const body = await readBodyLimited(response, MAX_BODY_BYTES);
|
|
284
|
-
text = body.text;
|
|
285
|
-
bodyTooLarge = body.truncated;
|
|
286
|
-
}
|
|
287
|
-
catch (err) {
|
|
288
|
-
return { kind: "network", message: describeFetchError(err) };
|
|
289
|
-
}
|
|
290
|
-
let parsed;
|
|
291
|
-
let parseFailed = false;
|
|
292
|
-
try {
|
|
293
|
-
parsed = JSON.parse(text);
|
|
294
|
-
}
|
|
295
|
-
catch {
|
|
296
|
-
parseFailed = true;
|
|
297
|
-
}
|
|
298
|
-
const backendMessage = parseFailed ? "" : serperErrorMessage(parsed);
|
|
299
|
-
if (!response.ok)
|
|
300
|
-
return { kind: "http", status: response.status, backendMessage };
|
|
301
|
-
if (parseFailed) {
|
|
302
|
-
return { kind: "invalid-json", bodyTooLarge, sample: clamp(collapse(text), 100) };
|
|
303
|
-
}
|
|
304
|
-
if (bodyTooLarge)
|
|
305
|
-
return { kind: "invalid-json", bodyTooLarge: true, sample: "" };
|
|
306
|
-
if (backendMessage)
|
|
307
|
-
return { kind: "http", status: response.status, backendMessage };
|
|
308
|
-
return { kind: "ok", data: parseSerper(parsed) };
|
|
309
|
-
}
|
|
310
|
-
function renderSerperFailure(o) {
|
|
311
|
-
const hint = serperHint();
|
|
312
|
-
switch (o.kind) {
|
|
313
|
-
case "network":
|
|
314
|
-
return `Search error: could not reach the Serper search API (${o.message}). This is not a problem with the query.`;
|
|
315
|
-
case "invalid-json":
|
|
316
|
-
if (o.bodyTooLarge) {
|
|
317
|
-
return `Search error: the search API response exceeded ${MAX_BODY_BYTES / 1024 / 1024} MB and could not be parsed. Narrow the query or request fewer results.`;
|
|
318
|
-
}
|
|
319
|
-
return `Search error: the search API returned a non-JSON response${o.sample ? ` (starts with: ${o.sample})` : ""}.`;
|
|
320
|
-
case "http": {
|
|
321
|
-
const detail = o.backendMessage ? ` Backend message: ${o.backendMessage}.` : "";
|
|
322
|
-
if (o.status === 429) {
|
|
323
|
-
return `Search error: the search API is rate-limiting (HTTP 429).${detail} Wait a few seconds before retrying.`;
|
|
324
|
-
}
|
|
325
|
-
if (o.status === 401 || o.status === 403) {
|
|
326
|
-
if (usingBuiltInSerperKey()) {
|
|
327
|
-
return `Search error: the built-in shared Serper key was rejected (HTTP ${o.status}).${detail} The shared key's quota is likely exhausted. Set your own key via "${SERPER_KEY_ENV}" or "serperApiKey" in ~/.min-agent/config.json, or configure your own SearXNG instance via "webSearchURL".`;
|
|
328
|
-
}
|
|
329
|
-
return `Search error: the search API rejected the API key (HTTP ${o.status}).${detail} ${hint}`;
|
|
330
|
-
}
|
|
331
|
-
if (o.status === 400 || o.status === 422) {
|
|
332
|
-
return `Search error: the search API rejected these parameters (HTTP ${o.status}).${detail} Fix the arguments and retry; retrying with the same values will fail again.`;
|
|
333
|
-
}
|
|
334
|
-
if (o.status >= 500) {
|
|
335
|
-
return `Search error: the search API failed with HTTP ${o.status}.${detail} It may be temporarily overloaded — retry once.`;
|
|
336
|
-
}
|
|
337
|
-
return `Search error: HTTP ${o.status} from the search API.${detail}`;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
function buildSerperPayload(input, notes, maxResults) {
|
|
342
|
-
const payload = { q: input.query, num: maxResults };
|
|
343
|
-
const lang = (input.language ?? "").trim().toLowerCase();
|
|
344
|
-
if (lang) {
|
|
345
|
-
if (LANGUAGE_RE.test(lang) && lang !== "all") {
|
|
346
|
-
payload.hl = lang.replace("_", "-");
|
|
347
|
-
}
|
|
348
|
-
else {
|
|
349
|
-
notes.push(`ignored invalid language "${input.language}"; use a code like "zh", "en" or "zh-CN"`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
const range = (input.time_range ?? "").trim().toLowerCase();
|
|
353
|
-
if (range) {
|
|
354
|
-
if (VALID_TIME_RANGES.has(range))
|
|
355
|
-
payload.tbs = TBS_BY_RANGE[range];
|
|
356
|
-
else
|
|
357
|
-
notes.push(`ignored invalid time_range "${range}"; valid values: day, week, month, year`);
|
|
358
|
-
}
|
|
359
|
-
return payload;
|
|
360
|
-
}
|
|
361
|
-
function searxngConfigured() {
|
|
362
|
-
const env = process.env[SEARXNG_BACKEND.envName];
|
|
363
|
-
if (env && env.trim())
|
|
364
|
-
return true;
|
|
365
|
-
const configured = loadConfig().webSearchURL;
|
|
366
|
-
return !!(configured && configured.trim());
|
|
367
|
-
}
|
|
368
|
-
// --- SearXNG (legacy, only when explicitly configured) ---
|
|
369
|
-
function collapse(s) {
|
|
31
|
+
export function collapse(s) {
|
|
370
32
|
return s.replace(/\s+/g, " ").trim();
|
|
371
33
|
}
|
|
372
|
-
function clamp(s, max) {
|
|
34
|
+
export function clamp(s, max) {
|
|
373
35
|
const chars = Array.from(s);
|
|
374
36
|
if (chars.length <= max)
|
|
375
37
|
return s;
|
|
376
38
|
return chars.slice(0, max - 1).join("") + "…";
|
|
377
39
|
}
|
|
378
|
-
function hostOf(url) {
|
|
40
|
+
export function hostOf(url) {
|
|
379
41
|
try {
|
|
380
42
|
return new URL(url).hostname.replace(/^www\./, "");
|
|
381
43
|
}
|
|
@@ -410,7 +72,7 @@ export function dedupeKey(raw) {
|
|
|
410
72
|
return raw.trim().toLowerCase();
|
|
411
73
|
}
|
|
412
74
|
}
|
|
413
|
-
function dedupeByUrl(results) {
|
|
75
|
+
export function dedupeByUrl(results) {
|
|
414
76
|
const seen = new Set();
|
|
415
77
|
return results.filter((r) => {
|
|
416
78
|
const key = dedupeKey(r.url);
|
|
@@ -429,7 +91,7 @@ function dedupeByUrl(results) {
|
|
|
429
91
|
* Snippets are whitespace-collapsed and clamped so the indented block
|
|
430
92
|
* structure survives multi-line engine output.
|
|
431
93
|
*/
|
|
432
|
-
function formatResults(results
|
|
94
|
+
export function formatResults(results) {
|
|
433
95
|
return results
|
|
434
96
|
.map((r, i) => {
|
|
435
97
|
const lines = [`${i + 1}. ${collapse(r.title) || hostOf(r.url)}`, ` ${r.url}`];
|
|
@@ -444,40 +106,62 @@ function formatResults(results, withImages) {
|
|
|
444
106
|
const snippet = clamp(collapse(r.content), SNIPPET_MAX_CHARS);
|
|
445
107
|
if (snippet)
|
|
446
108
|
lines.push(` ${snippet}`);
|
|
447
|
-
// Image results usually carry an empty title/content; the asset URL is
|
|
448
|
-
// the only useful payload, so surface it for image searches.
|
|
449
|
-
if (withImages && r.imgSrc)
|
|
450
|
-
lines.push(` image: ${r.imgSrc}`);
|
|
451
109
|
return lines.join("\n");
|
|
452
110
|
})
|
|
453
111
|
.join("\n\n");
|
|
454
112
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
113
|
+
export function resolveMaxResults(raw, notes) {
|
|
114
|
+
if (raw === undefined)
|
|
115
|
+
return DEFAULT_MAX_RESULTS;
|
|
116
|
+
const n = Math.floor(Number(raw));
|
|
117
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
118
|
+
notes.push(`ignored invalid max_results "${raw}"; using ${DEFAULT_MAX_RESULTS}`);
|
|
119
|
+
return DEFAULT_MAX_RESULTS;
|
|
120
|
+
}
|
|
121
|
+
if (n > MAX_RESULTS_LIMIT) {
|
|
122
|
+
notes.push(`max_results capped at ${MAX_RESULTS_LIMIT}`);
|
|
123
|
+
return MAX_RESULTS_LIMIT;
|
|
465
124
|
}
|
|
466
|
-
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
export function emptyResponse() {
|
|
128
|
+
return {
|
|
129
|
+
results: [],
|
|
130
|
+
answers: [],
|
|
131
|
+
suggestions: [],
|
|
132
|
+
corrections: [],
|
|
133
|
+
infoboxes: [],
|
|
134
|
+
unresponsive_engines: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Backends report parameter problems as {"error": "..."} — sometimes with HTTP 200. */
|
|
138
|
+
function backendErrorMessage(data) {
|
|
139
|
+
if (typeof data !== "object" || data === null)
|
|
467
140
|
return "";
|
|
468
|
-
const
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
141
|
+
const o = data;
|
|
142
|
+
for (const key of ["error", "detail", "message"]) {
|
|
143
|
+
const v = o[key];
|
|
144
|
+
if (isString(v) && v.trim())
|
|
145
|
+
return collapse(v);
|
|
146
|
+
if (typeof v === "object" && v !== null) {
|
|
147
|
+
const nested = v.message;
|
|
148
|
+
if (isString(nested) && nested.trim())
|
|
149
|
+
return collapse(nested);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return "";
|
|
473
153
|
}
|
|
474
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Shared fetch scaffolding for both search backends: read the body with a
|
|
156
|
+
* size cap, then distinguish HTTP errors, non-JSON bodies and backend-reported
|
|
157
|
+
* errors before handing valid JSON to the backend-specific parser. Error
|
|
158
|
+
* bodies are valid JSON even on a 4xx, so parse before branching on the
|
|
159
|
+
* status; that way the backend's own message reaches the caller.
|
|
160
|
+
*/
|
|
161
|
+
export async function fetchJsonOutcome(url, init, parse) {
|
|
475
162
|
let response;
|
|
476
163
|
try {
|
|
477
|
-
response = await fetch(
|
|
478
|
-
headers: { Accept: "application/json" },
|
|
479
|
-
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
|
|
480
|
-
});
|
|
164
|
+
response = await fetch(url, init);
|
|
481
165
|
}
|
|
482
166
|
catch (err) {
|
|
483
167
|
return { kind: "network", message: describeFetchError(err) };
|
|
@@ -500,8 +184,6 @@ async function searchOnce(base, params) {
|
|
|
500
184
|
catch {
|
|
501
185
|
parseFailed = true;
|
|
502
186
|
}
|
|
503
|
-
// Error bodies are valid JSON even on a 4xx, so parse before branching on the
|
|
504
|
-
// status; that way the backend's own message reaches the caller.
|
|
505
187
|
const backendMessage = parseFailed ? "" : backendErrorMessage(parsed);
|
|
506
188
|
if (!response.ok)
|
|
507
189
|
return { kind: "http", status: response.status, backendMessage };
|
|
@@ -513,184 +195,10 @@ async function searchOnce(base, params) {
|
|
|
513
195
|
return { kind: "invalid-json", bodyTooLarge: true, sample: "" };
|
|
514
196
|
if (backendMessage)
|
|
515
197
|
return { kind: "http", status: response.status, backendMessage };
|
|
516
|
-
return { kind: "ok", data:
|
|
517
|
-
}
|
|
518
|
-
function renderFailure(o) {
|
|
519
|
-
const hint = configHint(SEARXNG_BACKEND);
|
|
520
|
-
switch (o.kind) {
|
|
521
|
-
case "network":
|
|
522
|
-
return `Search error: could not reach the search backend (${o.message}). The instance may be down or the network may be unavailable — this is not a problem with the query. ${hint}`;
|
|
523
|
-
case "invalid-json":
|
|
524
|
-
if (o.bodyTooLarge) {
|
|
525
|
-
return `Search error: the search backend response exceeded ${MAX_BODY_BYTES / 1024 / 1024} MB and could not be parsed. Narrow the query or request fewer engines.`;
|
|
526
|
-
}
|
|
527
|
-
return `Search error: the search backend returned a non-JSON response${o.sample ? ` (starts with: ${o.sample})` : ""}. The instance most likely has the JSON output format disabled. ${hint}`;
|
|
528
|
-
case "http": {
|
|
529
|
-
const detail = o.backendMessage ? ` Backend message: ${o.backendMessage}.` : "";
|
|
530
|
-
if (o.status === 429) {
|
|
531
|
-
return `Search error: the search backend is rate-limiting (HTTP 429).${detail} Wait a few seconds before retrying, or use a different instance. ${hint}`;
|
|
532
|
-
}
|
|
533
|
-
if (o.status === 401 || o.status === 403) {
|
|
534
|
-
return `Search error: the search backend rejected the request (HTTP ${o.status}).${detail} The instance likely requires authentication or has the JSON output format disabled. ${hint}`;
|
|
535
|
-
}
|
|
536
|
-
if (o.status === 400 || o.status === 422) {
|
|
537
|
-
return `Search error: the search backend rejected these parameters (HTTP ${o.status}).${detail} Fix the arguments and retry; retrying with the same values will fail again.`;
|
|
538
|
-
}
|
|
539
|
-
if (o.status >= 500) {
|
|
540
|
-
return `Search error: the search backend failed with HTTP ${o.status}.${detail} It may be temporarily overloaded — retry once, then try a different instance. ${hint}`;
|
|
541
|
-
}
|
|
542
|
-
return `Search error: HTTP ${o.status} from the search backend.${detail} ${hint}`;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
function fallbackEngines() {
|
|
547
|
-
const env = process.env.MIN_AGENT_SEARXNG_FALLBACK_ENGINES;
|
|
548
|
-
if (env !== undefined)
|
|
549
|
-
return env.trim();
|
|
550
|
-
const configured = loadConfig().webSearchFallbackEngines;
|
|
551
|
-
if (configured !== undefined)
|
|
552
|
-
return configured.trim();
|
|
553
|
-
return DEFAULT_FALLBACK_ENGINES;
|
|
554
|
-
}
|
|
555
|
-
/** Validate/normalize the model-supplied arguments, collecting notes for anything ignored. */
|
|
556
|
-
function buildParams(input, notes) {
|
|
557
|
-
const params = new URLSearchParams({ q: input.query, format: "json", categories: "general" });
|
|
558
|
-
const lang = (input.language ?? "").trim();
|
|
559
|
-
if (lang) {
|
|
560
|
-
if (LANGUAGE_RE.test(lang))
|
|
561
|
-
params.set("language", lang.replace("_", "-"));
|
|
562
|
-
else
|
|
563
|
-
notes.push(`ignored invalid language "${lang}"; use a code like "zh", "en" or "zh-CN"`);
|
|
564
|
-
}
|
|
565
|
-
const range = (input.time_range ?? "").trim().toLowerCase();
|
|
566
|
-
if (range) {
|
|
567
|
-
if (VALID_TIME_RANGES.has(range))
|
|
568
|
-
params.set("time_range", range);
|
|
569
|
-
else
|
|
570
|
-
notes.push(`ignored invalid time_range "${range}"; valid values: day, week, month, year`);
|
|
571
|
-
}
|
|
572
|
-
return { params, requested: null, wantsImages: false };
|
|
573
|
-
}
|
|
574
|
-
function resolveMaxResults(raw, notes) {
|
|
575
|
-
if (raw === undefined)
|
|
576
|
-
return DEFAULT_MAX_RESULTS;
|
|
577
|
-
const n = Math.floor(Number(raw));
|
|
578
|
-
if (!Number.isFinite(n) || n < 1) {
|
|
579
|
-
notes.push(`ignored invalid max_results "${raw}"; using ${DEFAULT_MAX_RESULTS}`);
|
|
580
|
-
return DEFAULT_MAX_RESULTS;
|
|
581
|
-
}
|
|
582
|
-
if (n > MAX_RESULTS_LIMIT) {
|
|
583
|
-
notes.push(`max_results capped at ${MAX_RESULTS_LIMIT}`);
|
|
584
|
-
return MAX_RESULTS_LIMIT;
|
|
585
|
-
}
|
|
586
|
-
return n;
|
|
587
|
-
}
|
|
588
|
-
async function runSearxng(input, query, notes) {
|
|
589
|
-
const resolved = resolveBackendBase(SEARXNG_BACKEND, loadConfig().webSearchURL);
|
|
590
|
-
if (!resolved.ok)
|
|
591
|
-
return `Search error: ${resolved.error}. ${configHint(SEARXNG_BACKEND)}`;
|
|
592
|
-
const maxResults = resolveMaxResults(input.max_results, notes);
|
|
593
|
-
const { params, requested } = buildParams({ ...input, query }, notes);
|
|
594
|
-
let outcome = await searchOnce(resolved.base, params);
|
|
595
|
-
// The default engine set on a given instance can be entirely blocked
|
|
596
|
-
// (CAPTCHA / rate limits), which looks identical to "no such page exists".
|
|
597
|
-
// A single retry against known-good engines distinguishes the two. The
|
|
598
|
-
// unresponsive list is instance-wide and therefore not a reliable trigger.
|
|
599
|
-
let usedFallback = null;
|
|
600
|
-
if (outcome.kind === "ok" && outcome.data.results.length === 0 && !requested) {
|
|
601
|
-
const fallback = fallbackEngines();
|
|
602
|
-
if (fallback) {
|
|
603
|
-
const retryParams = new URLSearchParams(params);
|
|
604
|
-
retryParams.set("engines", fallback);
|
|
605
|
-
const retry = await searchOnce(resolved.base, retryParams);
|
|
606
|
-
if (retry.kind === "ok" && retry.data.results.length > 0) {
|
|
607
|
-
outcome = retry;
|
|
608
|
-
usedFallback = fallback;
|
|
609
|
-
}
|
|
610
|
-
else if (retry.kind !== "ok") {
|
|
611
|
-
notes.push(`the fallback engines (${fallback}) also failed: ${renderFailure(retry).replace(/^Search error: /, "")}`);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
if (outcome.kind !== "ok")
|
|
616
|
-
return renderFailure(outcome);
|
|
617
|
-
const data = outcome.data;
|
|
618
|
-
const results = dedupeByUrl(data.results).slice(0, maxResults);
|
|
619
|
-
const noteBlock = notes.map((n) => `Note: ${n}`).join("\n");
|
|
620
|
-
if (results.length === 0) {
|
|
621
|
-
const parts = [`No search results found for "${query}".`];
|
|
622
|
-
if (noteBlock)
|
|
623
|
-
parts.push(noteBlock);
|
|
624
|
-
const hint = unresponsiveHint(data.unresponsive_engines, usedFallback ? usedFallback.split(",") : requested);
|
|
625
|
-
if (hint)
|
|
626
|
-
parts.push(hint);
|
|
627
|
-
if (data.corrections.length > 0)
|
|
628
|
-
parts.push(`Did you mean: ${data.corrections.join(", ")}`);
|
|
629
|
-
if (data.suggestions.length > 0)
|
|
630
|
-
parts.push(`Related queries: ${data.suggestions.slice(0, 8).join(", ")}`);
|
|
631
|
-
if (!hint && data.corrections.length === 0 && data.suggestions.length === 0) {
|
|
632
|
-
parts.push("Try a different query or a broader wording.");
|
|
633
|
-
}
|
|
634
|
-
return parts.join("\n\n");
|
|
635
|
-
}
|
|
636
|
-
// Direct answers and infoboxes carry the highest information density, so
|
|
637
|
-
// they precede the result list.
|
|
638
|
-
const sections = [];
|
|
639
|
-
if (noteBlock)
|
|
640
|
-
sections.push(noteBlock);
|
|
641
|
-
for (const a of data.answers)
|
|
642
|
-
sections.push(`Answer: ${collapse(a)}`);
|
|
643
|
-
for (const b of data.infoboxes) {
|
|
644
|
-
sections.push(`Infobox${b.infobox ? ` (${collapse(b.infobox)})` : ""}: ${clamp(collapse(b.content), 600)}`);
|
|
645
|
-
}
|
|
646
|
-
sections.push(`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}"${usedFallback ? ` via fallback engines ${usedFallback}` : ""}:`);
|
|
647
|
-
sections.push(formatResults(results, false));
|
|
648
|
-
if (data.corrections.length > 0)
|
|
649
|
-
sections.push(`Did you mean: ${data.corrections.join(", ")}`);
|
|
650
|
-
if (data.suggestions.length > 0)
|
|
651
|
-
sections.push(`Related queries: ${data.suggestions.slice(0, 8).join(", ")}`);
|
|
652
|
-
return truncateToolOutput(sections.join("\n\n"), { direction: "head" }).content;
|
|
653
|
-
}
|
|
654
|
-
async function runSerper(input, query, notes) {
|
|
655
|
-
const maxResults = resolveMaxResults(input.max_results, notes);
|
|
656
|
-
const payload = buildSerperPayload(input, notes, maxResults);
|
|
657
|
-
const outcome = await serperSearch(payload);
|
|
658
|
-
if (outcome.kind !== "ok")
|
|
659
|
-
return renderSerperFailure(outcome);
|
|
660
|
-
const data = outcome.data;
|
|
661
|
-
const results = dedupeByUrl(data.results).slice(0, maxResults);
|
|
662
|
-
const noteBlock = notes.map((n) => `Note: ${n}`).join("\n");
|
|
663
|
-
if (results.length === 0) {
|
|
664
|
-
const parts = [`No search results found for "${query}".`];
|
|
665
|
-
if (noteBlock)
|
|
666
|
-
parts.push(noteBlock);
|
|
667
|
-
if (data.questions.length > 0)
|
|
668
|
-
parts.push(`Related questions: ${data.questions.slice(0, 5).join(" · ")}`);
|
|
669
|
-
if (data.suggestions.length > 0)
|
|
670
|
-
parts.push(`Related searches: ${data.suggestions.slice(0, 8).join(" · ")}`);
|
|
671
|
-
if (data.questions.length === 0 && data.suggestions.length === 0) {
|
|
672
|
-
parts.push("Try a different query or a broader wording.");
|
|
673
|
-
}
|
|
674
|
-
return parts.join("\n\n");
|
|
675
|
-
}
|
|
676
|
-
const sections = [];
|
|
677
|
-
if (noteBlock)
|
|
678
|
-
sections.push(noteBlock);
|
|
679
|
-
for (const a of data.answers)
|
|
680
|
-
sections.push(`Answer: ${collapse(a)}`);
|
|
681
|
-
for (const b of data.infoboxes) {
|
|
682
|
-
sections.push(`Infobox${b.infobox ? ` (${collapse(b.infobox)})` : ""}: ${clamp(collapse(b.content), 600)}`);
|
|
683
|
-
}
|
|
684
|
-
sections.push(`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}":`);
|
|
685
|
-
sections.push(formatResults(results, false));
|
|
686
|
-
if (data.questions.length > 0)
|
|
687
|
-
sections.push(`Related questions: ${data.questions.slice(0, 5).join(" · ")}`);
|
|
688
|
-
if (data.suggestions.length > 0)
|
|
689
|
-
sections.push(`Related searches: ${data.suggestions.slice(0, 8).join(" · ")}`);
|
|
690
|
-
return truncateToolOutput(sections.join("\n\n"), { direction: "head" }).content;
|
|
198
|
+
return { kind: "ok", data: parse(parsed) };
|
|
691
199
|
}
|
|
692
200
|
export const webSearchTool = tool({
|
|
693
|
-
description: `Search the web via Google. Returns ranked results with title, URL, source and a snippet. Use this for current information, news, documentation, or anything needing up-to-date knowledge. Today's date: ${new Date().toISOString().slice(0, 10)}. When the user asks about the latest or newest state of something, use the current year in the query or set time_range (day, week, month, year) — do not append an older year from your training knowledge.`,
|
|
201
|
+
description: `Search the web via Google. Returns ranked results with title, URL, source and a snippet. Use this for current information, news, documentation, or anything needing up-to-date knowledge. Today's date: ${new Date().toISOString().slice(0, 10)}. When the user asks about the latest or newest state of something, use the current year in the query or set time_range (day, week, month, year) — do not append an older year from your training knowledge. A few targeted queries plus fetching the best sources is enough; then produce the user's deliverable instead of searching exhaustively.`,
|
|
694
202
|
inputSchema: jsonSchema({
|
|
695
203
|
type: "object",
|
|
696
204
|
properties: {
|
|
@@ -705,12 +213,14 @@ export const webSearchTool = tool({
|
|
|
705
213
|
required: ["query"],
|
|
706
214
|
}),
|
|
707
215
|
execute: async (input) => {
|
|
216
|
+
if (getEffectiveSandboxPolicy().network === "deny")
|
|
217
|
+
return `Search error: ${networkDeniedMessage()}`;
|
|
708
218
|
const query = (input.query ?? "").trim();
|
|
709
219
|
if (!query)
|
|
710
220
|
return 'Search error: "query" must be a non-empty string.';
|
|
711
221
|
const notes = [];
|
|
712
222
|
// SearXNG is only used when the user explicitly configures an instance;
|
|
713
|
-
// otherwise
|
|
223
|
+
// otherwise Serper is used.
|
|
714
224
|
if (searxngConfigured())
|
|
715
225
|
return runSearxng(input, query, notes);
|
|
716
226
|
return runSerper(input, query, notes);
|