@te-river/opencode-team-mode 1.7.7 → 1.7.9

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.
Files changed (41) hide show
  1. package/README.md +1010 -1010
  2. package/README.zh-CN.md +907 -907
  3. package/dist/host/v2-browser-gate.js +2 -2
  4. package/dist/host/v2-offload.js +6 -6
  5. package/dist/host/v2-retry.d.ts +24 -11
  6. package/dist/host/v2-retry.d.ts.map +1 -1
  7. package/dist/host/v2-retry.js +81 -15
  8. package/dist/host/v2-retry.js.map +1 -1
  9. package/dist/prompts/specialists.js +286 -286
  10. package/dist/task-offload.js +1 -1
  11. package/dist/tm/config-layers.d.ts +5 -2
  12. package/dist/tm/config-layers.d.ts.map +1 -1
  13. package/dist/tm/config-layers.js +9 -6
  14. package/dist/tm/config-layers.js.map +1 -1
  15. package/dist/tm/config.d.ts.map +1 -1
  16. package/dist/tm/config.js +7 -1
  17. package/dist/tm/config.js.map +1 -1
  18. package/dist/tm/ledger.js +5 -5
  19. package/dist/tm/memory.js +9 -9
  20. package/dist/tm/search.d.ts +3 -0
  21. package/dist/tm/search.d.ts.map +1 -1
  22. package/dist/tm/search.js +67 -15
  23. package/dist/tm/search.js.map +1 -1
  24. package/dist/tm/tools.js +3 -3
  25. package/dist/tm/webfetch.d.ts +94 -5
  26. package/dist/tm/webfetch.d.ts.map +1 -1
  27. package/dist/tm/webfetch.js +285 -64
  28. package/dist/tm/webfetch.js.map +1 -1
  29. package/docs/installation-v2.md +11 -6
  30. package/docs/installation.md +505 -505
  31. package/docs/research/agent-data-exchange.md +117 -117
  32. package/docs/research/browser-pane.md +94 -94
  33. package/docs/research/host-http-api.md +132 -132
  34. package/docs/research/host-subagent-injection.md +187 -187
  35. package/docs/research/plugin-loader-contract.md +261 -261
  36. package/docs/research/v2-builtin-tools.md +447 -447
  37. package/docs/v2-port-notes.md +134 -78
  38. package/index.js +7 -7
  39. package/package.json +64 -64
  40. package/scripts/gen-v2-config.mjs +1 -1
  41. package/scripts/lib/config-surgery.cjs +239 -239
@@ -19,8 +19,13 @@
19
19
  * allowlist (an allowlisted shortener cannot bounce off-site);
20
20
  * - a URL naming an env file (isEnvFilePath) is refused — the R6 red
21
21
  * line applies to remote spellings too;
22
- * - response body capped at 2 MB, whole fetch under a hard 20 s timeout,
23
- * HTML stripped to text before governance.
22
+ * - response body capped at 2 MB, each request under a hard 20 s timeout,
23
+ * HTML stripped to text before governance;
24
+ * - a FAILED REQUEST (network throw, per-attempt timeout, HTTP 429/5xx) is
25
+ * retried on the fixed 5s/10s/20s ladder (max 3 retries, same config the
26
+ * provider-error governor reads) and the reply says when it was used —
27
+ * a policy refusal (allowlist, red line, env-file, 403/418, other 4xx) is
28
+ * never re-asked, because asking again earns the same verdict.
24
29
  */
25
30
  import { type TmAskFn } from "./perm-ask.js";
26
31
  import type { ToolResult } from "../types.js";
@@ -151,6 +156,10 @@ export interface WebFetchResult {
151
156
  /** Set when the body came from the URL cache instead of the network — the
152
157
  * reply says so, because a re-served page is not a freshly-verified one. */
153
158
  cachedAt?: number;
159
+ /** The HTTP retries actually taken before this body arrived (absent = the
160
+ * first request answered). Rides up to the reply so 第N次重试后成功 is a
161
+ * measurement of what happened, not a restatement of the config. */
162
+ retries?: number[];
154
163
  }
