@epoch-agent/plugin-web 3.2.0 → 4.0.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 CHANGED
@@ -105,6 +105,26 @@ grounding(两家都是 `native` 那一档),opencode 打 Exa / Parallel 公
105
105
 
106
106
  其余限制:响应上限 5 MB,Cloudflare 挑战会重试一次。
107
107
 
108
+ ## 回执语言:给模型读的一律英文
109
+
110
+ `web_fetch` / `web_search` 的**工具回执**——失败说明、被拦截的原因、provider 报错、
111
+ 「一条都没搜到」这类说明——**全部是英文**,判据同
112
+ [core 的 `code-mode/notice.ts`](../../core/src/code-mode/notice.ts) 文件头(2026-11-05
113
+ 拍的口径:回执的读者是模型,跟工具 description 同一档,不跟界面语言走)。
114
+ 所以这几处 `t()` 调用显式传了 `'en'`,`check:i18n` 那边也不要指望它们跟着 `zh` 变。
115
+
116
+ 反过来,这些**不**在这一档里:
117
+
118
+ - **抓回来的网页正文 / 搜索结果的标题、摘要**——那是别人的数据,原样回给模型
119
+ - **`unavailableReason()` 与 `selectSearchProvider()` 的 `detail`**——启动诊断,
120
+ 用户读的,照旧跟着界面语言走
121
+ - **`native` 档的 `buildPrompt()`**——发给对端模型的指令,用户读的正是它的回答,
122
+ 界面切成英文却用中文问它,答案多半也是中文
123
+
124
+ 拦截原因那一格有个额外要求:**URL 和判定原因都得留着**。`url-safety` 只给原因
125
+ (带主机名 / 解析到的地址),被拒的那个完整 URL 由 `web-fetch` 补在前面 ——
126
+ 少了 URL 模型不知道是第一跳还是第五跳被拒,少了原因它只能盲目换个地址重试。
127
+
108
128
  ## 三个 annotation 不只是元数据
109
129
 
