chainlesschain 0.166.30 → 0.166.34

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 (171) hide show
  1. package/README.md +27 -2
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/assets/{AIOps-D0L1OM_i.js → AIOps-D8PJmSrh.js} +1 -1
  4. package/src/assets/web-panel/assets/{ActionButton-CeZUkX_V.js → ActionButton-DG6DqHA3.js} +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-P_7CIOew.js → Analytics-DiLleeWs.js} +3 -3
  6. package/src/assets/web-panel/assets/{AppLayout-NuqDYrlO.js → AppLayout-DxYkhlHK.js} +4 -4
  7. package/src/assets/web-panel/assets/{Artifacts-D1BTQqJM.js → Artifacts-BAW3Kdsi.js} +1 -1
  8. package/src/assets/web-panel/assets/{Audit-D9XL6DVf.js → Audit-DBr4uY6E.js} +1 -1
  9. package/src/assets/web-panel/assets/{BackgroundAgents-Ci2DB2gV.js → BackgroundAgents-Dw0r-bHX.js} +1 -1
  10. package/src/assets/web-panel/assets/{Backup-CreK3Yqn.js → Backup-AyhwYsio.js} +1 -1
  11. package/src/assets/web-panel/assets/{BaseInput-BkIRKFXX.js → BaseInput-Cj8qJotr.js} +1 -1
  12. package/src/assets/web-panel/assets/{Chat-BoJjlCDT.js → Chat-DcvimQ0u.js} +6 -6
  13. package/src/assets/web-panel/assets/ChatBubbleRenderer--joJ1e2f.js +1 -0
  14. package/src/assets/web-panel/assets/{Checkbox-BbA932P3.js → Checkbox-C2mlU77l.js} +1 -1
  15. package/src/assets/web-panel/assets/{Codegen-BS_f3kQy.js → Codegen-CAfc3YaV.js} +1 -1
  16. package/src/assets/web-panel/assets/{Col-BJ1tzpRh.js → Col-CIxoxtJI.js} +1 -1
  17. package/src/assets/web-panel/assets/{Community-DYsZZlgI.js → Community-DvufZoRg.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compact-Cq41A43W.js → Compact-55JTa99J.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compliance-CBqSz1op.js → Compliance-BllWog6P.js} +1 -1
  20. package/src/assets/web-panel/assets/{Cowork-ZAn8buD_.js → Cowork-ThsFGc5p.js} +2 -2
  21. package/src/assets/web-panel/assets/{Cron-DM4WTZQT.js → Cron-CZjl5Ig2.js} +2 -2
  22. package/src/assets/web-panel/assets/{Crosschain-S65sJZwe.js → Crosschain-DIAKAoQT.js} +1 -1
  23. package/src/assets/web-panel/assets/{DID-0Av7FTMv.js → DID-ayygwEil.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dashboard-D-1vFtiV.js → Dashboard-C3Xjm2UO.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dropdown-BZufOCir.js → Dropdown-C1S3zf-j.js} +1 -1
  26. package/src/assets/web-panel/assets/{EmailListRenderer-DJE8szQZ.js → EmailListRenderer-DRvriU-H.js} +1 -1
  27. package/src/assets/web-panel/assets/{FamilyGuardDashboard-BqqPHaZn.js → FamilyGuardDashboard-iSX2bmsD.js} +1 -1
  28. package/src/assets/web-panel/assets/{Federation-CslSyj60.js → Federation-Dk0Stkv-.js} +1 -1
  29. package/src/assets/web-panel/assets/{FormItemContext-uTfAl63o.js → FormItemContext-DasXSGff.js} +1 -1
  30. package/src/assets/web-panel/assets/{GenericCardRenderer-BIP2obER.js → GenericCardRenderer-Dv9XGlxI.js} +1 -1
  31. package/src/assets/web-panel/assets/{Git-CoVpUKSg.js → Git-B5PgXL3Z.js} +2 -2
  32. package/src/assets/web-panel/assets/{Governance-zW6mlFcK.js → Governance-B78x11e_.js} +1 -1
  33. package/src/assets/web-panel/assets/{Inference-zfu2tMaa.js → Inference-b7CX1RqW.js} +1 -1
  34. package/src/assets/web-panel/assets/{KnowledgeGraph-Dd38pMCe.js → KnowledgeGraph--MgBzMVp.js} +1 -1
  35. package/src/assets/web-panel/assets/{Logs-BhX28L73.js → Logs-CYCiGmVc.js} +2 -2
  36. package/src/assets/web-panel/assets/MarkdownRenderer-yrl10SWw.js +1 -0
  37. package/src/assets/web-panel/assets/{Marketplace-DGBKtVL6.js → Marketplace-CsbwQ0eM.js} +1 -1
  38. package/src/assets/web-panel/assets/{McpTools-C0jSXrYN.js → McpTools-0GBWHVra.js} +3 -3
  39. package/src/assets/web-panel/assets/{Memory-_xyOJV55.js → Memory-fAywmAYr.js} +2 -2
  40. package/src/assets/web-panel/assets/{MobileBridge-DGm6cS5Y.js → MobileBridge-BeNEyy3s.js} +1 -1
  41. package/src/assets/web-panel/assets/MobileProjects-Zt1cpErd.js +1 -0
  42. package/src/assets/web-panel/assets/{Mtc-CSfoeF9f.js → Mtc-CqRhJB70.js} +6 -6
  43. package/src/assets/web-panel/assets/{MtcAudit-DuXFbCYL.js → MtcAudit-BEyGZflc.js} +4 -4
  44. package/src/assets/web-panel/assets/{Multisig-CdnOBFhs.js → Multisig-luz3EBT2.js} +3 -3
  45. package/src/assets/web-panel/assets/{NLProgramming-C0URZ4u4.js → NLProgramming-Dkbvp8IX.js} +1 -1
  46. package/src/assets/web-panel/assets/{Notes-CJznx6ft.js → Notes-Cf9I2K8j.js} +3 -3
  47. package/src/assets/web-panel/assets/{NotificationSettings-DJcqYoZP.js → NotificationSettings-BTfuzynL.js} +1 -1
  48. package/src/assets/web-panel/assets/OrderTableRenderer-DBmCtl2H.js +1 -0
  49. package/src/assets/web-panel/assets/{Organization-ucvP5Gzp.js → Organization-BbG3-vDy.js} +4 -4
  50. package/src/assets/web-panel/assets/{Overflow-Be3DoWvY.js → Overflow-CMtFKEIQ.js} +1 -1
  51. package/src/assets/web-panel/assets/{P2P-BXV17CWM.js → P2P-6SAEb_lU.js} +2 -2
  52. package/src/assets/web-panel/assets/PdhVaultBrowser-CWG2OLvh.js +7 -0
  53. package/src/assets/web-panel/assets/{Permissions-Bn0mjFJA.js → Permissions-ClZxZKZl.js} +4 -4
  54. package/src/assets/web-panel/assets/{PersonalDataHub-CQ2uLeuD.js → PersonalDataHub-BYkyoZDT.js} +3 -3
  55. package/src/assets/web-panel/assets/{Pipeline-DWv7KJKL.js → Pipeline-B28ZgADI.js} +1 -1
  56. package/src/assets/web-panel/assets/{Privacy-B4I0G5_J.js → Privacy-BxtZywbB.js} +1 -1
  57. package/src/assets/web-panel/assets/{ProjectInit-CDGy6B7j.js → ProjectInit-DPqoRnxK.js} +2 -2
  58. package/src/assets/web-panel/assets/{ProjectSettings-CXUq9nGH.js → ProjectSettings-QjKvMkau.js} +2 -2
  59. package/src/assets/web-panel/assets/{Projects-B-IA5qsB.js → Projects-Cp0Z8ZG0.js} +1 -1
  60. package/src/assets/web-panel/assets/{Providers-Card8pXM.js → Providers-CrS-rbyb.js} +1 -1
  61. package/src/assets/web-panel/assets/{QrScannerModal-DrU0hFRp.js → QrScannerModal-xTrhbVAU.js} +1 -1
  62. package/src/assets/web-panel/assets/{QuickAsk-cB-AFOxA.js → QuickAsk-DFOqJoq-.js} +1 -1
  63. package/src/assets/web-panel/assets/{Recommend-CvAvFr0u.js → Recommend-DXlKqu2-.js} +1 -1
  64. package/src/assets/web-panel/assets/{RemoteSession-BIrtatCm.js → RemoteSession-BLLaZH8K.js} +3 -3
  65. package/src/assets/web-panel/assets/{Reputation-CrRpi112.js → Reputation-CUgAdJ11.js} +1 -1
  66. package/src/assets/web-panel/assets/{Row-DmaenKNU.js → Row-DzOO3wP-.js} +1 -1
  67. package/src/assets/web-panel/assets/{RssFeed-CGZBcNLM.js → RssFeed-B6GrRvh8.js} +3 -3
  68. package/src/assets/web-panel/assets/{Search-Bvxjc4AU.js → Search-Cj3zKLb3.js} +1 -1
  69. package/src/assets/web-panel/assets/{Security-1Z-5c2n6.js → Security-LnktzidI.js} +3 -3
  70. package/src/assets/web-panel/assets/{Services-3zYh5_28.js → Services-BQ6sIc2K.js} +2 -2
  71. package/src/assets/web-panel/assets/{Skeleton-DEyGi_mq.js → Skeleton-CCSV3Jbw.js} +1 -1
  72. package/src/assets/web-panel/assets/{Skills-BjWR0303.js → Skills-CpoQ4SNk.js} +1 -1
  73. package/src/assets/web-panel/assets/{Sla-C6rE_C6V.js → Sla-DbAbgRPq.js} +1 -1
  74. package/src/assets/web-panel/assets/{SpeechSettings-DJ1DtDuf.js → SpeechSettings-C4cJ_hoD.js} +1 -1
  75. package/src/assets/web-panel/assets/{SyncSettings-B2j0LEry.js → SyncSettings-BKFzjN9Z.js} +2 -2
  76. package/src/assets/web-panel/assets/Tasks-Cf4bl7NS.js +1 -0
  77. package/src/assets/web-panel/assets/{Templates-Eb0UjdGe.js → Templates-DTEQfFAQ.js} +1 -1
  78. package/src/assets/web-panel/assets/{Tenant-BlkC3jZ8.js → Tenant-wToJWKXB.js} +1 -1
  79. package/src/assets/web-panel/assets/{Terminal-MMaa8P6V.js → Terminal-1s3c7kmF.js} +2 -2
  80. package/src/assets/web-panel/assets/{TimelineRenderer-B4EsSPro.js → TimelineRenderer-BcfsYW3u.js} +1 -1
  81. package/src/assets/web-panel/assets/{Tokens-Chk0rule.js → Tokens-Byez6FY0.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trigger-Cww9ZKjK.js → Trigger-Cq8mZO88.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trust-xSwx_Zhk.js → Trust-DUrMmEwR.js} +1 -1
  84. package/src/assets/web-panel/assets/{UkeySign-CNaC0gRj.js → UkeySign-CVHwXrVU.js} +1 -1
  85. package/src/assets/web-panel/assets/{VideoEditing-gpoRF70F.js → VideoEditing-CDKbTRUS.js} +1 -1
  86. package/src/assets/web-panel/assets/{Wallet-eSjvqmUV.js → Wallet-BqnEBIOy.js} +2 -2
  87. package/src/assets/web-panel/assets/{WebAuthn-C4T03Dyz.js → WebAuthn-CeTMpj7_.js} +4 -4
  88. package/src/assets/web-panel/assets/{WorkflowEditor-eR0JIHbW.js → WorkflowEditor-F6OgjV3C.js} +1 -1
  89. package/src/assets/web-panel/assets/{chat-CPRK3APB.js → chat-CMHUCwq3.js} +1 -1
  90. package/src/assets/web-panel/assets/{colors-BP4ZS2SA.js → colors-DzVM_fpJ.js} +1 -1
  91. package/src/assets/web-panel/assets/{compact-item-C2bvxEX0.js → compact-item-Hhmg51yT.js} +1 -1
  92. package/src/assets/web-panel/assets/{createContext-DfgFYASc.js → createContext-SwHt7x3Q.js} +1 -1
  93. package/src/assets/web-panel/assets/devWarning-5q_Y0E88.js +1 -0
  94. package/src/assets/web-panel/assets/{hasIn-BlwCGOVd.js → hasIn-HGNKkn9A.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-Bg_1EOhP.js → index--iOqM_AI.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-DDavuQ3n.js → index-AQcDW8bB.js} +3 -3
  97. package/src/assets/web-panel/assets/{index-CBJrpDfv.js → index-B6YQN5_R.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-BPezy3IJ.js → index-BDE-WiE5.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-JhYgZWbg.js → index-BDhCx17_.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-C2n5VKtr.js → index-BDt4wSl6.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-BENgpvOm.js → index-BJqNTF1S.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-CKZNLHhD.js → index-BRikmONJ.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-C3qzM-P9.js → index-B_HgLPC5.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-DLTzHq3a.js → index-Ba2Kszdy.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-DEohnDSJ.js → index-BlHB5eax.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-DQ-CB1zc.js → index-BmPYmoY3.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-5b_mMYB_.js → index-Bq9MMKno.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-IYshwMo1.js → index-BstLYy9q.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-DzHzRE9E.js → index-C-YkiVyL.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-CZHYspFu.js → index-C1QJxiOL.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-B_LuOKcc.js → index-CDtzUt75.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-9VWjFlVU.js → index-CMG_2fK2.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-QE8jhGyd.js → index-COOcGOyK.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-C6Zbp8DY.js → index-CWkWh48N.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-_5mGFZRu.js → index-Cc-d6f0F.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-BO1fPYye.js → index-ChLHLBkF.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-B-ubYfKF.js → index-Cnm4zroJ.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-BgUnGGL8.js → index-Co4RcBua.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-AbVd9bHP.js → index-D5dcWBqg.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-DZbQQtMn.js → index-DQT5thlV.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-DR2Tg8uN.js → index-Dcg7nQDe.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-DdaniU1x.js → index-DgoefbLs.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-CyMwW_p5.js → index-DyZUrnje.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-Dx3HnzcX.js → index-E-bI8HpV.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-B2D-mqRz.js → index-F7Grz3-G.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-DDGliY6u.js → index-MDdTkUpA.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-XBrnfoX6.js → index-aKV4Py_2.js} +1 -1
  128. package/src/assets/web-panel/assets/index-dl9TJMh5.js +1 -0
  129. package/src/assets/web-panel/assets/{index-B0HiXNYh.js → index-jrRtmiUh.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-DnD3N8YJ.js → index-oz0Xxs8D.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-Sk-fx_32.js → index-pwLuJGFh.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-WSC6WoDZ.js → index-sYkFp9A4.js} +1 -1
  133. package/src/assets/web-panel/assets/index-x0DDgUaT.js +1 -0
  134. package/src/assets/web-panel/assets/{initDefaultProps-CmR0B8Wx.js → initDefaultProps-C_2QM45F.js} +1 -1
  135. package/src/assets/web-panel/assets/{motion-BC0UJWJb.js → motion-DkxY04tR.js} +1 -1
  136. package/src/assets/web-panel/assets/{move-cvEJQRZ_.js → move-D0CJlA7D.js} +1 -1
  137. package/src/assets/web-panel/assets/{mtc-parser-0ZPAo5px.js → mtc-parser-DjP38YUD.js} +1 -1
  138. package/src/assets/web-panel/assets/{omit-UBXYakkw.js → omit-C9CJDaGT.js} +1 -1
  139. package/src/assets/web-panel/assets/{pickAttrs-CRZgdSvi.js → pickAttrs-D5Wq2fa8.js} +1 -1
  140. package/src/assets/web-panel/assets/{placementArrow-B_Y8sKr_.js → placementArrow-DJ6Dixs7.js} +1 -1
  141. package/src/assets/web-panel/assets/{responsiveObserve-CFgbiMsJ.js → responsiveObserve-DMmM0Dee.js} +1 -1
  142. package/src/assets/web-panel/assets/{slide-CF6LYcYS.js → slide-DipmpYA-.js} +1 -1
  143. package/src/assets/web-panel/assets/{statusUtils-Cj2PusGV.js → statusUtils-DSqw6_nU.js} +1 -1
  144. package/src/assets/web-panel/assets/{styleChecker-Czg7dDLr.js → styleChecker-t828MYjI.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFlexGapSupport-Bhi9P67E.js → useFlexGapSupport-COsSNzDQ.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFs-X9lqJXBc.js → useFs-Bq5IEzv_.js} +1 -1
  147. package/src/assets/web-panel/assets/{usePersonalDataHub-iL34aL71.js → usePersonalDataHub-CUKM0HdW.js} +1 -1
  148. package/src/assets/web-panel/assets/{vnode-DJ7TA9fc.js → vnode-l-JwAt2M.js} +1 -1
  149. package/src/assets/web-panel/assets/{zoom-DfQDsJXd.js → zoom-D7xkW-gD.js} +1 -1
  150. package/src/assets/web-panel/index.html +1 -1
  151. package/src/data/changelog.json +52 -0
  152. package/src/lib/evolution/evolution-file-witness.js +59 -13
  153. package/src/lib/evolution/evolution-ledger-file-backend.js +16 -1
  154. package/src/lib/remote-read-loop-guard.js +290 -0
  155. package/src/lib/task-progress-tracker.js +5 -1
  156. package/src/lib/text-file-search-worker.js +147 -0
  157. package/src/lib/text-file-search.js +116 -0
  158. package/src/lib/web-fetch-snapshots.js +211 -0
  159. package/src/lib/web-fetch.js +317 -26
  160. package/src/lib/web-search.js +118 -10
  161. package/src/runtime/agent-core.js +85 -1
  162. package/src/runtime/coding-agent-contract-shared.cjs +98 -4
  163. package/src/assets/web-panel/assets/ChatBubbleRenderer-D68YIuni.js +0 -1
  164. package/src/assets/web-panel/assets/MarkdownRenderer-CbOmzhyX.js +0 -1
  165. package/src/assets/web-panel/assets/MobileProjects-DqNyu4pO.js +0 -1
  166. package/src/assets/web-panel/assets/OrderTableRenderer-CEQJHoU9.js +0 -1
  167. package/src/assets/web-panel/assets/PdhVaultBrowser-CZDiOia9.js +0 -7
  168. package/src/assets/web-panel/assets/Tasks-ClYwVLRE.js +0 -1
  169. package/src/assets/web-panel/assets/devWarning-CahG3JuS.js +0 -1
  170. package/src/assets/web-panel/assets/index-BZ3kGfit.js +0 -1
  171. package/src/assets/web-panel/assets/index-Dm5MZg8A.js +0 -1