155
164
  export type FetchImpl = (input: string, init?: Record<string, unknown>) => Promise<{
156
165
  ok?: boolean;
@@ -167,12 +176,76 @@ export type FetchImpl = (input: string, init?: Record<string, unknown>) => Promi
167
176
  /** A 429/503 that says WHEN, not just NO. Only the delta-seconds spelling
168
177
  * becomes a sentence: an HTTP-date would be laundered into a countdown the
169
178
  * agent has no way to honor (there is no sleep in this toolset), and a wrong
170
- * number is worse than no number. */
171
- export declare function retryAfterNote(raw: unknown): string;
179
+ * number is worse than no number.
180
+ * `retriesTaken` keeps the advice consistent with what this tool just DID:
181
+ * telling the agent "别对同一 URL 原地重试" while the reply carries our own
182
+ * 「重试3次」 is one sentence contradicting the other (review finding #4).
183
+ * Either way the Retry-After number stays visible. */
184
+ export declare function retryAfterNote(raw: unknown, retriesTaken?: number): string;
185
+ /** Retries per request: attempt #1 waits 5s, #2 waits 10s, #3 waits 20s, then
186
+ * the failure is reported. Same ladder the provider-error governor promises,
187
+ * derived from the SAME config (`retryBaseMs` doubling, no jitter), so the two
188
+ * failure surfaces cannot drift into two different meanings of "retry". */
189
+ export declare const HTTP_RETRY_MAX = 3;
190
+ /** A transient HTTP failure worth asking again: 429 (rate limited) and any 5xx.
191
+ * Everything else in the 4xx band is a VERDICT about this request — a retry
192
+ * would earn the same bytes, and 403/418 carry their own browser directive. */
193
+ export declare function isRetryableHttpStatus(status: number): boolean;
194
+ /** Pure so a test can pin the ladder without sleeping: retry `attempt` (1-based)
195
+ * waits `baseMs × 2^(attempt-1)` clamped to `maxMs`; attempt 0 or anything past
196
+ * `HTTP_RETRY_MAX` waits nothing (0 = no further retry). The clamp is what
197
+ * makes the docs' promise true (review finding #3): with `retryBaseMs > 15000`
198
+ * the un-clamped third rung (base×4) exceeded `retryMaxMs`, while the `retry`
199
+ * key says BOTH surfaces share one capped ladder. Defaults read from the
200
+ * registry, so the shipped path stays exactly 5s/10s/20s (all under 60s). */
201
+ export declare function httpRetryDelayMs(attempt: number, baseMs?: number, maxMs?: number): number;
202
+ export interface HttpRetryPolicy {
203
+ enabled: boolean;
204
+ baseMs: number;
205
+ maxRetries: number;
206
+ /** One rung's ceiling — the registry's `retryMaxMs`, threaded so the ladder
207
+ * and the governor cannot drift into two meanings of "capped". */
208
+ maxMs: number;
209
+ }
210
+ /** The registry is the one source of truth: `retry: "off"` disables the HTTP
211
+ * retries as well as the governor's directive, and `retryBaseMs` sets the
212
+ * first wait. Callers holding a resolved `TmConfig` pass it in; a caller with
213
+ * none (the search engine legs' inner helper, a PTC bridge) gets the defaults. */
214
+ export declare function httpRetryFromConfig(cfg?: Partial<Pick<TmConfig, "retry" | "retryBaseMs">>): HttpRetryPolicy;
215
+ /** The same policy from an explicit per-call partial (what `fetchWebText`'s
216
+ * `retry` option carries): every field falls back to the registry, never to a
217
+ * guessed number, so a caller that passes `{}` retries exactly like the tool.
218
+ * `maxRetries` is CLAMPED to HTTP_RETRY_MAX (review finding #5): the ladder has
219
+ * three rungs, and `httpRetryDelayMs` answers 0 past them — an accepted 9 would
220
+ * take six zero-delay "retries" the note renderers used to print as 「1s」, a
221
+ * wait that never happened. */
222
+ export declare function resolveHttpRetry(policy?: Partial<HttpRetryPolicy>): HttpRetryPolicy;
223
+ /** Honest labelling (design goal #6): a page that arrived on the third try is
224
+ * not the same fact as a page that arrived on the first, and a failure after
225
+ * three tries says how long the caller waited for nothing. Zero-delay entries
226
+ * are filtered before rendering (review finding #5): a 0 is "the ladder was
227
+ * already walked", and `Math.max(1, round(0/1000))` would print it as a 「1s」
228
+ * wait that never happened. With the maxRetries clamp these cannot reach the
229
+ * list any more — this is the belt under the braces. */
230
+ export declare function httpRetryNote(delaysMs: readonly number[]): string;
231
+ /** The failure counterpart — how many times, at what waits, still no answer.
232
+ * Zero-delay entries filtered for the same reason as above. */
233
+ export declare function httpRetryFailNote(delaysMs: readonly number[]): string;
172
234
  /**
173
235
  * Fetch a URL with hard timeout, byte cap, and MANUAL redirects — every
174
236
  * hop is re-checked against the allowlist before it is requested.
175
- * `fetchImpl` is injectable for tests (default: globalThis.fetch).
237
+ * `fetchImpl` is injectable for tests (default: globalThis.fetch), as is
238
+ * `sleep` (default: setTimeout) so the ladder can be pinned without waiting
239
+ * on it.
240
+ *
241
+ * HTTP retries: a failed request (network throw, per-attempt timeout, 429,
242
+ * 5xx) is retried up to `HTTP_RETRY_MAX` times with the fixed 5s/10s/20s
243
+ * ladder. The timeout stays PER ATTEMPT, so the worst case for one URL is
244
+ * 4 × 20s of fetching plus 35s of waiting ≈ 115s — accepted deliberately:
245
+ * a transient failure that clears in 20s should not cost the caller a new
246
+ * round, and the reply says out loud when the ladder was used up.
247
+ * A policy refusal is never retried, and a retry re-requests THIS hop only —
248
+ * it never re-follows a redirect chain and never re-asks a dialog.
176
249
  */
177
250
  export declare function fetchWebText(url: URL, allowlist: readonly string[], opts?: {
178
251
  timeoutMs?: number;
@@ -203,6 +276,19 @@ export declare function fetchWebText(url: URL, allowlist: readonly string[], opt
203
276
  * content-type — which is also why the reply says so when a body arrived
204
277
  * as Markdown. A re-negotiation is a `fresh: true` away. */
205
278
  accept?: string;
279
+ /** HTTP retry policy (see `httpRetryFromConfig`). Omitted = the registry
280
+ * defaults, so tm_webfetch, every tm_search engine leg and the PTC bridge
281
+ * all retry the same way without repeating the knobs at each call site. */
282
+ retry?: Partial<HttpRetryPolicy>;
283
+ /** Injectable sleep so a test can pin the 5s/10s/20s ladder instantly. */
284
+ sleep?: (ms: number) => Promise<void>;
285
+ /** Audit sink: one row per retry ACTUALLY taken (the caller writes it to the
286
+ * trajectory — fetchWebText owns no pipeline instance of its own). */
287
+ onRetry?: (row: {
288
+ attempt: number;
289
+ delayMs: number;
290
+ reason: string;
291
+ }) => void;
206
292
  }): Promise<WebFetchResult>;
207
293
  /** Content extraction: HTML stripped to text; JSON kept for the json
208
294
  * preview branch; anything else passes through untouched. */
@@ -221,6 +307,9 @@ export declare function buildTmWebfetchTool(deps: {
221
307
  /** Shared URL cache (tm/index.ts owns the one instance so tm_search and
222
308
  * tm_webfetch cannot drift into two stores of the same page). */
223
309
  cache?: WebCache;
310
+ /** Injectable sleep for the HTTP retry ladder (tests pin 5s/10s/20s without
311
+ * waiting on it). Omitted = real setTimeout. */
312
+ sleep?: (ms: number) => Promise<void>;
224
313
  }): {
225
314
  description: string;
226
315
  args: Record<string, unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"webfetch.d.ts","sourceRoot":"","sources":["../../src/tm/webfetch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"webfetch.d.ts","sourceRoot":"","sources":["../../src/tm/webfetch.ts"],"names":[],"mappings":"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"}
@@ -19,12 +19,17 @@
19
19
  * allowlist (an allowlisted shortener cannot bounce off-site);
20
20
  * - a URL naming an env file (isEnvFilePath) is refused — the R6 red
21
21
  * line applies to remote spellings too;
22
- * - response body capped at 2 MB, whole fetch under a hard 20 s timeout,
23
- * HTML stripped to text before governance.
22
+ * - response body capped at 2 MB, each request under a hard 20 s timeout,
23
+ * HTML stripped to text before governance;
24
+ * - a FAILED REQUEST (network throw, per-attempt timeout, HTTP 429/5xx) is
25
+ * retried on the fixed 5s/10s/20s ladder (max 3 retries, same config the
26
+ * provider-error governor reads) and the reply says when it was used —
27
+ * a policy refusal (allowlist, red line, env-file, 403/418, other 4xx) is
28
+ * never re-asked, because asking again earns the same verdict.
24
29
  */
25
30
  import { isEnvFilePath } from "../envprotect.js";
26
31
  import { askFnOf, askGrantNote, askRefusalNote, askUserForTarget, askUserForTargetDetailed } from "./perm-ask.js";
27
- import { shorten, DEFAULT_WEBFETCH_DOMAINS } from "./config.js";
32
+ import { shorten, resolveConfig, DEFAULT_WEBFETCH_DOMAINS } from "./config.js";
28
33
  /** T4 (tm_search quality upgrade): the two new no-key engine hosts.
29
34
  * DEFAULT_WEBFETCH_DOMAINS itself lives in config.ts (owned by the P0
30
35
  * batch — NOT editable by this package), so the seed extension is applied
@@ -481,21 +486,155 @@ async function readBodyCapped(res, maxBytes) {
481
486
  /** A 429/503 that says WHEN, not just NO. Only the delta-seconds spelling
482
487
  * becomes a sentence: an HTTP-date would be laundered into a countdown the
483
488
  * agent has no way to honor (there is no sleep in this toolset), and a wrong
484
- * number is worse than no number. */
485
- export function retryAfterNote(raw) {
489
+ * number is worse than no number.
490
+ * `retriesTaken` keeps the advice consistent with what this tool just DID:
491
+ * telling the agent "别对同一 URL 原地重试" while the reply carries our own
492
+ * 「重试3次」 is one sentence contradicting the other (review finding #4).
493
+ * Either way the Retry-After number stays visible. */
494
+ export function retryAfterNote(raw, retriesTaken = 0) {
486
495
  const v = String(raw ?? "").trim();
487
496
  if (!/^\d{1,6}$/.test(v))
488
497
  return "";
489
498
  const s = Number(v);
490
499
  if (!s)
491
500
  return "";
501
+ if (retriesTaken > 0) {
502
+ return (` · Retry-After: ${s} 秒——服务端建议等 ${s} 秒;本工具已按 5s/10s/20s 重试过 ${retriesTaken} 次仍被限流,` +
503
+ `别再原地重问,换别的来源(tm_search 其它引擎 / 直接源站)。`);
504
+ }
492
505
  return (` · Retry-After: ${s} 秒——这是"待会儿再来",不是"这页没了"。` +
493
506
  `本轮先换别的来源(tm_search 其它引擎 / 直接源站),别对同一 URL 原地重试。`);
494
507
  }
508
+ // ---------- HTTP-layer retry ladder (the fixed 5s / 10s / 20s) ----------
509
+ /** Retries per request: attempt #1 waits 5s, #2 waits 10s, #3 waits 20s, then
510
+ * the failure is reported. Same ladder the provider-error governor promises,
511
+ * derived from the SAME config (`retryBaseMs` doubling, no jitter), so the two
512
+ * failure surfaces cannot drift into two different meanings of "retry". */
513
+ export const HTTP_RETRY_MAX = 3;
514
+ /** A transient HTTP failure worth asking again: 429 (rate limited) and any 5xx.
515
+ * Everything else in the 4xx band is a VERDICT about this request — a retry
516
+ * would earn the same bytes, and 403/418 carry their own browser directive. */
517
+ export function isRetryableHttpStatus(status) {
518
+ return status === 429 || (status >= 500 && status <= 599);
519
+ }
520
+ /** Pure so a test can pin the ladder without sleeping: retry `attempt` (1-based)
521
+ * waits `baseMs × 2^(attempt-1)` clamped to `maxMs`; attempt 0 or anything past
522
+ * `HTTP_RETRY_MAX` waits nothing (0 = no further retry). The clamp is what
523
+ * makes the docs' promise true (review finding #3): with `retryBaseMs > 15000`
524
+ * the un-clamped third rung (base×4) exceeded `retryMaxMs`, while the `retry`
525
+ * key says BOTH surfaces share one capped ladder. Defaults read from the
526
+ * registry, so the shipped path stays exactly 5s/10s/20s (all under 60s). */
527
+ export function httpRetryDelayMs(attempt, baseMs, maxMs) {
528
+ const a = Number.isFinite(attempt) ? Math.floor(attempt) : 0;
529
+ if (a < 1 || a > HTTP_RETRY_MAX)
530
+ return 0;
531
+ const base = Number.isFinite(baseMs) && baseMs > 0 ? baseMs : resolveConfig().retryBaseMs;
532
+ const max = Number.isFinite(maxMs) && maxMs > 0 ? maxMs : resolveConfig().retryMaxMs;
533
+ return Math.min(Math.round(base * Math.pow(2, a - 1)), max);
534
+ }
535
+ /** The registry is the one source of truth: `retry: "off"` disables the HTTP
536
+ * retries as well as the governor's directive, and `retryBaseMs` sets the
537
+ * first wait. Callers holding a resolved `TmConfig` pass it in; a caller with
538
+ * none (the search engine legs' inner helper, a PTC bridge) gets the defaults. */
539
+ export function httpRetryFromConfig(cfg = {}) {
540
+ const base = resolveConfig();
541
+ return {
542
+ enabled: (cfg.retry ?? base.retry) !== "off",
543
+ baseMs: Number.isFinite(cfg.retryBaseMs) && cfg.retryBaseMs > 0 ? cfg.retryBaseMs : base.retryBaseMs,
544
+ maxRetries: HTTP_RETRY_MAX,
545
+ maxMs: base.retryMaxMs,
546
+ };
547
+ }
548
+ /** The same policy from an explicit per-call partial (what `fetchWebText`'s
549
+ * `retry` option carries): every field falls back to the registry, never to a
550
+ * guessed number, so a caller that passes `{}` retries exactly like the tool.
551
+ * `maxRetries` is CLAMPED to HTTP_RETRY_MAX (review finding #5): the ladder has
552
+ * three rungs, and `httpRetryDelayMs` answers 0 past them — an accepted 9 would
553
+ * take six zero-delay "retries" the note renderers used to print as 「1s」, a
554
+ * wait that never happened. */
555
+ export function resolveHttpRetry(policy = {}) {
556
+ const base = resolveConfig();
557
+ return {
558
+ enabled: policy.enabled ?? base.retry !== "off",
559
+ baseMs: Number.isFinite(policy.baseMs) && policy.baseMs > 0 ? policy.baseMs : base.retryBaseMs,
560
+ maxRetries: Number.isFinite(policy.maxRetries) && policy.maxRetries >= 0
561
+ ? Math.min(Math.floor(policy.maxRetries), HTTP_RETRY_MAX)
562
+ : HTTP_RETRY_MAX,
563
+ maxMs: Number.isFinite(policy.maxMs) && policy.maxMs > 0 ? policy.maxMs : base.retryMaxMs,
564
+ };
565
+ }
566
+ /** Honest labelling (design goal #6): a page that arrived on the third try is
567
+ * not the same fact as a page that arrived on the first, and a failure after
568
+ * three tries says how long the caller waited for nothing. Zero-delay entries
569
+ * are filtered before rendering (review finding #5): a 0 is "the ladder was
570
+ * already walked", and `Math.max(1, round(0/1000))` would print it as a 「1s」
571
+ * wait that never happened. With the maxRetries clamp these cannot reach the
572
+ * list any more — this is the belt under the braces. */
573
+ export function httpRetryNote(delaysMs) {
574
+ const ds = delaysMs.filter((d) => Number.isFinite(d) && d > 0);
575
+ if (!ds.length)
576
+ return "";
577
+ const waited = ds.map((d) => `${Math.max(1, Math.round(d / 1000))}s`).join("+");
578
+ return `(第${ds.length}次重试后成功 · 已等待${waited})`;
579
+ }
580
+ /** The failure counterpart — how many times, at what waits, still no answer.
581
+ * Zero-delay entries filtered for the same reason as above. */
582
+ export function httpRetryFailNote(delaysMs) {
583
+ const ds = delaysMs.filter((d) => Number.isFinite(d) && d > 0);
584
+ if (!ds.length)
585
+ return "";
586
+ const ladder = ds.map((d) => `${Math.max(1, Math.round(d / 1000))}s`).join("/");
587
+ return `(重试${ds.length}次 · ${ladder} · 仍失败)`;
588
+ }
589
+ /** A POLICY verdict, not a failed request: allowlist/dialog refusal, 403/418,
590
+ * a non-429 4xx, a redirect without a usable Location. Never retried. */
591
+ function fatalFetchError(message) {
592
+ const e = new Error(message);
593
+ e.retryable = false;
594
+ return e;
595
+ }
596
+ /** A status the caller may ask again about (429 / 5xx). */
597
+ function transientFetchError(message) {
598
+ const e = new Error(message);
599
+ e.retryable = true;
600
+ return e;
601
+ }
602
+ const isFatalFetchError = (err) => err?.retryable === false;
603
+ /** `AbortError` is a timeout, anything else that reached the catch without a
604
+ * fatal tag is a network failure — the two shapes a retry can actually fix. */
605
+ function retryReason(err) {
606
+ const e = err;
607
+ if (e?.name === "AbortError")
608
+ return "timeout";
609
+ return String(e?.name ?? e?.message ?? "error").slice(0, 40) || "network error";
610
+ }
611
+ /** Stamp the exhausted-ladder fact onto the error the caller will read, without
612
+ * replacing it (an `AbortError` that lost its name would print as unknown). */
613
+ function tagHttpRetryExhausted(err, delaysMs) {
614
+ const e = err;
615
+ if (!e)
616
+ return;
617
+ const note = httpRetryFailNote(delaysMs);
618
+ if (!note)
619
+ return;
620
+ e.retryNote = note;
621
+ e.message = `${String(e.message ?? "")}\n${note}`;
622
+ }
495
623
  /**
496
624
  * Fetch a URL with hard timeout, byte cap, and MANUAL redirects — every
497
625
  * hop is re-checked against the allowlist before it is requested.
498
- * `fetchImpl` is injectable for tests (default: globalThis.fetch).
626
+ * `fetchImpl` is injectable for tests (default: globalThis.fetch), as is
627
+ * `sleep` (default: setTimeout) so the ladder can be pinned without waiting
628
+ * on it.
629
+ *
630
+ * HTTP retries: a failed request (network throw, per-attempt timeout, 429,
631
+ * 5xx) is retried up to `HTTP_RETRY_MAX` times with the fixed 5s/10s/20s
632
+ * ladder. The timeout stays PER ATTEMPT, so the worst case for one URL is
633
+ * 4 × 20s of fetching plus 35s of waiting ≈ 115s — accepted deliberately:
634
+ * a transient failure that clears in 20s should not cost the caller a new
635
+ * round, and the reply says out loud when the ladder was used up.
636
+ * A policy refusal is never retried, and a retry re-requests THIS hop only —
637
+ * it never re-follows a redirect chain and never re-asks a dialog.
499
638
  */
500
639
  export async function fetchWebText(url, allowlist, opts = {}) {
501
640
  const doFetch = opts.fetchImpl ?? globalThis.fetch;
@@ -505,6 +644,23 @@ export async function fetchWebText(url, allowlist, opts = {}) {
505
644
  const timeoutMs = opts.timeoutMs ?? WEBFETCH_TIMEOUT_MS;
506
645
  const maxBytes = opts.maxBytes ?? WEBFETCH_MAX_BYTES;
507
646
  const accept = (opts.accept && opts.accept.trim()) || WEBFETCH_ACCEPT;
647
+ const retry = resolveHttpRetry(opts.retry ?? {});
648
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
649
+ // Retries actually taken in THIS call, in the order they were slept. Both
650
+ // labels (success and exhausted) are rendered from this list, so the reply
651
+ // can only ever claim waiting for time this function actually spent.
652
+ const delays = [];
653
+ const takeRetry = async (attempt, reason) => {
654
+ const delayMs = httpRetryDelayMs(attempt + 1, retry.baseMs, retry.maxMs);
655
+ delays.push(delayMs);
656
+ try {
657
+ opts.onRetry?.({ attempt: attempt + 1, delayMs, reason });
658
+ }
659
+ catch {
660
+ /* the trajectory line is an extra, never a reason to fail the fetch */
661
+ }
662
+ await sleep(delayMs);
663
+ };
508
664
  let current = url;
509
665
  const approvedHosts = opts.skipAskHosts ?? new Set();
510
666
  // Every hop, in order. Without this an allowlisted shortener that bounces
@@ -517,7 +673,7 @@ export async function fetchWebText(url, allowlist, opts = {}) {
517
673
  const seq = hopHosts.filter((h, i) => i === 0 || h !== hopHosts[i - 1]);
518
674
  return seq.length > 1 ? ` · 跳转链: ${seq.join(" → ")}(停在第 ${hopHosts.length} 跳)` : "";
519
675
  };
520
- for (let hop = 0; hop <= WEBFETCH_MAX_REDIRECTS; hop++) {
676
+ hopLoop: for (let hop = 0; hop <= WEBFETCH_MAX_REDIRECTS; hop++) {
521
677
  hopHosts.push(current.hostname);
522
678
  // every hop re-checked — an allowlisted shortener cannot bounce off-site
523
679
  const verdict = checkWebUrl(current.toString(), allowlist);
@@ -549,53 +705,94 @@ export async function fetchWebText(url, allowlist, opts = {}) {
549
705
  if (hit)
550
706
  return { text: hit.body, contentType: hit.contentType, finalUrl: current.toString(), cachedAt: hit.at };
551
707
  }
552
- const ctrl = new AbortController();
553
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
554
- try {
555
- const res = (await doFetch(current.toString(), {
556
- redirect: "manual",
557
- signal: ctrl.signal,
558
- headers: {
559
- "user-agent": WEBFETCH_UA,
560
- accept,
561
- "accept-language": WEBFETCH_ACCEPT_LANGUAGE,
562
- },
563
- }));
564
- if (REDIRECT_STATUSES.has(res.status)) {
565
- const loc = res.headers.get("location");
566
- if (!loc)
567
- throw new Error(`重定向 ${res.status} 缺少 Location 头${trail()}`);
568
- current = new URL(loc, current);
569
- continue;
708
+ // One attempt = ONE request for THIS hop. A retry re-requests this same
709
+ // URL — it never re-follows the chain, never re-asks a dialog, and never
710
+ // reaches a hop whose allowlist / red-line verdict already refused (those
711
+ // throw above this loop, outside the retry's reach).
712
+ for (let attempt = 0;; attempt++) {
713
+ const ctrl = new AbortController();
714
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
715
+ try {
716
+ const res = (await doFetch(current.toString(), {
717
+ redirect: "manual",
718
+ signal: ctrl.signal,
719
+ headers: {
720
+ "user-agent": WEBFETCH_UA,
721
+ accept,
722
+ "accept-language": WEBFETCH_ACCEPT_LANGUAGE,
723
+ },
724
+ }));
725
+ if (REDIRECT_STATUSES.has(res.status)) {
726
+ const loc = res.headers.get("location");
727
+ if (!loc)
728
+ throw fatalFetchError(`重定向 ${res.status} 缺少 Location 头${trail()}`);
729
+ // A malformed Location is a VERDICT about this response, not a failed
730
+ // request — asking again earns the same broken header. Without the
731
+ // fatal tag (review finding #2) a TypeError from the URL parse looked
732
+ // transient and burned the whole 35 s ladder on it.
733
+ try {
734
+ current = new URL(loc, current);
735
+ }
736
+ catch {
737
+ throw fatalFetchError(`重定向 ${res.status} 的 Location 无法解析${trail()}`);
738
+ }
739
+ continue hopLoop;
740
+ }
741
+ // A 429/5xx with ladder left is a retry, not a result — the reporting
742
+ // code below only ever sees a response the caller has to be told about.
743
+ if (isRetryableHttpStatus(res.status) && retry.enabled && attempt < retry.maxRetries) {
744
+ await takeRetry(attempt, `HTTP ${res.status}`);
745
+ continue;
746
+ }
747
+ if (res.status === 403 || res.status === 418) {
748
+ // Already sent real-Chrome UA + Accept headers and still rejected:
749
+ // the gate is JS-challenge / TLS-fingerprint based — only a real
750
+ // browser passes. DIRECTIVE to the agent: use the host's native browser.
751
+ const u = current.toString();
752
+ const known = u.includes("baike.baidu.com")
753
+ ? "(baike.baidu.com 是 JS 渲染 SPA)"
754
+ : u.includes("zhihu.com")
755
+ ? "(知乎对 fetch 一律 403)"
756
+ : "";
757
+ throw fatalFetchError(`HTTP ${res.status}${known}——伪装浏览器请求头后仍被拒,该站需要真实浏览器会话。` +
758
+ `下一步:用宿主原生浏览器打开此 URL(browser_navigate → browser_snapshot)` +
759
+ `(${shorten(u, 120)});或换 tm_search 引擎 / 找直接源站。${trail()}`);
760
+ }
761
+ if (res.status < 200 || res.status >= 300) {
762
+ // 429/5xx exhausted their ladder → still a transient, and the note
763
+ // says so. Any other 4xx is a verdict on THIS request: no retry.
764
+ throw (isRetryableHttpStatus(res.status) ? transientFetchError : fatalFetchError)(`HTTP ${res.status}(最终 URL: ${shorten(current.toString(), 120)})${retryAfterNote(res.headers.get("retry-after"), delays.length)}${trail()}`);
765
+ }
766
+ const contentType = (res.headers.get("content-type") ?? "text/plain").toLowerCase();
767
+ const raw = await readBodyCapped(res, maxBytes);
768
+ // Store the RAW body (pre-extraction) under the SAME static-allowlist
769
+ // condition; a 2xx-only rule keeps an error page from outliving itself
770
+ // as "the content of that URL".
771
+ if (staticAllow && opts.cache?.enabled())
772
+ opts.cache.put(current.toString(), { status: res.status, contentType, body: raw });
773
+ return {
774
+ text: raw,
775
+ contentType,
776
+ finalUrl: current.toString(),
777
+ ...(delays.length ? { retries: [...delays] } : {}),
778
+ };
570
779
  }
571
- if (res.status === 403 || res.status === 418) {
572
- // Already sent real-Chrome UA + Accept headers and still rejected:
573
- // the gate is JS-challenge / TLS-fingerprint based — only a real
574
- // browser passes. DIRECTIVE to the agent: use the host's native browser.
575
- const u = current.toString();
576
- const known = u.includes("baike.baidu.com")
577
- ? "(baike.baidu.com 是 JS 渲染 SPA)"
578
- : u.includes("zhihu.com")
579
- ? "(知乎对 fetch 一律 403)"
580
- : "";
581
- throw new Error(`HTTP ${res.status}${known}——伪装浏览器请求头后仍被拒,该站需要真实浏览器会话。` +
582
- `下一步:用宿主原生浏览器打开此 URL(browser_navigate → browser_snapshot)` +
583
- `(${shorten(u, 120)});或换 tm_search 引擎 / 找直接源站。${trail()}`);
780
+ catch (err) {
781
+ // Only a FAILED REQUEST retries here. An error carrying a policy
782
+ // verdict is tagged fatal (allowlist refusal, 403/418, a non-429 4xx,
783
+ // a redirect without a usable Location) and leaves immediately; a bare
784
+ // throw from fetch/body-read is a network error or a timeout abort,
785
+ // which is exactly what asking again can fix.
786
+ if (retry.enabled && attempt < retry.maxRetries && !isFatalFetchError(err)) {
787
+ await takeRetry(attempt, retryReason(err));
788
+ continue;
789
+ }
790
+ tagHttpRetryExhausted(err, delays);
791
+ throw err;
584
792
  }
585
- if (res.status < 200 || res.status >= 300) {
586
- throw new Error(`HTTP ${res.status}(最终 URL: ${shorten(current.toString(), 120)})${retryAfterNote(res.headers.get("retry-after"))}${trail()}`);
793
+ finally {
794
+ clearTimeout(timer);
587
795
  }
588
- const contentType = (res.headers.get("content-type") ?? "text/plain").toLowerCase();
589
- const raw = await readBodyCapped(res, maxBytes);
590
- // Store the RAW body (pre-extraction) under the SAME static-allowlist
591
- // condition; a 2xx-only rule keeps an error page from outliving itself
592
- // as "the content of that URL".
593
- if (staticAllow && opts.cache?.enabled())
594
- opts.cache.put(current.toString(), { status: res.status, contentType, body: raw });
595
- return { text: raw, contentType, finalUrl: current.toString() };
596
- }
597
- finally {
598
- clearTimeout(timer);
599
796
  }
600
797
  }
601
798
  throw new Error(`重定向超过 ${WEBFETCH_MAX_REDIRECTS} 跳,放弃抓取`);
@@ -609,13 +806,15 @@ export function extractWebResponse(raw, contentType) {
609
806
  return { text: raw, isHtml: false };
610
807
  }
611
808
  // ---------- tool definition ----------
612
- const WEBFETCH_DESCRIPTION = `Fetch a web page through the governed pipeline (domain allowlist + threshold offload) — use it for a KNOWN URL only. For open-ended lookups use tm_search FIRST (multi-engine, extracted hit lists); never hand-build search URLs here.
613
-
614
- - ANTI-PATTERN: do NOT hand-build search-engine URLs here — that is tm_search's job (multi-engine, extracted hit lists). Use THIS tool for a page you already know: a direct article/wiki term, a registry JSON endpoint, a raw file.
615
- - Seeded hosts (CN-reachable, no API keys): moegirl.org.cn (parent — all subdomains: mobile. term https://mobile.moegirl.org.cn/TERM, mzh. main site) · search.bilibili.com · cn.bing.com (search: https://cn.bing.com/search?q=QUERY; &ensearch=1 for international results) · baidu.com (parent: www. search /s?wd=QUERY, baike. encyclopedia entries) · www.sogou.com (https://www.sogou.com/web?query=QUERY) · www.so.com (https://www.so.com/s?q=QUERY) · registry.npmjs.org (package JSON: https://registry.npmjs.org/<pkg>/latest, search: https://registry.npmjs.org/-/v1/search?text=QUERY) · api.github.com (repo search: https://api.github.com/search/repositories?q=QUERY) · api.stackexchange.com (question search: https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=QUERY&site=stackoverflow&pagesize=10) · hn.algolia.com (HN story search: https://hn.algolia.com/api/v1/search?query=QUERY&tags=story) · raw.githubusercontent.com + gist.githubusercontent.com + github.com (docs/code/issues) · ghproxy.net (mainland mirror for github raw). URL-encode the query (CJK terms too). Search-engine result pages are auto-extracted to a title+URL hit list. Expand colloquial/abbreviated terms to canonical forms and fetch BOTH spellings.
616
- - Governance: only http(s), hosts must be allowlisted (extend via webfetchAllowedDomains, "*" opens all), redirects re-checked per hop (a refusal names the whole hop chain), HTML stripped to text — some documentation hosts (learn.microsoft.com measured 5.3x smaller) are fetched as Markdown when they honour accept: text/markdown, and the reply says so; output above offloadThreshold tokens is offloaded to a handle — page with tm_fetch (try mode:"structure" first).
617
- - Governance: out-of-allowlist hosts route through the OFFICIAL confirmation dialog (approve to proceed once; the 1-min unanswered auto-reject applies); env-file URLs and non-http(s) schemes are hard-rejected with no dialog.
618
- - Freshness: a repeated URL is served from a local TTL cache (webCacheTtlSec, default 300 s) and the reply SAYS 缓存命中 with its age. Need the page as it is NOW? Pass { fresh: true } to bypass the cache and hit the network.`;
809
+ const WEBFETCH_DESCRIPTION = `Fetch a web page through the governed pipeline (domain allowlist + threshold offload) — use it for a KNOWN URL only. For open-ended lookups use tm_search FIRST (multi-engine, extracted hit lists); never hand-build search URLs here.
810
+
811
+ - ANTI-PATTERN: do NOT hand-build search-engine URLs here — that is tm_search's job (multi-engine, extracted hit lists). Use THIS tool for a page you already know: a direct article/wiki term, a registry JSON endpoint, a raw file.
812
+ - Seeded hosts (CN-reachable, no API keys): moegirl.org.cn (parent — all subdomains: mobile. term https://mobile.moegirl.org.cn/TERM, mzh. main site) · search.bilibili.com · cn.bing.com (search: https://cn.bing.com/search?q=QUERY; &ensearch=1 for international results) · baidu.com (parent: www. search /s?wd=QUERY, baike. encyclopedia entries) · www.sogou.com (https://www.sogou.com/web?query=QUERY) · www.so.com (https://www.so.com/s?q=QUERY) · registry.npmjs.org (package JSON: https://registry.npmjs.org/<pkg>/latest, search: https://registry.npmjs.org/-/v1/search?text=QUERY) · api.github.com (repo search: https://api.github.com/search/repositories?q=QUERY) · api.stackexchange.com (question search: https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=QUERY&site=stackoverflow&pagesize=10) · hn.algolia.com (HN story search: https://hn.algolia.com/api/v1/search?query=QUERY&tags=story) · raw.githubusercontent.com + gist.githubusercontent.com + github.com (docs/code/issues) · ghproxy.net (mainland mirror for github raw). URL-encode the query (CJK terms too). Search-engine result pages are auto-extracted to a title+URL hit list. Expand colloquial/abbreviated terms to canonical forms and fetch BOTH spellings.
813
+ - Governance: only http(s), hosts must be allowlisted (extend via webfetchAllowedDomains, "*" opens all), redirects re-checked per hop (a refusal names the whole hop chain), HTML stripped to text — some documentation hosts (learn.microsoft.com measured 5.3x smaller) are fetched as Markdown when they honour accept: text/markdown, and the reply says so; output above offloadThreshold tokens is offloaded to a handle — page with tm_fetch (try mode:"structure" first).
814
+ - Governance: out-of-allowlist hosts route through the OFFICIAL confirmation dialog (approve to proceed once; the 1-min unanswered auto-reject applies); env-file URLs and non-http(s) schemes are hard-rejected with no dialog.
815
+ - Freshness: a repeated URL is served from a local TTL cache (webCacheTtlSec, default 300 s) and the reply SAYS 缓存命中 with its age. Need the page as it is NOW? Pass { fresh: true } to bypass the cache and hit the network.
816
+ - Transient failures: a network throw, a per-request 20 s timeout, an HTTP 429 or 5xx is retried up to 3 times on the fixed 5s/10s/20s ladder and the reply labels it (第N次重试后成功 / 重试3次…仍失败). A refusal (off-allowlist, red line, env-file URL, 403/418, other 4xx) is NOT retried — it is a verdict, so change source instead of re-asking. Set retry: "off" to disable both.
817
+ `;
619
818
  /** Build the tm_webfetch ToolDefinition over the SHARED main pipelines
620
819
  * instance (same step counter as tm_read/tm_grep/tm_bash — refs stay
621
820
  * unambiguous). `fetchImpl` is injectable for tests. */
@@ -674,7 +873,23 @@ export function buildTmWebfetchTool(deps) {
674
873
  // learn.microsoft.com, no-op elsewhere); the engine legs never come
675
874
  // through here, so their calibrated browser-shaped header stands.
676
875
  accept: WEBFETCH_ACCEPT_MD,
876
+ // The fixed HTTP ladder (5s/10s/20s) with the registry's own knobs, and
877
+ // one trajectory row per retry actually taken — so "重试3次" in the
878
+ // reply is falsifiable in steps.jsonl, not just asserted.
879
+ retry: httpRetryFromConfig(cfg),
880
+ sleep: deps.sleep,
881
+ onRetry: (row) => pipelines.store.appendTrajectory({
882
+ tool,
883
+ step_id: stepId,
884
+ event: "http_retry",
885
+ attempt: row.attempt,
886
+ delay_ms: row.delayMs,
887
+ reason: row.reason,
888
+ }),
677
889
  });
890
+ // A page that arrived on the second or third try is not the same fact as
891
+ // one that arrived on the first — the wait is stated, not hidden.
892
+ const retryNote = httpRetryNote(res.retries ?? []);
678
893
  // A re-served page is stated as such — an agent that believes a cache
679
894
  // hit is a fresh observation propagates a stale fact into the plan.
680
895
  let cacheNote = "";
@@ -692,7 +907,7 @@ export function buildTmWebfetchTool(deps) {
692
907
  if (hits.length > 0) {
693
908
  const sp = (verdict.ok ? verdict.url : new URL(requested)).searchParams;
694
909
  const query = sp.get("q") ?? sp.get("wd") ?? sp.get("query") ?? sp.get("keyword") ?? "";
695
- const listing = renderSearchHits(query || res.finalUrl, "webfetch", hits) + cacheNote + grantNote;
910
+ const listing = renderSearchHits(query || res.finalUrl, "webfetch", hits) + retryNote + cacheNote + grantNote;
696
911
  return toToolResult(pipelines.govern(stepId, tool, listing, {
697
912
  contentType: detectContentType(listing),
698
913
  clue: `search url=${shorten(res.finalUrl, 120)}`,
@@ -708,8 +923,11 @@ export function buildTmWebfetchTool(deps) {
708
923
  : "";
709
924
  if (!text.trim()) {
710
925
  // anti-bot / JS-rendered pages (baidu is the usual offender) return
711
- // an empty shell — tell the agent instead of storing nothing
712
- return toToolResult(tmError(tool, "execute", "页面内容为空——该站点可能是反爬或 JS 渲染页(baidu 常见)。换 tm_search 的其他引擎(bing/stackoverflow/hn)、用宿主原生浏览器打开,或直接访问数据源 URL(如 registry.npmjs.org/<pkg>/latest)。"));
926
+ // an empty shell — tell the agent instead of storing nothing. When
927
+ // the shell only arrived after walking the ladder, that wait is part
928
+ // of the fact too (review finding #6) — an empty page that cost 35 s
929
+ // is not the same experience as one that answered instantly.
930
+ return toToolResult(tmError(tool, "execute", "页面内容为空——该站点可能是反爬或 JS 渲染页(baidu 常见)。换 tm_search 的其他引擎(bing/stackoverflow/hn)、用宿主原生浏览器打开,或直接访问数据源 URL(如 registry.npmjs.org/<pkg>/latest)。" + retryNote));
713
931
  }
714
932
  const contentType = detectContentType(text);
715
933
  // Wave B M2 — `fields` dot-path projection on a JSON response body.
@@ -732,13 +950,13 @@ export function buildTmWebfetchTool(deps) {
732
950
  "--- 投影 ---",
733
951
  ].join("\n");
734
952
  const projected = proj.values.length ? `${head}\n${proj.values.join("\n")}` : head;
735
- return toToolResult(pipelines.govern(stepId, tool, projected, {
953
+ return toToolResult(pipelines.govern(stepId, tool, `${projected}${retryNote}`, {
736
954
  contentType: "text",
737
955
  clue: `url=${shorten(res.finalUrl, 120)} fields=${shorten(fields, 60)}`,
738
956
  }));
739
957
  }
740
958
  }
741
- return toToolResult(pipelines.govern(stepId, tool, text + cacheNote + mdNote + grantNote, {
959
+ return toToolResult(pipelines.govern(stepId, tool, text + retryNote + cacheNote + mdNote + grantNote, {
742
960
  contentType,
743
961
  clue: `url=${shorten(res.finalUrl, 120)}`,
744
962
  }));
@@ -746,7 +964,10 @@ export function buildTmWebfetchTool(deps) {
746
964
  catch (err) {
747
965
  const e = err;
748
966
  if (e?.name === "AbortError") {
749
- return toToolResult(tmError(tool, "execute", "抓取超时(20s)——换更具体的搜索词或稍后重试"));
967
+ // A timeout that used up the ladder says so — the per-attempt 20s is
968
+ // not the whole story of how long this call took.
969
+ const exhausted = typeof e?.retryNote === "string" ? e.retryNote : "";
970
+ return toToolResult(tmError(tool, "execute", "抓取超时(每次请求 20s)——换更具体的搜索词或稍后重试" + exhausted));
750
971
  }
751
972
  return toToolResult(tmError(tool, "execute", String(e?.message ?? err ?? "webfetch 失败")));
752
973
  }