110
130
  ```ts
package/dist/index.d.ts CHANGED
@@ -238,6 +238,10 @@ declare function createBraveProvider(opts?: BraveOptions): SearchProvider;
238
238
  * 一份实现就够。`openai`(Responses API)和 `google`(grounding)的传输层
239
239
  * 完全不同,是**另外两份实现**,不在这一版里;能力表留着扩展位,
240
240
  * 表里没有的 provider 会如实报原因然后落到下一档,不是报错。
241
+ *
242
+ * ⚠️ **这一档只有一处跟界面语言走:`buildPrompt()`** —— 那是发给对端模型的指令,
243
+ * 用户读的正是它的回答。而 `SearchError` / `ignored` 那两句进的是**工具回执**,
244
+ * 一律英文(`t(..., 'en')`,2026-11-05 的口径)。
241
245
  */
242
246
 
243
247
  interface NativeOptions {
@@ -283,6 +287,10 @@ declare function createNativeProvider(opts?: NativeOptions): SearchProvider;
283
287
  * - Parallel 还收一个可选的 `model_name` 分析字段 —— **故意不传**。
284
288
  *
285
289
  * 即便如此,查询词本身仍然会发给第三方。要整档关掉:`search.keyless: false`。
290
+ *
291
+ * ⚠️ **`SearchError` / `ignored` 那两句是进工具回执的,一律英文**
292
+ * (`t(..., 'en')`,2026-11-05 的口径);而 `unavailableReason()` 那句是启动诊断、
293
+ * 给用户看的,照旧跟界面语言走。
286
294
  */
287
295
 
288
296
  /** 给用例用:把游标拨回 0,否则用例之间会互相影响 */
package/dist/index.js CHANGED
@@ -110,24 +110,30 @@ function checkUrlSafety(url) {
110
110
  try {
111
111
  parsed = new URL(url);
112
112
  } catch {
113
- return { safe: false, reason: "\u65E0\u6548 URL" };
113
+ return { safe: false, reason: "not a valid URL (it could not be parsed)" };
114
114
  }
115
115
  if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
116
- return { safe: false, reason: `\u7981\u6B62\u534F\u8BAE: ${parsed.protocol}` };
116
+ return {
117
+ safe: false,
118
+ reason: `disallowed protocol: ${parsed.protocol} (only http and https are allowed)`
119
+ };
117
120
  }
118
121
  if (parsed.username || parsed.password) {
119
- return { safe: false, reason: "URL \u4E0D\u5141\u8BB8\u5305\u542B\u7528\u6237\u540D/\u5BC6\u7801" };
122
+ return { safe: false, reason: "URL must not carry a username or password" };
120
123
  }
121
124
  const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
122
- if (!host) return { safe: false, reason: "\u7F3A\u5C11\u4E3B\u673A\u540D" };
125
+ if (!host) return { safe: false, reason: "missing hostname" };
123
126
  if (BLOCKED_HOSTNAMES.has(host)) {
124
- return { safe: false, reason: `\u7981\u6B62\u8BBF\u95EE\u672C\u5730/\u5143\u6570\u636E\u4E3B\u673A: ${host}` };
127
+ return {
128
+ safe: false,
129
+ reason: `blocked host (loopback or cloud metadata endpoint): ${host}`
130
+ };
125
131
  }
126
132
  if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) {
127
- return { safe: false, reason: `\u7981\u6B62\u8BBF\u95EE\u5185\u90E8\u57DF: ${host}` };
133
+ return { safe: false, reason: `blocked internal domain suffix: ${host}` };
128
134
  }
129
135
  if (isIP(host) !== 0 && isBlockedAddress(host)) {
130
- return { safe: false, reason: `\u7981\u6B62\u8BBF\u95EE\u79C1\u6709/\u4FDD\u7559\u5730\u5740: ${host}` };
136
+ return { safe: false, reason: `blocked private or reserved address: ${host}` };
131
137
  }
132
138
  return { safe: true };
133
139
  }
@@ -142,7 +148,7 @@ async function checkUrlSafetyAsync(url) {
142
148
  if (isBlockedAddress(r.address)) {
143
149
  return {
144
150
  safe: false,
145
- reason: `${host} \u89E3\u6790\u5230\u79C1\u6709/\u4FDD\u7559\u5730\u5740 ${r.address}\uFF0C\u62D2\u7EDD\u8BBF\u95EE`
151
+ reason: `${host} resolves to the private or reserved address ${r.address} \u2014 refusing to connect`
146
152
  };
147
153
  }
148
154
  }
@@ -154,6 +160,8 @@ async function checkUrlSafetyAsync(url) {
154
160
  // src/tools/web-fetch.ts
155
161
  var MAX_SIZE = 5 * 1024 * 1024;
156
162
  var MAX_REDIRECTS = 5;
163
+ var TOO_LARGE = "Response too large (exceeds the 5MB limit) \u2014 fetch a smaller resource instead";
164
+ var TIMEOUT_HINT = "Request timed out \u2014 retry with a larger timeout (up to 120s) or fetch a smaller page";
157
165
  var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
158
166
  function acceptFor(format2) {
159
167
  if (format2 === "markdown") return "text/markdown;q=1.0, text/html;q=0.9, text/plain;q=0.8";
@@ -165,7 +173,7 @@ async function fetchWithSafeRedirects(startUrl, init) {
165
173
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
166
174
  const safety = await checkUrlSafetyAsync(current);
167
175
  if (!safety.safe) {
168
- const where = hop === 0 ? "URL \u4E0D\u5B89\u5168" : `\u91CD\u5B9A\u5411\u7B2C ${hop} \u8DF3\u88AB\u62D2`;
176
+ const where = hop === 0 ? `Refused to fetch ${current}` : `Refused redirect hop ${hop} (${current})`;
169
177
  return { error: `${where}: ${safety.reason}` };
170
178
  }
171
179
  const response = await fetch(current, { ...init, redirect: "manual" });
@@ -177,18 +185,21 @@ async function fetchWithSafeRedirects(startUrl, init) {
177
185
  try {
178
186
  next = new URL(location, current).toString();
179
187
  } catch {
180
- return { error: `\u91CD\u5B9A\u5411\u5730\u5740\u65E0\u6CD5\u89E3\u6790: ${location}` };
188
+ return { error: `Redirect target could not be parsed: ${location}` };
181
189
  }
182
190
  current = next;
183
191
  continue;
184
192
  }
185
193
  return { response, finalUrl: current };
186
194
  }
187
- return { error: `\u91CD\u5B9A\u5411\u8D85\u8FC7 ${MAX_REDIRECTS} \u8DF3\uFF0C\u5DF2\u653E\u5F03` };
195
+ return {
196
+ error: `Gave up after more than ${MAX_REDIRECTS} redirects (possible redirect loop)`
197
+ };
188
198
  }
189
199
  async function execWebFetch(args, _ctx) {
190
200
  const url = String(args["url"] || "").trim();
191
- if (!url) return fail(5001, "URL \u4E0D\u80FD\u4E3A\u7A7A");
201
+ if (!url)
202
+ return fail(5001, "URL is empty \u2014 pass a full page URL that starts with http:// or https://");
192
203
  const format2 = args["format"] || "markdown";
193
204
  const timeout = Math.min(args["timeout"] || 30, 120) * 1e3;
194
205
  const controller = new AbortController();
@@ -214,10 +225,9 @@ async function execWebFetch(args, _ctx) {
214
225
  if (!response.ok) return fail(5003, `HTTP ${response.status}: ${response.statusText}`);
215
226
  const contentType = response.headers.get("content-type") || "";
216
227
  const contentLen = response.headers.get("content-length");
217
- if (contentLen && Number(contentLen) > MAX_SIZE)
218
- return fail(5004, "\u54CD\u5E94\u8FC7\u5927 (exceeds 5MB limit)");
228
+ if (contentLen && Number(contentLen) > MAX_SIZE) return fail(5004, TOO_LARGE);
219
229
  const body = await readCapped(response, MAX_SIZE);
220
- if (body === null) return fail(5004, "\u54CD\u5E94\u8FC7\u5927 (exceeds 5MB limit)");
230
+ if (body === null) return fail(5004, TOO_LARGE);
221
231
  const raw = new TextDecoder().decode(body);
222
232
  const isHtml = contentType.includes("text/html") || raw.includes("<html") || raw.includes("<HTML");
223
233
  let output;
@@ -227,7 +237,7 @@ async function execWebFetch(args, _ctx) {
227
237
  output = raw.slice(0, 5e4);
228
238
  }
229
239
  const title = extractTitle(raw, isHtml);
230
- const prefix = finalUrl !== url ? `> \u6700\u7EC8\u5730\u5740: ${finalUrl}
240
+ const prefix = finalUrl !== url ? `> Final URL: ${finalUrl}
231
241
 
232
242
  ` : "";