@@ -10,8 +10,11 @@ import https from "node:https";
10
10
  import { lookup as dnsLookup } from "node:dns";
11
11
  import { URL } from "node:url";
12
12
  import { createHash } from "node:crypto";
13
+ import { StringDecoder } from "node:string_decoder";
14
+ import { webFetchSnapshots } from "./web-fetch-snapshots.js";
13
15
 
14
- const DEFAULT_MAX_BYTES = 2_000_000;
16
+ const DEFAULT_MAX_BYTES = 10_000_000;
17
+ const DEFAULT_MAX_CHARS = 20_000;
15
18
  const DEFAULT_TIMEOUT_MS = 15_000;
16
19
 
17
20
  // RFC 1918 + loopback + link-local (IPv4 and IPv6). Blocked by default unless
@@ -99,13 +102,23 @@ export function checkAllowed(urlStr, config = {}) {
99
102
  return { allowed: true, url: parsed };
100
103
  }
101
104
 
102
- export function htmlToMarkdown(html) {
103
- if (!html) return "";
105
+ function stripHtmlNoise(html) {
104
106
  let text = String(html);
105
107
  // Strip scripts/styles entirely
106
108
  text = text.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
107
109
  text = text.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
108
110
  text = text.replace(/<!--[\s\S]*?-->/g, "");
111
+ // A bounded download may end inside a script, stylesheet, comment or tag.
112
+ // Closed blocks were removed above; discard any unfinished non-content tail.
113
+ return text
114
+ .replace(/<(?:script|style)\b[^>]*>[\s\S]*$/gi, "")
115
+ .replace(/<!--[\s\S]*$/g, "")
116
+ .replace(/<[^>]*$/g, "");
117
+ }
118
+
119
+ export function htmlToMarkdown(html) {
120
+ if (!html) return "";
121
+ let text = stripHtmlNoise(html);
109
122
  // Convert headings
110
123
  text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, n, inner) => {
111
124
  return `\n\n${"#".repeat(Number(n))} ${inner.trim()}\n\n`;
@@ -141,7 +154,10 @@ function stripTags(html) {
141
154
  return String(html).replace(/<[^>]+>/g, "");
142
155
  }
143
156
 
144
- function webFetchCacheKey(parsed, { format, maxBytes, maxRedirects, headers }) {
157
+ function webFetchCacheKey(
158
+ parsed,
159
+ { format, maxBytes, maxChars, offset, onOverflow, maxRedirects, headers },
160
+ ) {
145
161
  // A caller-provided header can carry credentials or change representation.
146
162
  // Keep those requests out of the shared host cache entirely.
147
163
  if (
@@ -155,6 +171,9 @@ function webFetchCacheKey(parsed, { format, maxBytes, maxRedirects, headers }) {
155
171
  url: parsed.toString(),
156
172
  format,
157
173
  maxBytes,
174
+ maxChars,
175
+ offset,
176
+ onOverflow,
158
177
  maxRedirects,
159
178
  });
160
179
  // Do not retain a raw URL/query as a long-lived Map key in the host cache.
@@ -200,6 +219,9 @@ export function makeSafeLookup(allowPrivateHosts, deps = _deps) {
200
219
  }
201
220
  }
202
221
  }
222
+ // Node's autoSelectFamily path requests all addresses. Returning a scalar
223
+ // here makes Node read undefined.address and reject every hostname.
224
+ if (opts.all) return cb(null, list);
203
225
  const chosen = list[0];
204
226
  cb(null, chosen.address, chosen.family);
205
227
  });