233
243
  return {
@@ -239,9 +249,9 @@ ${output}` : output),
239
249
  };
240
250
  } catch (err) {
241
251
  if (err instanceof Error && err.name === "AbortError") {
242
- return fail(5005, "\u8BF7\u6C42\u8D85\u65F6");
252
+ return fail(5005, TIMEOUT_HINT);
243
253
  }
244
- return fail(5006, `\u7F51\u7EDC\u9519\u8BEF: ${err instanceof Error ? err.message : String(err)}`);
254
+ return fail(5006, `Network error: ${err instanceof Error ? err.message : String(err)}`);
245
255
  } finally {
246
256
  clearTimeout(timer);
247
257
  }
@@ -296,18 +306,20 @@ async function searchFetch(url, init, who) {
296
306
  response = await fetch(url, init);
297
307
  } catch (err) {
298
308
  if (err instanceof Error && err.name === "AbortError") {
299
- throw new SearchError(`${who} \u641C\u7D22\u8D85\u65F6`);
309
+ throw new SearchError(`${who} search timed out`);
300
310
  }
301
- throw new SearchError(`${who} \u8FDE\u4E0D\u4E0A\uFF1A${err instanceof Error ? err.message : String(err)}`);
311
+ throw new SearchError(
312
+ `${who} is unreachable: ${err instanceof Error ? err.message : String(err)}`
313
+ );
302
314
  }
303
315
  if (!response.ok) {
304
- const hint = response.status === 401 || response.status === 403 ? "\uFF08API key \u65E0\u6548\u6216\u6CA1\u6709\u6743\u9650\uFF09" : response.status === 429 ? "\uFF08\u914D\u989D\u7528\u5C3D\u6216\u89E6\u53D1\u9650\u6D41\uFF09" : "";
305
- throw new SearchError(`${who} \u8FD4\u56DE HTTP ${response.status}${hint}`, response.status);
316
+ const hint = response.status === 401 || response.status === 403 ? " (invalid or unauthorized API key)" : response.status === 429 ? " (quota exhausted or rate limited \u2014 retry later or switch provider)" : "";
317
+ throw new SearchError(`${who} returned HTTP ${response.status}${hint}`, response.status);
306
318
  }
307
319
  try {
308
320
  return await response.json();
309
321
  } catch {
310
- throw new SearchError(`${who} \u7684\u54CD\u5E94\u4E0D\u662F\u5408\u6CD5 JSON`);
322
+ throw new SearchError(`${who} returned a response that is not valid JSON`);
311
323
  }
312
324
  }
313
325
  function str(obj, key) {
@@ -347,10 +359,14 @@ function format(results, ignored, summary) {
347
359
  ${snippet}` : `${i + 1}. ${r.title}${date}
348
360
  ${r.url}`;
349
361
  });
350
- const head = `${results.length} \u6761\u7ED3\u679C\uFF08\u53EA\u6709\u6458\u8981\uFF0C\u8981\u6B63\u6587\u8BF7\u5BF9\u5177\u4F53 URL \u8C03 web_fetch\uFF09`;
351
- const lead = summary ? ["", `\u540E\u7AEF\u7EFC\u8FF0\uFF08\u636E\u4E0B\u5217\u6765\u6E90\u5408\u6210\uFF0C\u5F15\u7528\u65F6\u8BF7\u843D\u5230\u5177\u4F53 URL\uFF09\uFF1A`, summary] : [];
362
+ const head = `${results.length} result${results.length === 1 ? "" : "s"} (titles, URLs and snippets only \u2014 call web_fetch on a specific URL to read the page)`;
363
+ const lead = summary ? [
364
+ "",
365
+ "Backend summary (synthesized from the sources below \u2014 cite a concrete URL when you quote it):",
366
+ summary
367
+ ] : [];
352
368
  const tail = ignored.length > 0 ? [`
353
- \u6CE8\u610F\uFF1A${ignored.join("\uFF1B")}`] : [];
369
+ Note: ${ignored.join("; ")}`] : [];
354
370
  return [head, ...lead, "", ...lines, ...tail].join("\n");
355
371
  }
356
372
  function stringArray(value) {
@@ -366,7 +382,7 @@ function createWebSearchExec(deps) {
366
382
  return {
367
383
  success: false,
368
384
  output: "",
369
- error: { code: 5101, message: "\u67E5\u8BE2\u8BCD\u4E0D\u80FD\u4E3A\u7A7A" },
385
+ error: { code: 5101, message: "Query must not be empty" },
370
386
  duration: 0
371
387
  };
372
388
  }
@@ -387,7 +403,11 @@ function createWebSearchExec(deps) {
387
403
  if (cached) {
388
404
  return {
389
405
  success: true,
390
- output: format(cached.results, ["\u672C\u6B21\u7ED3\u679C\u6765\u81EA\u672C\u4F1A\u8BDD\u7F13\u5B58\uFF0C\u672A\u91CD\u65B0\u8054\u7F51"], cached.summary),
406
+ output: format(
407
+ cached.results,
408
+ ["served from this session cache \u2014 no new network request was made"],
409
+ cached.summary
410
+ ),
391
411
  duration: Date.now() - started
392
412
  };
393
413
  }
@@ -402,7 +422,7 @@ function createWebSearchExec(deps) {
402
422
  if (outcome.results.length === 0) {
403
423
  return {
404
424
  success: true,
405
- output: `\u6CA1\u6709\u641C\u5230\u7ED3\u679C\uFF08${deps.provider.name}\uFF09\u3002\u6362\u4E2A\u8BF4\u6CD5\u6216\u653E\u5BBD\u6761\u4EF6\u518D\u8BD5\u3002`,
425
+ output: `No results from ${deps.provider.name}. Try different wording, or loosen the filters (domains / recencyDays).`,
406
426
  duration: Date.now() - started
407
427
  };
408
428
  }
@@ -412,14 +432,14 @@ function createWebSearchExec(deps) {
412
432
  duration: Date.now() - started
413
433
  };
414
434
  } catch (err) {
415
- const message = err instanceof SearchError ? err.message : `\u641C\u7D22\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`;
435
+ const message = err instanceof SearchError ? err.message : `Search failed: ${err instanceof Error ? err.message : String(err)}`;
416
436
  return {
417
437
  success: false,
418
438
  output: "",
419
439
  error: {
420
440
  code: 5102,
421
441
  message,
422
- suggestion: "\u8FD9\u4E0D\u662F\u81F4\u547D\u9519\u8BEF\uFF0C\u53EF\u4EE5\u7EE7\u7EED\u522B\u7684\u5DE5\u4F5C\uFF0C\u6216\u7A0D\u540E\u91CD\u8BD5"
442
+ suggestion: "This is not fatal \u2014 carry on with other work, or retry later."
423
443
  },
424
444
  duration: Date.now() - started
425
445
  };
@@ -474,7 +494,9 @@ function createBraveProvider(opts = {}) {
474
494
  }
475
495
  const ignored = [];
476
496
  if (options.recencyDays !== void 0 && !freshness) {
477
- ignored.push("recencyDays\uFF08brave \u7684 freshness \u53EA\u5230\u300C\u4E00\u5E74\u5185\u300D\uFF0C\u66F4\u5927\u7684\u5929\u6570\u5DF2\u5FFD\u7565\uFF09");
497
+ ignored.push(
498
+ 'recencyDays (brave freshness only reaches "past year"; larger day counts were ignored)'
499
+ );
478
500
  }
479
501
  return { results, ...ignored.length > 0 ? { ignored } : {} };
480
502
  }
@@ -553,25 +575,29 @@ async function mcpCall(init) {
553
575
  });
554
576
  } catch (err) {
555
577
  if (err instanceof Error && err.name === "AbortError") {
556
- throw new SearchError(t2("web_search_setup.err_timeout", { who: init.who }));
578
+ throw new SearchError(t2("web_search_setup.err_timeout", { who: init.who }, "en"));
557
579
  }
558
580
  throw new SearchError(
559
- t2("web_search_setup.err_unreachable", {
560
- who: init.who,
561
- detail: err instanceof Error ? err.message : String(err)
562
- })
581
+ t2(
582
+ "web_search_setup.err_unreachable",
583
+ {
584
+ who: init.who,
585
+ detail: err instanceof Error ? err.message : String(err)
586
+ },
587
+ "en"
588
+ )
563
589
  );
564
590
  }
565
591
  if (!response.ok) {
566
- const hint = response.status === 429 ? t2("web_search_setup.err_rate_limited_hint") : "";
592
+ const hint = response.status === 429 ? t2("web_search_setup.err_rate_limited_hint", void 0, "en") : "";
567
593
  throw new SearchError(
568
- t2("web_search_setup.err_http", { who: init.who, status: response.status, hint }),
594
+ t2("web_search_setup.err_http", { who: init.who, status: response.status, hint }, "en"),
569
595
  response.status
570
596
  );
571
597
  }
572
598
  const text = parseMcpResponse(await response.text());
573
599
  if (text === void 0)
574
- throw new SearchError(t2("web_search_setup.err_no_content", { who: init.who }));
600
+ throw new SearchError(t2("web_search_setup.err_no_content", { who: init.who }, "en"));
575
601
  return text;
576
602
  }
577
603
  // src/search/keyless.ts
@@ -630,7 +656,7 @@ function parseParallel(text, max) {
630
656
  try {
631
657
  data = JSON.parse(text);
632
658
  } catch {
633
- throw new SearchError(t3("web_search_setup.err_bad_json", { who: "parallel" }));
659
+ throw new SearchError(t3("web_search_setup.err_bad_json", { who: "parallel" }, "en"));
634
660
  }
635
661
  if (!Array.isArray(data.results)) return [];
636
662
  const out = [];
@@ -699,9 +725,13 @@ function createKeylessProvider(opts = {}) {
699
725
  }
700
726
  }
701
727
  throw new SearchError(
702
- t3("web_search_setup.keyless_all_failed", {
703
- tried: failures.join(t3("web_search_setup.tried_sep"))
704
- })
728
+ t3(
729
+ "web_search_setup.keyless_all_failed",
730
+ {
731
+ tried: failures.join(t3("web_search_setup.tried_sep", void 0, "en"))
732
+ },
733
+ "en"
734
+ )
705
735
  );
706
736
  }
707
737
  };
@@ -709,9 +739,11 @@ function createKeylessProvider(opts = {}) {
709
739
  function ignoredOf(options) {
710
740
  const ignored = [];
711
741
  if (options.recencyDays !== void 0)
712
- ignored.push(t3("web_search_setup.ignored_recency_keyless"));
713
- if (options.domains?.length) ignored.push(t3("web_search_setup.ignored_domains_keyless"));
714
- if (options.excludeDomains?.length) ignored.push(t3("web_search_setup.ignored_exclude_keyless"));
742
+ ignored.push(t3("web_search_setup.ignored_recency_keyless", void 0, "en"));
743
+ if (options.domains?.length)
744
+ ignored.push(t3("web_search_setup.ignored_domains_keyless", void 0, "en"));
745
+ if (options.excludeDomains?.length)
746
+ ignored.push(t3("web_search_setup.ignored_exclude_keyless", void 0, "en"));
715
747
  return ignored;
716
748
  }
717
749
  // src/search/native.ts
@@ -795,11 +827,11 @@ function createNativeProvider(opts = {}) {
795
827
  const results = collectResults(blocks, options.maxResults);
796
828
  if (results.length === 0) {
797
829
  throw new SearchError(
798
- t4("web_search_setup.native_no_search", { provider: opts.providerType })
830
+ t4("web_search_setup.native_no_search", { provider: opts.providerType }, "en")
799
831
  );
800
832
  }
801
833
  const summary = collectSummary(blocks);
802
- const ignored = options.recencyDays === void 0 ? [] : [t4("web_search_setup.ignored_recency_native")];
834
+ const ignored = options.recencyDays === void 0 ? [] : [t4("web_search_setup.ignored_recency_native", void 0, "en")];
803
835
  return {
804
836
  results,
805
837
  ...summary ? { summary } : {},
@@ -844,7 +876,7 @@ function createSearxngProvider(opts) {
844
876
  ).catch((err) => {
845
877
  if (err instanceof SearchError && err.status === 403) {
846
878
  throw new SearchError(
847
- "searxng \u8FD4\u56DE 403 \u2014\u2014 \u591A\u534A\u662F\u5B9E\u4F8B\u6CA1\u5F00 JSON \u8F93\u51FA\uFF08settings.yml \u91CC search.formats \u8981\u542B json\uFF09",
879
+ "searxng returned 403 \u2014 most likely the instance has JSON output disabled (settings.yml needs json under search.formats)",
848
880
  403
849
881
  );
850
882
  }
@@ -864,7 +896,9 @@ function createSearxngProvider(opts) {
864
896
  }
865
897
  const ignored = [];
866
898
  if (options.recencyDays !== void 0 && !range) {
867
- ignored.push("recencyDays\uFF08searxng \u53EA\u652F\u6301 day/week/month/year \u56DB\u6863\uFF0C\u5929\u6570\u592A\u5927\u5DF2\u5FFD\u7565\uFF09");
899
+ ignored.push(
900
+ "recencyDays (searxng only supports day/week/month/year buckets; larger day counts were ignored)"
901
+ );
868
902
  }
869
903
  return { results, ...ignored.length > 0 ? { ignored } : {} };
870
904
  }
@@ -991,17 +1025,21 @@ function selectSearchProvider(config, native) {
991
1025
  var webPlugin = {
992
1026
  name: "@epoch-agent/plugin-web",
993
1027
  version: "0.0.0",
994
- description: "Web \u6293\u53D6\u63D2\u4EF6 \u2014 URL \u5B89\u5168\u6821\u9A8C + HTML\u2192Markdown + SSRF \u9632\u62A4",
1028
+ description: "Web fetching and searching \u2014 URL safety checks, HTML to Markdown, SSRF protection",
995
1029
  tools: [
996
1030
  {
997
1031
  name: "web_fetch",
998
- description: "\u6293\u53D6\u7F51\u9875\u5185\u5BB9\u3002\u53C2\u6570: url(\u5FC5\u586B), format(markdown|text|html, \u9ED8\u8BA4 markdown), timeout(\u79D2, \u9ED8\u8BA4 30, \u6700\u5927 120)\u3002\u81EA\u52A8\u5C06 HTML \u8F6C\u4E3A Markdown\uFF0C\u8FC7\u6EE4 script/style\uFF0CSSRF \u5B89\u5168\u6821\u9A8C\u3002",
1032
+ description: "Fetch a web page and return its content. HTML is converted to Markdown and script/style are stripped. Fails on private and loopback addresses (SSRF protection).",
999
1033
  parameters: {
1000
1034
  type: "object",
1001
1035
  properties: {
1002
- url: { type: "string", description: "\u7F51\u9875 URL\uFF08\u5FC5\u987B\u4EE5 http:// \u6216 https:// \u5F00\u5934\uFF09" },
1003
- format: { type: "string", enum: ["markdown", "text", "html"], description: "\u8FD4\u56DE\u683C\u5F0F" },
1004
- timeout: { type: "number", description: "\u8D85\u65F6\u79D2\u6570\uFF08\u9ED8\u8BA4 30\uFF0C\u6700\u5927 120\uFF09" }
1036
+ url: { type: "string", description: "Page URL. Must start with http:// or https://." },
1037
+ format: {
1038
+ type: "string",
1039
+ enum: ["markdown", "text", "html"],
1040
+ description: "What to return (default markdown)."
1041
+ },
1042
+ timeout: { type: "number", description: "Timeout in seconds (default 30, max 120)." }
1005
1043
  },
1006
1044
  required: ["url"]
1007
1045
  },
@@ -1015,19 +1053,29 @@ var webPlugin = {
1015
1053
  function createWebSearchTool(provider, opts = {}) {
1016
1054
  return {
1017
1055
  name: "web_search",
1018
- description: `\u7528 ${provider.name} \u641C\u7D22\u7F51\u9875\u3002\u53C2\u6570: query(\u5FC5\u586B), maxResults(\u9ED8\u8BA4 5, \u6700\u5927 20), recencyDays(\u53EA\u8981\u6700\u8FD1 N \u5929), domains(\u53EA\u641C\u8FD9\u4E9B\u57DF\u540D), excludeDomains(\u6392\u9664\u8FD9\u4E9B\u57DF\u540D)\u3002**\u53EA\u8FD4\u56DE\u6807\u9898 / URL / \u6458\u8981\uFF0C\u4E0D\u8FD4\u56DE\u7F51\u9875\u6B63\u6587** \u2014\u2014 \u770B\u5B8C\u6458\u8981\u5224\u65AD\u54EA\u6761\u503C\u5F97\u8BFB\uFF0C\u518D\u5BF9\u90A3\u4E2A URL \u8C03 web_fetch \u53D6\u6B63\u6587\u3002`,
1056
+ description: `Search the web with ${provider.name}. **Returns titles, URLs and snippets only \u2014 not page contents.** Read the snippets, decide which result is worth opening, then call web_fetch on that URL.`,
1019
1057
  parameters: {
1020
1058
  type: "object",
1021
1059
  properties: {
1022
- query: { type: "string", description: "\u641C\u7D22\u8BCD" },
1023
- maxResults: { type: "number", description: "\u8FD4\u56DE\u51E0\u6761\uFF08\u9ED8\u8BA4 5\uFF0C\u6700\u5927 20\uFF09" },
1024
- recencyDays: { type: "number", description: "\u53EA\u8981\u6700\u8FD1 N \u5929\u5185\u53D1\u5E03\u7684" },
1060
+ query: { type: "string", description: "Search query." },
1061
+ maxResults: {
1062
+ type: "number",
1063
+ description: "How many results to return (default 5, max 20)."
1064
+ },
1065
+ recencyDays: {
1066
+ type: "number",
1067
+ description: "Only results published within the last N days."
1068
+ },
1025
1069
  domains: {
1026
1070
  type: "array",
1027
1071
  items: { type: "string" },
1028
- description: '\u53EA\u5728\u8FD9\u4E9B\u57DF\u540D\u91CC\u641C\uFF0C\u4F8B\u5982 ["nodejs.org"]'
1072
+ description: 'Only search within these domains, e.g. ["nodejs.org"].'
1029
1073
  },
1030
- excludeDomains: { type: "array", items: { type: "string" }, description: "\u6392\u9664\u8FD9\u4E9B\u57DF\u540D" }
1074
+ excludeDomains: {
1075
+ type: "array",
1076
+ items: { type: "string" },
1077
+ description: "Exclude these domains."
1078
+ }
1031
1079
  },
1032
1080
  required: ["query"]
1033
1081
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epoch-agent/plugin-web",
3
- "version": "3.2.0",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "description": "Web 抓取插件 — URL 安全校验 + HTML→Markdown + SSRF 防护",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  "dist"
23
23
  ],
24
24
  "dependencies": {
25
- "@epoch-agent/infra": "3.2.0",
26
- "@epoch-agent/protocol": "3.2.0"
25
+ "@epoch-agent/infra": "4.0.0",
26
+ "@epoch-agent/protocol": "4.0.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.0.0",