@@ -208,15 +230,24 @@ export function makeSafeLookup(allowPrivateHosts, deps = _deps) {
208
230
 
209
231
  async function _doRequest(
210
232
  parsed,
211
- { maxBytes, timeout, headers, allowPrivateHosts },
233
+ { maxBytes, timeout, headers, allowPrivateHosts, truncateOnOverflow },
212
234
  ) {
213
235
  const lib = parsed.protocol === "https:" ? https : http;
214
236
  return new Promise((resolve, reject) => {
237
+ let timer;
238
+ let settled = false;
239
+ const settle = (error, result) => {
240
+ if (settled) return;
241
+ settled = true;
242
+ clearTimeout(timer);
243
+ if (error) reject(error);
244
+ else resolve(result);
245
+ };
215
246
  const req = lib.request(
216
247
  {
217
248
  method: "GET",
218
249
  protocol: parsed.protocol,
219
- hostname: parsed.hostname,
250
+ hostname: parsed.hostname.replace(/^\[|\]$/g, ""),
220
251
  port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
221
252
  path: parsed.pathname + parsed.search,
222
253
  // Resolve-and-check: reject the connection if the host resolves to a
@@ -237,30 +268,76 @@ async function _doRequest(
237
268
  res.headers.location
238
269
  ) {
239
270
  res.resume();
240
- return resolve({ redirect: res.headers.location });
271
+ return settle(null, { redirect: res.headers.location });
241
272
  }
242
273
  const chunks = [];
243
274
  let size = 0;
275
+ const complete = (downloadTruncated = false) => {
276
+ const buffer = Buffer.concat(chunks);
277
+ settle(null, {
278
+ statusCode: res.statusCode,
279
+ headers: res.headers,
280
+ // With a partial response, discard an incomplete final UTF-8
281
+ // sequence instead of inventing a replacement character.
282
+ body: downloadTruncated
283
+ ? new StringDecoder("utf8").write(buffer)
284
+ : buffer.toString("utf8"),
285
+ bytes: buffer.length,
286
+ downloadTruncated,
287
+ });
288
+ };
244
289
  res.on("data", (chunk) => {
290
+ if (settled) return;
291
+ const available = maxBytes - size;
245
292
  size += chunk.length;
246
293
  if (size > maxBytes) {
247
- req.destroy(new Error(`response exceeds maxBytes (${maxBytes})`));
294
+ if (truncateOnOverflow) {
295
+ if (available > 0) chunks.push(chunk.subarray(0, available));
296
+ complete(true);
297
+ res.destroy();
298
+ req.destroy();
299
+ return;
300
+ }
301
+ const error = Object.assign(
302
+ new Error(`response exceeds maxBytes (${maxBytes})`),
303
+ {
304
+ code: "ERR_RESPONSE_TOO_LARGE",
305
+ maxBytes,
306
+ receivedBytes: size,
307
+ },
308
+ );
309
+ settle(error);
310
+ res.destroy();
311
+ req.destroy();
248
312
  return;
249
313
  }
250
314
  chunks.push(chunk);
251
315
  });
252
316
  res.on("end", () => {
253
- resolve({
254
- statusCode: res.statusCode,
255
- headers: res.headers,
256
- body: Buffer.concat(chunks).toString("utf8"),
257
- });
317
+ if (!settled) complete();
258
318
  });
259
- res.on("error", reject);
319
+ res.on("error", (error) => settle(error));
320
+ res.on("aborted", () =>
321
+ settle(
322
+ Object.assign(new Error("response aborted"), {
323
+ code: "ECONNRESET",
324
+ }),
325
+ ),
326
+ );
260
327
  },
261
328
  );
262
- req.on("error", reject);
263
- req.on("timeout", () => req.destroy(new Error("request timeout")));
329
+ const abortTimeout = () => {
330
+ settle(
331
+ Object.assign(new Error("request timeout"), { code: "ETIMEDOUT" }),
332
+ );
333
+ req.destroy();
334
+ };
335
+ req.on("error", (error) => settle(error));
336
+ req.on("timeout", abortTimeout);
337
+ // Covers DNS resolution and continuously streaming bodies as well as idle
338
+ // sockets. Redirects receive only the remaining overall request budget.
339
+ timer = setTimeout(abortTimeout, timeout);
340
+ timer.unref?.();
264
341
  req.end();
265
342
  });
266
343
  }
@@ -269,6 +346,14 @@ export async function webFetch(url, options = {}) {
269
346
  const {
270
347
  format = "markdown",
271
348
  maxBytes = DEFAULT_MAX_BYTES,
349
+ maxChars = DEFAULT_MAX_CHARS,
350
+ offset = 0,
351
+ snapshotId = null,
352
+ query = null,
353
+ maxMatches = 20,
354
+ contextChars = 150,
355
+ caseSensitive = false,
356
+ onOverflow = "truncate",
272
357
  timeout = DEFAULT_TIMEOUT_MS,
273
358
  config = {},
274
359
  headers = {},
@@ -278,19 +363,124 @@ export async function webFetch(url, options = {}) {
278
363
 
279
364
  const check = checkAllowed(url, config);
280
365
  if (!check.allowed) {
281
- return { error: `web_fetch blocked: ${check.reason}` };
366
+ return {
367
+ error: `web_fetch blocked: ${check.reason}`,
368
+ code: "ERR_FETCH_BLOCKED",
369
+ retryable: false,
370
+ };
371
+ }
372
+ if (
373
+ !Number.isSafeInteger(maxBytes) ||
374
+ maxBytes <= 0 ||
375
+ !Number.isSafeInteger(maxChars) ||
376
+ maxChars <= 0 ||
377
+ !Number.isSafeInteger(offset) ||
378
+ offset < 0 ||
379
+ (snapshotId !== null && (typeof snapshotId !== "string" || !snapshotId)) ||
380
+ !Number.isSafeInteger(timeout) ||
381
+ timeout <= 0 ||
382
+ timeout > 2_147_483_647 ||
383
+ !Number.isSafeInteger(maxRedirects) ||
384
+ maxRedirects < 0 ||
385
+ !["truncate", "error"].includes(onOverflow)
386
+ ) {
387
+ return {
388
+ error: "invalid web_fetch size, output length, timeout or redirect limit",
389
+ code: "ERR_FETCH_OPTIONS",
390
+ retryable: false,
391
+ };
392
+ }
393
+
394
+ if (query !== null && (!snapshotId || typeof query !== "string" || !query)) {
395
+ return {
396
+ error:
397
+ "Download the URL first, then pass snapshotId and a non-empty query to search its saved text",
398
+ code: "ERR_FETCH_OPTIONS",
399
+ retryable: false,
400
+ };
401
+ }
402
+ if (format === "json" && (snapshotId || offset !== 0)) {
403
+ return {
404
+ error:
405
+ "JSON values cannot be paged. Use format=text to save and read a JSON document in chunks.",
406
+ code: "ERR_FETCH_OPTIONS",
407
+ retryable: false,
408
+ };
409
+ }
410
+ const snapshotIdentity = createHash("sha256")
411
+ .update(
412
+ JSON.stringify({
413
+ url: check.url.toString(),
414
+ format,
415
+ headers,
416
+ allowPrivateHosts: config.allowPrivateHosts === true,
417
+ allowedDomains: config.allowedDomains || ["*"],
418
+ }),
419
+ )
420
+ .digest("hex");
421
+ const readSnapshot = (id) =>
422
+ webFetchSnapshots.read(
423
+ id,
424
+ snapshotIdentity,
425
+ hostResourceBudget,
426
+ offset,
427
+ maxChars,
428
+ );
429
+ if (snapshotId) {
430
+ try {
431
+ const result = readSnapshot(snapshotId);
432
+ if (result.downloadTruncated && onOverflow === "error") {
433
+ return {
434
+ error:
435
+ "The saved download is incomplete. Fetch again without snapshotId and with a larger maxBytes.",
436
+ code: "ERR_RESPONSE_TOO_LARGE",
437
+ retryable: false,
438
+ };
439
+ }
440
+ if (query !== null && !result.error) {
441
+ const search = await webFetchSnapshots.search(
442
+ snapshotId,
443
+ snapshotIdentity,
444
+ hostResourceBudget,
445
+ {
446
+ pattern: query,
447
+ offset,
448
+ maxMatches,
449
+ contextChars,
450
+ caseSensitive,
451
+ url: check.url.toString(),
452
+ },
453
+ );
454
+ return search.error ? search : { ...search, cached: true };
455
+ }
456
+ return result.error ? result : { ...result, cached: true };
457
+ } catch {
458
+ return {
459
+ error:
460
+ "Unable to read the local webpage snapshot. Fetch the URL again without snapshotId.",
461
+ code: "ERR_FETCH_SNAPSHOT_READ",
462
+ retryable: false,
463
+ };
464
+ }
282
465
  }
283
466
 
284
467
  const cacheKey = webFetchCacheKey(check.url, {
285
468
  format,
286
469
  maxBytes,
470
+ maxChars,
471
+ offset,
472
+ onOverflow,
287
473
  maxRedirects,
288
474
  headers,
289
475
  });
290
476
  if (cacheKey && hostResourceBudget?.getWebFetch) {
291
477
  try {
292
478
  const cached = hostResourceBudget.getWebFetch(cacheKey);
293
- if (cached) return { ...cached, cached: true };
479
+ if (cached) {
480
+ if (!cached.snapshotId) return { ...cached, cached: true };
481
+ const page = readSnapshot(cached.snapshotId);
482
+ if (!page.error) return { ...page, cached: true };
483
+ }
294
484
  } catch (error) {
295
485
  return hostBudgetUnavailable("web_fetch", error);
296
486
  }
@@ -314,21 +504,38 @@ export async function webFetch(url, options = {}) {
314
504
  let parsed = check.url;
315
505
  let redirects = 0;
316
506
  let response;
507
+ const deadline = Date.now() + timeout;
317
508
  while (true) {
509
+ const remaining = deadline - Date.now();
510
+ if (remaining <= 0) {
511
+ throw Object.assign(new Error("request timeout"), {
512
+ code: "ETIMEDOUT",
513
+ });
514
+ }
318
515
  response = await _doRequest(parsed, {
319
516
  maxBytes,
320
- timeout,
517
+ timeout: remaining,
321
518
  headers,
322
519
  allowPrivateHosts: config.allowPrivateHosts,
520
+ // An incomplete JSON document cannot be returned as a valid value.
521
+ truncateOnOverflow: onOverflow === "truncate" && format !== "json",
323
522
  });
324
523
  if (!response.redirect) break;
325
524
  if (++redirects > maxRedirects) {
326
- return { error: "too many redirects" };
525
+ return {
526
+ error: "too many redirects",
527
+ code: "ERR_TOO_MANY_REDIRECTS",
528
+ retryable: false,
529
+ };
327
530
  }
328
531
  const next = new URL(response.redirect, parsed);
329
532
  const nextCheck = checkAllowed(next.toString(), config);
330
533
  if (!nextCheck.allowed) {
331
- return { error: `redirect blocked: ${nextCheck.reason}` };
534
+ return {
535
+ error: `redirect blocked: ${nextCheck.reason}`,
536
+ code: "ERR_FETCH_BLOCKED",
537
+ retryable: false,
538
+ };
332
539
  }
333
540
  parsed = nextCheck.url;
334
541
  }
@@ -336,29 +543,86 @@ export async function webFetch(url, options = {}) {
336
543
  const { statusCode, headers: respHeaders, body } = response;
337
544
  const contentType = String(respHeaders["content-type"] || "");
338
545
 
546
+ // Error pages are tool failures, not successful evidence or cache entries.
547
+ // Classify before JSON parsing so a 404 HTML page is not a JSON error.
548
+ if (statusCode >= 400) {
549
+ const rateLimited =
550
+ statusCode === 429 ||
551
+ (statusCode === 403 &&
552
+ (respHeaders["x-ratelimit-remaining"] === "0" ||
553
+ !!respHeaders["retry-after"]));
554
+ const retryable = rateLimited || statusCode === 408 || statusCode >= 500;
555
+ return {
556
+ url: parsed.toString(),
557
+ error: `web_fetch failed: HTTP ${statusCode}`,
558
+ code: "ERR_HTTP_STATUS",
559
+ statusCode,
560
+ retryable,
561
+ ...(respHeaders["retry-after"]
562
+ ? { retryAfter: String(respHeaders["retry-after"]) }
563
+ : {}),
564
+ body: body.slice(0, 2000),
565
+ hint: rateLimited
566
+ ? "Rate limited. Honor Retry-After or the service's rate-limit reset before a bounded retry. Do not retry immediately."
567
+ : [401, 403, 404].includes(statusCode)
568
+ ? "Check the URL and authentication/access. An identical unauthenticated request will not fix this. For GitHub Actions, use an authenticated gh run view with --log-failed for the specific run/job."
569
+ : "Retry at most once for a transient failure; otherwise use the existing evidence and report the missing information.",
570
+ };
571
+ }
572
+
339
573
  let output = body;
340
574
  let outputFormat = format;
341
575
  if (format === "markdown") {
342
576
  output = /html/i.test(contentType) ? htmlToMarkdown(body) : body;
343
577
  } else if (format === "text") {
344
- output = /html/i.test(contentType) ? stripTags(body) : body;
578
+ output = /html/i.test(contentType)
579
+ ? stripTags(stripHtmlNoise(body))
580
+ : body;
345
581
  } else if (format === "json") {
346
582
  try {
347
583
  output = JSON.parse(body);
348
584
  } catch {
349
- return { error: "response is not valid JSON", statusCode, body };
585
+ return {
586
+ error: "response is not valid JSON",
587
+ code: "ERR_INVALID_JSON",
588
+ retryable: false,
589
+ statusCode,
590
+ body: body.slice(0, 2000),
591
+ };
350
592
  }
351
593
  }
352
594
 
353
- const result = {
595
+ // Download limits apply to the raw page, which can contain much more
596
+ // markup than the extracted text. Bound model context AFTER extraction;
597
+ // a short requested answer must not abort an otherwise readable page.
598
+ const totalChars =
599
+ format !== "json" && typeof output === "string" ? output.length : null;
600
+ const downloadTruncated = response.downloadTruncated === true;
601
+ let result = {
354
602
  url: parsed.toString(),
355
603
  statusCode,
356
604
  contentType,
357
605
  format: outputFormat,
358
- bytes: Buffer.byteLength(body, "utf8"),
606
+ bytes: response.bytes,
359
607
  content: output,
608
+ ...(downloadTruncated ? { downloadTruncated: true, maxBytes } : {}),
609
+ ...(downloadTruncated
610
+ ? {
611
+ hint: `The raw response exceeded maxBytes=${maxBytes}. Only the downloaded prefix is included; totalChars measures that prefix after extraction, not the complete page. Increase maxBytes to fetch more, or request a focused page/API endpoint. Use maxChars to control returned text. Do not treat this as the complete page.`,
612
+ }
613
+ : {}),
360
614
  };
361
- if (cacheKey && hostResourceBudget?.putWebFetch) {
615
+ if (totalChars !== null) {
616
+ // Save the extracted document once, then read only the requested range
617
+ // from disk. Subsequent snapshot reads never contact the origin server.
618
+ const id = webFetchSnapshots.save(
619
+ result,
620
+ snapshotIdentity,
621
+ hostResourceBudget,
622
+ );
623
+ result = readSnapshot(id);
624
+ }
625
+ if (!downloadTruncated && cacheKey && hostResourceBudget?.putWebFetch) {
362
626
  try {
363
627
  hostResourceBudget.putWebFetch(cacheKey, result);
364
628
  } catch {
@@ -366,6 +630,33 @@ export async function webFetch(url, options = {}) {
366
630
  }
367
631
  }
368
632
  return result;
633
+ } catch (error) {
634
+ const code = error.code || "ERR_WEB_FETCH";
635
+ return {
636
+ error: `web_fetch failed: ${error.message}`,
637
+ code,
638
+ ...(code === "ERR_RESPONSE_TOO_LARGE"
639
+ ? {
640
+ maxBytes,
641
+ receivedBytes: error.receivedBytes,
642
+ ...(maxBytes < DEFAULT_MAX_BYTES
643
+ ? { suggestedMaxBytes: DEFAULT_MAX_BYTES }
644
+ : {}),
645
+ }
646
+ : {}),
647
+ retryable: [
648
+ "ETIMEDOUT",
649
+ "ECONNRESET",
650
+ "ECONNREFUSED",
651
+ "EAI_AGAIN",
652
+ ].includes(code),
653
+ hint:
654
+ code === "ERR_RESPONSE_TOO_LARGE"
655
+ ? maxBytes < DEFAULT_MAX_BYTES
656
+ ? `The raw response exceeded maxBytes=${maxBytes}, before text extraction. To limit returned text, use maxChars and omit maxBytes (default ${DEFAULT_MAX_BYTES}). Retry once with corrected limits, not the same small download limit.`
657
+ : "The raw response exceeded the download limit. Request a smaller resource or focused log excerpt, or explicitly raise maxBytes within the permitted resource budget. maxChars only limits the returned text."
658
+ : "Use the error code to check connectivity, DNS or access. Retry a transient failure at most once, then change approach or report the blocker.",
659
+ };
369
660
  } finally {
370
661
  toolLease?.release();
371
662
  }
@@ -15,7 +15,7 @@ import http from "http";
15
15
  import https from "https";
16
16
  import { URL } from "url";
17
17
 
18
- const DEFAULT_MAX_BYTES = 2_000_000;
18
+ const DEFAULT_MAX_BYTES = 10_000_000;
19
19
  const DEFAULT_TIMEOUT_MS = 15_000;
20
20
  const DEFAULT_MAX_RESULTS = 8;
21
21
  export const MAX_WEB_SEARCH_RESULTS = 20;
@@ -98,6 +98,15 @@ function _request(
98
98
  const parsed = new URL(urlStr);
99
99
  const lib = parsed.protocol === "https:" ? _deps.https : _deps.http;
100
100
  return new Promise((resolve, reject) => {
101
+ let timer;
102
+ let settled = false;
103
+ const settle = (error, result) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ if (error) reject(error);
108
+ else resolve(result);
109
+ };
101
110
  const req = lib.request(
102
111
  {
103
112
  method,
@@ -113,28 +122,66 @@ function _request(
113
122
  timeout,
114
123
  },
115
124
  (res) => {
125
+ if (res.statusCode >= 400) {
126
+ settle(
127
+ Object.assign(new Error(`HTTP ${res.statusCode}`), {
128
+ code: "ERR_HTTP_STATUS",
129
+ statusCode: res.statusCode,
130
+ retryAfter: res.headers?.["retry-after"],
131
+ }),
132
+ );
133
+ res.destroy?.();
134
+ req.destroy();
135
+ return;
136
+ }
116
137
  const chunks = [];
117
138
  let size = 0;
118
139
  res.on("data", (chunk) => {
140
+ if (settled) return;
119
141
  size += chunk.length;
120
142
  if (size > maxBytes) {
121
- req.destroy(new Error(`response exceeds maxBytes (${maxBytes})`));
143
+ settle(
144
+ Object.assign(
145
+ new Error(`response exceeds maxBytes (${maxBytes})`),
146
+ {
147
+ code: "ERR_RESPONSE_TOO_LARGE",
148
+ },
149
+ ),
150
+ );
151
+ res.destroy?.();
152
+ req.destroy();
122
153
  return;
123
154
  }
124
155
  chunks.push(chunk);
125
156
  });
126
157
  res.on("end", () =>
127
- resolve({
158
+ settle(null, {
128
159
  statusCode: res.statusCode,
129
160
  headers: res.headers,
130
161
  body: Buffer.concat(chunks).toString("utf8"),
131
162
  }),
132
163
  );
133
- res.on("error", reject);
164
+ res.on("error", (error) => settle(error));
165
+ res.on("aborted", () =>
166
+ settle(
167
+ Object.assign(new Error("response aborted"), {
168
+ code: "ECONNRESET",
169
+ }),
170
+ ),
171
+ );
134
172
  },
135
173
  );
136
- req.on("error", reject);
137
- req.on("timeout", () => req.destroy(new Error("request timeout")));
174
+ const abortTimeout = () => {
175
+ settle(
176
+ Object.assign(new Error("request timeout"), { code: "ETIMEDOUT" }),
177
+ );
178
+ req.destroy();
179
+ };
180
+ req.on("error", (error) => settle(error));
181
+ req.on("timeout", abortTimeout);
182
+ // An overall deadline also bounds DNS and continuously streaming results.
183
+ timer = setTimeout(abortTimeout, timeout);
184
+ timer.unref?.();
138
185
  if (body != null) req.write(body);
139
186
  req.end();
140
187
  });
@@ -285,6 +332,19 @@ async function _searchDuckDuckGo(query, { maxResults, timeout, maxBytes }) {
285
332
  return { error: `duckduckgo HTTP ${res.statusCode}` };
286
333
  }
287
334
  const html = res.body;
335
+ if (
336
+ res.statusCode === 202 ||
337
+ (res.statusCode >= 300 && res.statusCode < 400) ||
338
+ /anomaly-modal|challenge-form|anomaly\.js|captcha/i.test(html)
339
+ ) {
340
+ return {
341
+ error:
342
+ "duckduckgo: verification challenge; search results are unavailable",
343
+ code: "ERR_SEARCH_CHALLENGE",
344
+ retryable: false,
345
+ hint: "Do not report this as no results or repeat the same request. Configure a keyed search provider (Tavily, Brave, Bocha or Qianfan), or use your configured SearXNG instance.",
346
+ };
347
+ }
288
348
  const results = [];
289
349
  const anchorRe =
290
350
  /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
@@ -453,8 +513,26 @@ export async function webSearch(query, options = {}) {
453
513
  const config = options.config || {};
454
514
  const provider = resolveProvider(options, config);
455
515
  let maxResults = normalizeMaxResults(options.maxResults ?? config.maxResults);
456
- const timeout = Number(options.timeout) || DEFAULT_TIMEOUT_MS;
457
- const maxBytes = Number(options.maxBytes) || DEFAULT_MAX_BYTES;
516
+ const timeout = options.timeout ?? config.timeout ?? DEFAULT_TIMEOUT_MS;
517
+ const maxBytes = options.maxBytes ?? config.maxBytes ?? DEFAULT_MAX_BYTES;
518
+ const maxSnippetChars =
519
+ options.maxSnippetChars ?? config.maxSnippetChars ?? 2000;
520
+ if (
521
+ !Number.isSafeInteger(timeout) ||
522
+ timeout <= 0 ||
523
+ timeout > 2_147_483_647 ||
524
+ !Number.isSafeInteger(maxBytes) ||
525
+ maxBytes <= 0 ||
526
+ !Number.isSafeInteger(maxSnippetChars) ||
527
+ maxSnippetChars <= 0 ||
528
+ maxSnippetChars > 10000
529
+ ) {
530
+ return {
531
+ error: "web_search: invalid download, snippet or timeout limit",
532
+ code: "ERR_SEARCH_OPTIONS",
533
+ retryable: false,
534
+ };
535
+ }
458
536
 
459
537
  if (!SUPPORTED_PROVIDERS.includes(provider)) {
460
538
  return {
@@ -533,17 +611,47 @@ export async function webSearch(query, options = {}) {
533
611
  return {
534
612
  error: `web_search (${provider}) failed: ${err.message}`,
535
613
  provider,
614
+ code: err.code || "ERR_WEB_SEARCH",
615
+ ...(err.statusCode ? { statusCode: err.statusCode } : {}),
616
+ ...(err.retryAfter ? { retryAfter: String(err.retryAfter) } : {}),
617
+ retryable:
618
+ err.statusCode === 429 ||
619
+ err.statusCode >= 500 ||
620
+ ["ETIMEDOUT", "ECONNRESET", "EAI_AGAIN"].includes(err.code),
621
+ hint:
622
+ err.code === "ERR_RESPONSE_TOO_LARGE"
623
+ ? "Search response exceeds maxBytes. Raise maxBytes for a larger download; use maxResults and maxSnippetChars to limit returned results, not maxBytes."
624
+ : err.statusCode === 429
625
+ ? "Honor Retry-After before a bounded retry. Do not repeat the same search immediately."
626
+ : "Check the search provider's configuration, API key and connectivity. Do not treat a provider failure as no results or invent source URLs.",
536
627
  };
537
628
  }
538
629
 
539
630
  if (out && out.error) return { ...out, provider };
540
- const results = (out && out.results) || [];
631
+ let truncated = false;
632
+ const bound = (value, limit) => {
633
+ const text = String(value || "");
634
+ if (text.length <= limit) return text;
635
+ truncated = true;
636
+ return text.slice(
637
+ 0,
638
+ /[\uD800-\uDBFF]/.test(text[limit - 1]) ? limit - 1 : limit,
639
+ );
640
+ };
641
+ const results = ((out && out.results) || []).map((result) => ({
642
+ ...result,
643
+ title: bound(result.title, 512),
644
+ snippet: bound(result.snippet, maxSnippetChars),
645
+ }));
646
+ const answer = bound(out?.answer, 8000);
541
647
  return {
542
648
  query: q,
543
649
  provider,
544
650
  count: results.length,
545
651
  results,
546
- answer: (out && out.answer) || "",
652
+ answer,
653
+ ...(truncated ? { truncated: true, maxSnippetChars } : {}),
654
+ hint: "These are search results, not full webpages. Cite the returned source URLs. Use web_fetch only when a page needs closer reading; download it once, then continue with snapshotId and nextOffset as offset.",
547
655
  };
548
656
  } finally {
549
657
  toolLease?.release();