@animalabs/agent-framework 0.26.2 → 0.28.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.
Files changed (371) hide show
  1. package/README.md +99 -8
  2. package/dist/src/agent.d.ts +291 -23
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +596 -61
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +5 -4
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +34 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +36 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/cut-off-round.d.ts +24 -0
  18. package/dist/src/cut-off-round.d.ts.map +1 -0
  19. package/dist/src/cut-off-round.js +87 -0
  20. package/dist/src/cut-off-round.js.map +1 -0
  21. package/dist/src/framework.d.ts +1038 -193
  22. package/dist/src/framework.d.ts.map +1 -1
  23. package/dist/src/framework.js +5967 -1514
  24. package/dist/src/framework.js.map +1 -1
  25. package/dist/src/gate/event-gate.d.ts +405 -46
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/event-gate.js +2646 -399
  28. package/dist/src/gate/event-gate.js.map +1 -1
  29. package/dist/src/gate/index.d.ts +3 -2
  30. package/dist/src/gate/index.d.ts.map +1 -1
  31. package/dist/src/gate/index.js +2 -1
  32. package/dist/src/gate/index.js.map +1 -1
  33. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  34. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  35. package/dist/src/gate/recipe-reconcile.js +325 -0
  36. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  37. package/dist/src/gate/reply-judge.d.ts +116 -0
  38. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  39. package/dist/src/gate/reply-judge.js +467 -0
  40. package/dist/src/gate/reply-judge.js.map +1 -0
  41. package/dist/src/gate/types.d.ts +229 -8
  42. package/dist/src/gate/types.d.ts.map +1 -1
  43. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  44. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  45. package/dist/src/heartbeat/heartbeat.js +752 -0
  46. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  47. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  48. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.js +107 -0
  50. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  51. package/dist/src/index.d.ts +10 -4
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +7 -2
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  56. package/dist/src/mcpl/capability-mask.js +3 -2
  57. package/dist/src/mcpl/capability-mask.js.map +1 -1
  58. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  59. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  60. package/dist/src/mcpl/channel-registry.js +1154 -435
  61. package/dist/src/mcpl/channel-registry.js.map +1 -1
  62. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  63. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  64. package/dist/src/mcpl/conversation-router.js +29 -15
  65. package/dist/src/mcpl/conversation-router.js.map +1 -1
  66. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  67. package/dist/src/mcpl/convert-block.js +14 -2
  68. package/dist/src/mcpl/convert-block.js.map +1 -1
  69. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  70. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  71. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  72. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  73. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  74. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  75. package/dist/src/mcpl/inbound-source.js +40 -69
  76. package/dist/src/mcpl/inbound-source.js.map +1 -1
  77. package/dist/src/mcpl/index.d.ts +5 -1
  78. package/dist/src/mcpl/index.d.ts.map +1 -1
  79. package/dist/src/mcpl/index.js +4 -0
  80. package/dist/src/mcpl/index.js.map +1 -1
  81. package/dist/src/mcpl/inference-router.d.ts +14 -2
  82. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  83. package/dist/src/mcpl/inference-router.js +73 -28
  84. package/dist/src/mcpl/inference-router.js.map +1 -1
  85. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  86. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  87. package/dist/src/mcpl/prose-grammar.js +6 -0
  88. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  89. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  90. package/dist/src/mcpl/protocol-family.js +3 -0
  91. package/dist/src/mcpl/protocol-family.js.map +1 -1
  92. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  93. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  94. package/dist/src/mcpl/push-coalescer.js +33 -11
  95. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  96. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  97. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  98. package/dist/src/mcpl/reference-fetcher.js +177 -145
  99. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  100. package/dist/src/mcpl/references.d.ts +380 -46
  101. package/dist/src/mcpl/references.d.ts.map +1 -1
  102. package/dist/src/mcpl/references.js +736 -77
  103. package/dist/src/mcpl/references.js.map +1 -1
  104. package/dist/src/mcpl/server-connection.d.ts +93 -4
  105. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  106. package/dist/src/mcpl/server-connection.js +176 -11
  107. package/dist/src/mcpl/server-connection.js.map +1 -1
  108. package/dist/src/mcpl/tags.d.ts +9 -2
  109. package/dist/src/mcpl/tags.d.ts.map +1 -1
  110. package/dist/src/mcpl/tags.js +12 -2
  111. package/dist/src/mcpl/tags.js.map +1 -1
  112. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  113. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  114. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  115. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  116. package/dist/src/mcpl/tool-presence.d.ts +311 -0
  117. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  118. package/dist/src/mcpl/tool-presence.js +697 -0
  119. package/dist/src/mcpl/tool-presence.js.map +1 -0
  120. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  121. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  122. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  123. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  124. package/dist/src/mcpl/tool-signature.d.ts +46 -0
  125. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  126. package/dist/src/mcpl/tool-signature.js +321 -0
  127. package/dist/src/mcpl/tool-signature.js.map +1 -0
  128. package/dist/src/mcpl/toolset-host.d.ts +251 -0
  129. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  130. package/dist/src/mcpl/toolset-host.js +720 -0
  131. package/dist/src/mcpl/toolset-host.js.map +1 -0
  132. package/dist/src/mcpl/toolsets-module.d.ts +83 -0
  133. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  134. package/dist/src/mcpl/toolsets-module.js +523 -0
  135. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  136. package/dist/src/mcpl/transport.d.ts +2 -2
  137. package/dist/src/mcpl/transport.d.ts.map +1 -1
  138. package/dist/src/mcpl/transport.js +3 -2
  139. package/dist/src/mcpl/transport.js.map +1 -1
  140. package/dist/src/mcpl/types.d.ts +24 -2
  141. package/dist/src/mcpl/types.d.ts.map +1 -1
  142. package/dist/src/mcpl/types.js.map +1 -1
  143. package/dist/src/mcpl/visible-content.d.ts +7 -9
  144. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  145. package/dist/src/mcpl/visible-content.js +21 -9
  146. package/dist/src/mcpl/visible-content.js.map +1 -1
  147. package/dist/src/module-registry.d.ts +27 -9
  148. package/dist/src/module-registry.d.ts.map +1 -1
  149. package/dist/src/module-registry.js +77 -17
  150. package/dist/src/module-registry.js.map +1 -1
  151. package/dist/src/modules/discord/index.js +1 -1
  152. package/dist/src/modules/discord/index.js.map +1 -1
  153. package/dist/src/modules/history/index.d.ts +2 -1
  154. package/dist/src/modules/history/index.d.ts.map +1 -1
  155. package/dist/src/modules/history/index.js +73 -28
  156. package/dist/src/modules/history/index.js.map +1 -1
  157. package/dist/src/modules/history/semantic.d.ts +108 -6
  158. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  159. package/dist/src/modules/history/semantic.js +273 -57
  160. package/dist/src/modules/history/semantic.js.map +1 -1
  161. package/dist/src/modules/workspace/answers.d.ts +22 -0
  162. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  163. package/dist/src/modules/workspace/answers.js +123 -0
  164. package/dist/src/modules/workspace/answers.js.map +1 -0
  165. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  166. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/branch-intent.js +6 -2
  168. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  169. package/dist/src/modules/workspace/direct-write.d.ts +184 -0
  170. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  171. package/dist/src/modules/workspace/direct-write.js +417 -0
  172. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  173. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  174. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  176. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  177. package/dist/src/modules/workspace/edit-text.d.ts +27 -12
  178. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/edit-text.js +61 -29
  180. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  181. package/dist/src/modules/workspace/effects.d.ts +10 -0
  182. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/effects.js +21 -14
  184. package/dist/src/modules/workspace/effects.js.map +1 -1
  185. package/dist/src/modules/workspace/index.d.ts +308 -78
  186. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  187. package/dist/src/modules/workspace/index.js +2103 -720
  188. package/dist/src/modules/workspace/index.js.map +1 -1
  189. package/dist/src/modules/workspace/observe.d.ts +75 -63
  190. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  191. package/dist/src/modules/workspace/observe.js +366 -180
  192. package/dist/src/modules/workspace/observe.js.map +1 -1
  193. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  194. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  195. package/dist/src/modules/workspace/read-page.js +219 -0
  196. package/dist/src/modules/workspace/read-page.js.map +1 -0
  197. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  198. package/dist/src/modules/workspace/reconcile.js +26 -16
  199. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  200. package/dist/src/modules/workspace/result-text.d.ts +68 -0
  201. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  202. package/dist/src/modules/workspace/result-text.js +94 -0
  203. package/dist/src/modules/workspace/result-text.js.map +1 -0
  204. package/dist/src/modules/workspace/sync.d.ts +9 -0
  205. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  206. package/dist/src/modules/workspace/sync.js +14 -0
  207. package/dist/src/modules/workspace/sync.js.map +1 -1
  208. package/dist/src/modules/workspace/text.d.ts +29 -12
  209. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  210. package/dist/src/modules/workspace/text.js +35 -33
  211. package/dist/src/modules/workspace/text.js.map +1 -1
  212. package/dist/src/modules/workspace/types.d.ts +137 -60
  213. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  214. package/dist/src/modules/workspace/types.js.map +1 -1
  215. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  216. package/dist/src/modules/workspace/watcher.js +8 -3
  217. package/dist/src/modules/workspace/watcher.js.map +1 -1
  218. package/dist/src/prose-drafts.d.ts +4 -3
  219. package/dist/src/prose-drafts.d.ts.map +1 -1
  220. package/dist/src/prose-drafts.js +3 -2
  221. package/dist/src/prose-drafts.js.map +1 -1
  222. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  223. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  224. package/dist/src/reasoning-loss-notice.js +50 -0
  225. package/dist/src/reasoning-loss-notice.js.map +1 -0
  226. package/dist/src/reasoning-loss.d.ts +24 -0
  227. package/dist/src/reasoning-loss.d.ts.map +1 -0
  228. package/dist/src/reasoning-loss.js +102 -0
  229. package/dist/src/reasoning-loss.js.map +1 -0
  230. package/dist/src/send-tools.d.ts +3 -2
  231. package/dist/src/send-tools.d.ts.map +1 -1
  232. package/dist/src/send-tools.js +3 -2
  233. package/dist/src/send-tools.js.map +1 -1
  234. package/dist/src/sent-rows.d.ts +59 -0
  235. package/dist/src/sent-rows.d.ts.map +1 -0
  236. package/dist/src/sent-rows.js +145 -0
  237. package/dist/src/sent-rows.js.map +1 -0
  238. package/dist/src/silent-heartbeat.d.ts +17 -10
  239. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  240. package/dist/src/silent-heartbeat.js.map +1 -1
  241. package/dist/src/stable-hash.d.ts +3 -0
  242. package/dist/src/stable-hash.d.ts.map +1 -0
  243. package/dist/src/stable-hash.js +15 -0
  244. package/dist/src/stable-hash.js.map +1 -0
  245. package/dist/src/stored-injections.d.ts +99 -0
  246. package/dist/src/stored-injections.d.ts.map +1 -0
  247. package/dist/src/stored-injections.js +106 -0
  248. package/dist/src/stored-injections.js.map +1 -0
  249. package/dist/src/text-as-stored.d.ts +4 -3
  250. package/dist/src/text-as-stored.d.ts.map +1 -1
  251. package/dist/src/text-as-stored.js +4 -3
  252. package/dist/src/text-as-stored.js.map +1 -1
  253. package/dist/src/thinking-drops.d.ts +74 -0
  254. package/dist/src/thinking-drops.d.ts.map +1 -0
  255. package/dist/src/thinking-drops.js +96 -0
  256. package/dist/src/thinking-drops.js.map +1 -0
  257. package/dist/src/tool-args.d.ts +12 -0
  258. package/dist/src/tool-args.d.ts.map +1 -1
  259. package/dist/src/tool-args.js +35 -1
  260. package/dist/src/tool-args.js.map +1 -1
  261. package/dist/src/tool-image-ledger.d.ts +9 -8
  262. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  263. package/dist/src/tool-image-ledger.js +9 -8
  264. package/dist/src/tool-image-ledger.js.map +1 -1
  265. package/dist/src/tool-presentation.d.ts.map +1 -1
  266. package/dist/src/tool-presentation.js +3 -1
  267. package/dist/src/tool-presentation.js.map +1 -1
  268. package/dist/src/tool-result-guard.d.ts +12 -6
  269. package/dist/src/tool-result-guard.d.ts.map +1 -1
  270. package/dist/src/tool-result-guard.js +44 -17
  271. package/dist/src/tool-result-guard.js.map +1 -1
  272. package/dist/src/tool-result-history.d.ts +176 -24
  273. package/dist/src/tool-result-history.d.ts.map +1 -1
  274. package/dist/src/tool-result-history.js +411 -91
  275. package/dist/src/tool-result-history.js.map +1 -1
  276. package/dist/src/tune-out/coordinator.d.ts +17 -9
  277. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  278. package/dist/src/tune-out/coordinator.js +84 -37
  279. package/dist/src/tune-out/coordinator.js.map +1 -1
  280. package/dist/src/tune-out/tools.d.ts +4 -1
  281. package/dist/src/tune-out/tools.d.ts.map +1 -1
  282. package/dist/src/types/agent.d.ts +217 -7
  283. package/dist/src/types/agent.d.ts.map +1 -1
  284. package/dist/src/types/events.d.ts +33 -9
  285. package/dist/src/types/events.d.ts.map +1 -1
  286. package/dist/src/types/framework.d.ts +39 -3
  287. package/dist/src/types/framework.d.ts.map +1 -1
  288. package/dist/src/types/module.d.ts +95 -9
  289. package/dist/src/types/module.d.ts.map +1 -1
  290. package/dist/src/types/trace.d.ts +91 -3
  291. package/dist/src/types/trace.d.ts.map +1 -1
  292. package/package.json +5 -4
  293. package/src/agent.ts +661 -67
  294. package/src/api/server.ts +5 -4
  295. package/src/auto-continue.ts +43 -0
  296. package/src/code-execution/isolate/scripts.ts +11 -3
  297. package/src/cut-off-round.ts +98 -0
  298. package/src/framework.ts +6168 -1540
  299. package/src/gate/event-gate.ts +2895 -405
  300. package/src/gate/index.ts +10 -1
  301. package/src/gate/recipe-reconcile.ts +355 -0
  302. package/src/gate/reply-judge.ts +512 -0
  303. package/src/gate/types.ts +234 -9
  304. package/src/heartbeat/heartbeat.ts +804 -0
  305. package/src/heartbeat/reminder-time.ts +107 -0
  306. package/src/index.ts +11 -4
  307. package/src/mcpl/capability-mask.ts +3 -2
  308. package/src/mcpl/channel-registry.ts +1222 -468
  309. package/src/mcpl/conversation-router.ts +34 -16
  310. package/src/mcpl/convert-block.ts +14 -1
  311. package/src/mcpl/hook-orchestrator.ts +33 -6
  312. package/src/mcpl/inbound-source.ts +42 -69
  313. package/src/mcpl/index.ts +6 -1
  314. package/src/mcpl/inference-router.ts +79 -34
  315. package/src/mcpl/prose-grammar.ts +7 -0
  316. package/src/mcpl/protocol-family.ts +2 -0
  317. package/src/mcpl/push-coalescer.ts +51 -13
  318. package/src/mcpl/reference-fetcher.ts +201 -162
  319. package/src/mcpl/references.ts +851 -77
  320. package/src/mcpl/server-connection.ts +189 -11
  321. package/src/mcpl/tags.ts +13 -2
  322. package/src/mcpl/tool-lifecycle.ts +21 -0
  323. package/src/mcpl/tool-presence.ts +716 -0
  324. package/src/mcpl/tool-result-normalize.ts +18 -6
  325. package/src/mcpl/tool-signature.ts +316 -0
  326. package/src/mcpl/toolset-host.ts +727 -0
  327. package/src/mcpl/toolsets-module.ts +545 -0
  328. package/src/mcpl/transport.ts +3 -2
  329. package/src/mcpl/types.ts +25 -2
  330. package/src/mcpl/visible-content.ts +22 -9
  331. package/src/module-registry.ts +85 -26
  332. package/src/modules/discord/index.ts +1 -1
  333. package/src/modules/history/index.ts +70 -30
  334. package/src/modules/history/semantic.ts +308 -54
  335. package/src/modules/workspace/answers.ts +133 -0
  336. package/src/modules/workspace/branch-intent.ts +10 -5
  337. package/src/modules/workspace/direct-write.ts +460 -0
  338. package/src/modules/workspace/disk-agreement.ts +52 -21
  339. package/src/modules/workspace/edit-text.ts +84 -35
  340. package/src/modules/workspace/effects.ts +22 -13
  341. package/src/modules/workspace/index.ts +2142 -723
  342. package/src/modules/workspace/observe.ts +381 -177
  343. package/src/modules/workspace/read-page.ts +228 -0
  344. package/src/modules/workspace/reconcile.ts +27 -15
  345. package/src/modules/workspace/result-text.ts +121 -0
  346. package/src/modules/workspace/sync.ts +16 -0
  347. package/src/modules/workspace/text.ts +58 -32
  348. package/src/modules/workspace/types.ts +137 -60
  349. package/src/modules/workspace/watcher.ts +8 -3
  350. package/src/prose-drafts.ts +4 -3
  351. package/src/reasoning-loss-notice.ts +62 -0
  352. package/src/reasoning-loss.ts +120 -0
  353. package/src/send-tools.ts +3 -2
  354. package/src/sent-rows.ts +162 -0
  355. package/src/silent-heartbeat.ts +17 -10
  356. package/src/stable-hash.ts +14 -0
  357. package/src/stored-injections.ts +131 -0
  358. package/src/text-as-stored.ts +4 -3
  359. package/src/thinking-drops.ts +135 -0
  360. package/src/tool-args.ts +34 -1
  361. package/src/tool-image-ledger.ts +9 -8
  362. package/src/tool-presentation.ts +3 -1
  363. package/src/tool-result-guard.ts +45 -17
  364. package/src/tool-result-history.ts +471 -90
  365. package/src/tune-out/coordinator.ts +80 -37
  366. package/src/tune-out/tools.ts +4 -1
  367. package/src/types/agent.ts +220 -7
  368. package/src/types/events.ts +33 -9
  369. package/src/types/framework.ts +36 -3
  370. package/src/types/module.ts +91 -9
  371. package/src/types/trace.ts +94 -3
@@ -25,15 +25,23 @@
25
25
  * Autofetch policy (host default, not protocol): eager only below
26
26
  * DEFAULT_EAGER_FETCH_MAX_BYTES from the connection origin — anything under
27
27
  * that threshold the server could legitimately have inlined, so eager
28
- * materialization is never worse than the status quo. Everything larger is
29
- * fetched on demand (the fetch_reference tool, or the code-execution result
30
- * hook, where a script receiving a result is the strongest available signal
31
- * that the bytes are wanted). Per-server budgets cap cumulative appetite.
28
+ * materialization is never worse than the status quo for a reference whose
29
+ * claim is true. A fetch the eager default starts reads no further than the
30
+ * first chunk past its server's eager limit (autofetch.maxBytes, this
31
+ * default when that's unset), whatever size the server claims; one it joins
32
+ * is its starter's, whose success answers it too. The eager default doesn't
33
+ * choose a reference again once its fetch of it failed. Everything larger
34
+ * is fetched on demand (the fetch_reference tool, or the code-execution
35
+ * result hook, where a script receiving a result is the strongest available
36
+ * signal that the bytes are wanted). Per-server budgets cap cumulative
37
+ * appetite.
32
38
  */
33
39
  import { createHash } from 'node:crypto';
34
- import type { ReferenceRecord } from './references.js';
40
+ import type { ReferenceRecord, Refusal, ServerAddress } from './references.js';
35
41
  import { boundedForLog, describeCause } from './errors.js';
36
- import { EXPIRY_REASON, noAddressReason, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel, UNBOUND_REASON } from './references.js';
42
+ import { dialCheck, dialServer, FRESH_REFERENCE, NOTHING_TO_DO, REFERENCE_LIMITS, sanitizeLabel, shownServerId } from './references.js';
43
+
44
+ export { normalizedOrigin, type Refusal } from './references.js';
37
45
 
38
46
  /** Per-fetch hard ceiling on actual bytes. A call can lower it for itself
39
47
  * (fetch_reference's max_bytes, which it clamps to this); nothing raises it. */
@@ -44,17 +52,18 @@ export const DEFAULT_EAGER_FETCH_MAX_BYTES = 256 * 1024;
44
52
  * through a server reads counts against that server's, whether or not it's
45
53
  * kept. */
46
54
  export const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
55
+ /** How long a caller waits for a fetch, its save included, unless it asks
56
+ * for another time. */
47
57
  const FETCH_TIMEOUT_MS = 60_000;
58
+ /** How long the eager default waits for one reference. */
48
59
  const EAGER_FETCH_TIMEOUT_MS = 10_000;
49
60
 
50
61
  // What calling again can do, as each failure's remedy says it. The fetcher
51
62
  // doesn't know where a reference came from (a tool result, a push, a channel
52
63
  // message), so none names a tool, and fetch_reference's max_bytes is named
53
- // only where a larger one can fetch it.
54
- const NOTHING_TO_DO = "Calling again won't change that.";
55
- /** The registry keys a record by its uri, so a fresh reference to the same
56
- * uri arrives as this very id and refreshes its record. */
57
- const FRESH_REFERENCE = "Once a fresh reference to it arrives, fetching may work; until then, fetching it again won't.";
64
+ // only where a larger one can fetch it. The refusals before dialing that a
65
+ // stub shares come from references.ts (dialServer, dialCheck) with their
66
+ // remedies (NOTHING_TO_DO, FRESH_REFERENCE, SERVER_BACK).
58
67
  const TRY_LATER = 'Trying again later may work.';
59
68
  /** A request that failed before an answer: a refused or reset connection
60
69
  * can clear, and a cause that repeats on every try (a port fetch refuses, a
@@ -71,8 +80,6 @@ const BUDGET = "The budget doesn't renew on its own, so calling again won't chan
71
80
  /** The host's own ceiling: fetch_reference clamps max_bytes to it, and no
72
81
  * config sets another. */
73
82
  const HOST_CEILING = "No max_bytes or setting lifts that limit, so calling again won't change it.";
74
- /** The resolver reads the host's server configs as they are at each fetch. */
75
- const SERVER_BACK = "Calling again won't change that unless whoever runs this host adds that server back.";
76
83
 
77
84
  export interface ResolvedServerBinding {
78
85
  /** The url the host dials the server at. The framework's resolver gives only
@@ -90,18 +97,6 @@ export interface ResolvedServerBinding {
90
97
  * know resolves to undefined. */
91
98
  export type ServerResolution = ResolvedServerBinding | 'no-address';
92
99
 
93
- /** Why a fetch failed, or would be refused before dialing. */
94
- export interface Refusal {
95
- /** What happened, as a clause that completes "couldn't fetch <ref>: ",
96
- * whatever it's about: fetch_reference's sentence, save_recent_image's,
97
- * and the eager and script-demand fetches' log lines read it. */
98
- error: string;
99
- /** What calling again can do, as a sentence. fetch_reference and
100
- * save_recent_image show it; the eager and script-demand fetches log the
101
- * error alone. */
102
- remedy: string;
103
- }
104
-
105
100
  /** A fetch's result: the saved copy, or why it failed, which every failure
106
101
  * says with both a clause and a remedy. The fields the other side lacks are
107
102
  * declared undefined, so a caller can read them before checking ok. */
@@ -116,10 +111,49 @@ export type FetchOutcome =
116
111
  error?: undefined;
117
112
  remedy?: undefined;
118
113
  }
119
- | ({ ok: false; path?: undefined; bytes?: undefined; mimeType?: undefined; digestVerified?: undefined } & Refusal);
114
+ | ({ ok: false; path?: undefined; bytes?: undefined; mimeType?: undefined; digestVerified?: undefined } & Pick<Refusal, 'error' | 'remedy'>);
120
115
 
121
116
  const refused = (error: string, remedy: string): { refusal: Refusal } => ({ refusal: { error, remedy } });
122
117
  const fail = (error: string, remedy: string): FetchOutcome => ({ ok: false, error, remedy });
118
+ /** A timeout, naming the timeout that ran out: its starter's, when the
119
+ * fetch is aborted at its deadline, or a caller's own, when its wait ends
120
+ * while the fetch or its save goes on. */
121
+ function tookLonger(timeoutMs: number): FetchOutcome {
122
+ const seconds = timeoutMs / 1000;
123
+ return fail(`it took longer than ${seconds} second${seconds === 1 ? '' : 's'}`, TRY_LATER);
124
+ }
125
+
126
+ /** The two endings that name a call's own byte ceiling, as a function of it,
127
+ * so a caller that joined a fetch is told its own ceiling, not its
128
+ * starter's. */
129
+ const claimOver = (claimed: number) => (ceiling: number): string =>
130
+ `it claims ${claimed} bytes, over the ${ceiling} bytes this call allows`;
131
+ const readPast = (received: number) => (ceiling: number): string =>
132
+ `the download had reached ${received} bytes, past the ${ceiling} bytes this call allows, so it was stopped and nothing was kept`;
133
+
134
+ /** How a fetch ended, with the bound of its starter's it stopped on, if any:
135
+ * its byte ceiling, which a claim over it reaches before dialing too, or its
136
+ * deadline. A caller that takes a ceiling ending is told its own ceiling
137
+ * (atCeiling). A deadline ending names its starter's timeout, which ran out
138
+ * unanswered, so it holds for every caller that takes it. */
139
+ type Ended = FetchOutcome & { stoppedBy?: 'ceiling' | 'deadline'; atCeiling?: (ceiling: number) => string };
140
+
141
+ /** A fetch in flight, under the bounds of the caller that started it: its
142
+ * byte ceiling and its timeout. */
143
+ interface Flight {
144
+ ended: Promise<Ended>;
145
+ ceiling: number;
146
+ timeoutMs: number;
147
+ }
148
+
149
+ /** What the promise settles to, or undefined if the deadline, on the
150
+ * monotonic clock, comes first. */
151
+ function settledBy<T>(promise: Promise<T>, deadline: number): Promise<T | undefined> {
152
+ return new Promise((resolve, reject) => {
153
+ const timer = setTimeout(() => resolve(undefined), Math.max(0, deadline - performance.now()));
154
+ promise.finally(() => clearTimeout(timer)).then(resolve, reject);
155
+ });
156
+ }
123
157
 
124
158
  /** What calling again can do about a server's HTTP status. A later try can
125
159
  * work after a timeout, a conflict or lock the server's state can clear, a
@@ -191,101 +225,107 @@ const EXT_BY_MIME = new Map<string, string>(Object.entries({
191
225
  'application/pdf': 'pdf', 'application/zip': 'zip',
192
226
  }));
193
227
 
194
- /** A server's address as it's configured, its scheme and host, for a
195
- * clause: never its userinfo, path or query. URL's own origin is 'null' for
196
- * a scheme that isn't special, which normalizedOrigin accepts. The caller
197
- * has already parsed it. */
198
- function configuredOrigin(url: string): string {
199
- const u = new URL(url);
200
- return `${u.protocol}//${u.host}`;
201
- }
202
-
203
- /** ws→http / wss→https with default ports elided, for origin comparison. */
204
- export function normalizedOrigin(url: string): string | null {
205
- try {
206
- const u = new URL(url);
207
- const scheme = u.protocol === 'ws:' ? 'http:' : u.protocol === 'wss:' ? 'https:' : u.protocol;
208
- const defaultPort = scheme === 'https:' ? '443' : '80';
209
- const port = u.port === '' || u.port === defaultPort ? '' : `:${u.port}`;
210
- return `${scheme}//${u.hostname.toLowerCase()}${port}`;
211
- } catch {
212
- return null;
213
- }
214
- }
215
-
216
228
  export class ReferenceFetcher {
217
229
  /** Bytes read through each server in this process, charged to the server a
218
230
  * fetch dials through as each chunk is read, kept or not. Concurrent
219
231
  * fetches through one server share the total, so each checks what the
220
232
  * others have taken. */
221
233
  private totals = new Map<string, number>();
222
- private inFlight = new Map<string, Promise<FetchOutcome>>();
234
+ private inFlight = new Map<string, Flight>();
223
235
 
224
236
  constructor(
237
+ /** A server's address, credential and limits, by id, from the
238
+ * framework's own config: the origin a fetch is checked against and the
239
+ * token it carries come from one answer. */
225
240
  private readonly resolveServer: (serverId: string) => ServerResolution | undefined,
226
241
  /** Persist the payload; returns a mount-prefixed path or null when no
227
242
  * writable mount without an include exists. File name is host-generated (§7.3). */
228
243
  private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
229
244
  ) {}
230
245
 
231
- /** Would the eager (autofetch) default fetch this record? Not one the fetch
232
- * would refuse before dialing, asked as the eager fetch asks it, with the
233
- * default ceiling, so such a record takes no eager slot; and only one whose
234
- * claimed size is within the eager limit of the server it dials. For one
235
- * whose fetch is already under way, which the eager fetch would join as
236
- * fetch() does, only the server and its eager limit enter: the expiry,
237
- * the budget and the ceiling were checked when that fetch began. */
246
+ /** The server a check (dialServer or dialCheck) chose, with its credential
247
+ * and limits from the same resolver answer whose address the check read:
248
+ * each server's resolution is read once here, so the origin the check
249
+ * passes and the token the fetch sends come from one answer, whatever a
250
+ * resolver answers the next time it's asked. */
251
+ private chosen(
252
+ check: (addressOf: (serverId: string) => ServerAddress | undefined) => { serverId: string } | { refusal: Refusal },
253
+ ): { serverId: string; binding: ResolvedServerBinding } | { refusal: Refusal } {
254
+ const resolutions = new Map<string, ServerResolution | undefined>();
255
+ const addressOf = (serverId: string): ServerAddress | undefined => {
256
+ if (!resolutions.has(serverId)) resolutions.set(serverId, this.resolveServer(serverId));
257
+ const resolution = resolutions.get(serverId);
258
+ return resolution === undefined || resolution === 'no-address' ? resolution : { url: resolution.url };
259
+ };
260
+ const server = check(addressOf);
261
+ if ('refusal' in server) return server;
262
+ // The check chose only a server whose address it read as a url, so that
263
+ // server's resolution is a binding.
264
+ return { serverId: server.serverId, binding: resolutions.get(server.serverId) as ResolvedServerBinding };
265
+ }
266
+
267
+ /** The server a fetch of a record dials (§6.2), with its credential and
268
+ * limits, or why none can be. */
269
+ private serverFor(record: ReferenceRecord): { serverId: string; binding: ResolvedServerBinding } | { refusal: Refusal } {
270
+ return this.chosen((addressOf) => dialServer(record, addressOf));
271
+ }
272
+
273
+ /** Would the eager (autofetch) default fetch this record? Not one whose
274
+ * eager fetch already failed; not one the fetch would refuse before
275
+ * dialing, asked with the default ceiling, so such a record takes no eager
276
+ * slot; and only one whose claimed size is within the eager limit of the
277
+ * server it dials. For one whose fetch is already under way, which the
278
+ * eager fetch would join as fetch() does, only the server and its eager
279
+ * limit enter: the expiry, the budget and the ceiling were checked when
280
+ * that fetch began. */
238
281
  isEagerEligible(record: ReferenceRecord): boolean {
239
- if (record.fetchedPath) return false;
282
+ if (record.fetchedPath || record.eagerFailed) return false;
240
283
  const check = this.inFlight.has(record.refId) ? this.serverFor(record) : this.beforeDial(record);
241
284
  if ('refusal' in check) return false;
242
- const cap = check.binding.autofetch?.maxBytes ?? DEFAULT_EAGER_FETCH_MAX_BYTES;
243
- return record.testimony.sizeBytes !== undefined && record.testimony.sizeBytes <= cap;
285
+ return record.testimony.sizeBytes !== undefined && record.testimony.sizeBytes <= this.eagerLimit(check.binding);
244
286
  }
245
287
 
246
- /** The server to dial for a record (§6.2): the first, in the order they
247
- * delivered it, whose address is on the uri's origin. When none is, the
248
- * first server's refusal (unknown to this host, with no address to dial,
249
- * or the origin check's), and for a record no server delivered, that it's
250
- * unbound. */
251
- private serverFor(record: ReferenceRecord): { serverId: string; binding: ResolvedServerBinding } | { refusal: Refusal } {
252
- let refusal: { refusal: Refusal } | undefined;
253
- for (const serverId of record.serverIds) {
254
- const binding = this.resolveServer(serverId);
255
- if (!binding) {
256
- refusal ??= refused(`this host no longer has the "${serverId}" server it came from`, SERVER_BACK);
257
- continue;
258
- }
259
- if (binding === 'no-address') {
260
- refusal ??= refused(noAddressReason(serverId), NOTHING_TO_DO);
261
- continue;
262
- }
263
- const offOrigin = this.originCheck(record, binding, serverId);
264
- if (offOrigin === null) return { serverId, binding };
265
- refusal ??= offOrigin;
266
- }
267
- // A later delivery of the same uri by a server binds this record.
268
- return refusal ?? refused(`it's ${UNBOUND_REASON}`, FRESH_REFERENCE);
288
+ /** The eager default's fetch. One it starts reads no further than the first
289
+ * chunk past the server's eager limit, whatever size the reference claims;
290
+ * one it joins reads under its starter's ceiling, and that fetch's success
291
+ * answers it too (fetch()). It waits no longer than
292
+ * EAGER_FETCH_TIMEOUT_MS. One that fails is remembered on the record, so
293
+ * the eager default doesn't choose that reference again; fetch_reference
294
+ * still fetches it. */
295
+ async fetchEager(record: ReferenceRecord): Promise<FetchOutcome> {
296
+ const server = this.serverFor(record);
297
+ const maxBytes = this.eagerLimit('refusal' in server ? undefined : server.binding);
298
+ const outcome = await this.fetch(record, { maxBytes, timeoutMs: EAGER_FETCH_TIMEOUT_MS });
299
+ if (!outcome.ok) record.eagerFailed = true;
300
+ return outcome;
301
+ }
302
+
303
+ /** This server's eager limit: its autofetch.maxBytes,
304
+ * DEFAULT_EAGER_FETCH_MAX_BYTES when that's unset. Eligibility holds a
305
+ * claim to it, and a fetch the eager default starts stops reading at the
306
+ * first chunk past it. The host's own ceiling, which every fetch checks
307
+ * first, still holds past a larger one. */
308
+ private eagerLimit(binding: ResolvedServerBinding | undefined): number {
309
+ return binding?.autofetch?.maxBytes ?? DEFAULT_EAGER_FETCH_MAX_BYTES;
269
310
  }
270
311
 
271
312
  /** Every check the fetch makes before it dials, in its order: the server
272
313
  * (none, unknown, no address, an address of its own or the reference's
273
314
  * that doesn't parse, a reference address carrying a user name or
274
- * password, a scheme other than http: or https:, off the uri's origin),
275
- * the expiry, a claimed size over the host's own ceiling, the server's
276
- * spent budget, a claimed size over a call's ceiling. For a record with no
277
- * saved copy and no fetch under way, the fetch, `refusal()` and the eager
278
- * check all ask this, so what save_recent_image says is the reason the
279
- * fetch would refuse with, and no eager slot goes to a fetch that would be
280
- * refused. */
315
+ * password, a scheme other than http: or https:, off the uri's origin)
316
+ * and the expiry, which dialCheck makes as a reference's stub does; then
317
+ * a claimed size over the host's own ceiling, the server's spent budget,
318
+ * a claimed size over a call's ceiling. For a record with no saved copy
319
+ * and no fetch under way, the fetch, `refusal()` and the eager check all
320
+ * ask this, so what save_recent_image says is the reason the fetch would
321
+ * refuse with, and no eager slot goes to a fetch that would be refused. */
281
322
  private beforeDial(record: ReferenceRecord, opts?: { maxBytes?: number }):
282
- { refusal: Refusal } | { serverId: string; binding: ResolvedServerBinding; ceiling: number; budget: number } {
283
- const server = this.serverFor(record);
323
+ { refusal: Refusal; byCeiling?: true; atCeiling?: (ceiling: number) => string } |
324
+ { serverId: string; binding: ResolvedServerBinding; ceiling: number; budget: number } {
325
+ const server = this.chosen((addressOf) => dialCheck(record, addressOf));
284
326
  if ('refusal' in server) return server;
285
327
  const { serverId, binding } = server;
286
328
  const t = record.testimony;
287
- const expiry = referenceExpiry(t);
288
- if (expiry) return refused(`the reference is ${EXPIRY_REASON[expiry]}`, FRESH_REFERENCE);
289
329
 
290
330
  const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
291
331
  // A claim past the host's own ceiling is refused first: nothing lifts
@@ -299,23 +339,29 @@ export class ReferenceFetcher {
299
339
  const budget = binding.autofetch?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
300
340
  const used = this.totals.get(serverId) ?? 0;
301
341
  if (used >= budget) {
302
- return refused(`this host's fetch budget for the "${serverId}" server is used up (${used} of ${budget} bytes)`, BUDGET);
342
+ return refused(`this host's fetch budget for the "${shownServerId(serverId)}" server is used up (${used} of ${budget} bytes)`, BUDGET);
303
343
  }
304
344
  // Vector 4: a claim already over the ceiling refuses without dialing. One
305
345
  // that's also over what's left of the budget is told about the budget,
306
346
  // which no max_bytes lifts. A claim over what's left but within the
307
347
  // ceiling still dials, and the budget stops it at its first chunk past.
348
+ // Both refusals here are reached only through the call's own ceiling, so
349
+ // each is that ceiling's ending: a caller whose ceiling holds the claim
350
+ // would dial, and one that joined this call's fetch does (fetch()).
308
351
  if (t.sizeBytes !== undefined && t.sizeBytes > ceiling) {
309
352
  if (t.sizeBytes > budget - used) {
310
- return refused(
311
- `it claims ${t.sizeBytes} bytes, more than the ${budget - used} bytes left of this host's fetch budget for the "${serverId}" server`,
312
- BUDGET,
313
- );
353
+ return {
354
+ ...refused(
355
+ `it claims ${t.sizeBytes} bytes, more than the ${budget - used} bytes left of this host's fetch budget for the "${shownServerId(serverId)}" server`,
356
+ BUDGET,
357
+ ),
358
+ byCeiling: true,
359
+ };
314
360
  }
315
361
  // Within what's left of the budget and the host's ceiling, so a
316
362
  // ceiling of the call's own is what a larger max_bytes lifts.
317
- return refused(`it claims ${t.sizeBytes} bytes, over the ${ceiling} bytes this call allows`,
318
- `Pass max_bytes of at least ${t.sizeBytes} to fetch it.`);
363
+ const atCeiling = claimOver(t.sizeBytes);
364
+ return { ...refused(atCeiling(ceiling), `Pass max_bytes of at least ${t.sizeBytes} to fetch it.`), byCeiling: true, atCeiling };
319
365
  }
320
366
  return { serverId, binding, ceiling, budget };
321
367
  }
@@ -331,67 +377,63 @@ export class ReferenceFetcher {
331
377
  return 'refusal' in check ? check.refusal : null;
332
378
  }
333
379
 
334
- /** §6.1/§7.1 policy check; returns a refusal or null. */
335
- private originCheck(record: ReferenceRecord, binding: ResolvedServerBinding, serverId: string): { refusal: Refusal } | null {
336
- const refOrigin = normalizedOrigin(record.testimony.uri);
337
- if (!refOrigin) return refused("its address doesn't parse as a URL", NOTHING_TO_DO);
338
- // A server's address that doesn't parse leaves nothing to dial. The
339
- // framework's resolver gives only an address its transport dials, so this
340
- // guards other resolvers.
341
- const dialedOrigin = normalizedOrigin(binding.url);
342
- if (!dialedOrigin) {
343
- return refused(`the "${serverId}" server's address doesn't parse as a URL, so there's nowhere to fetch it from`,
344
- "Calling again won't change that unless whoever runs this host corrects the server's address.");
345
- }
346
- const refUrl = new URL(record.testimony.uri);
347
- // A user name or password in the uri is a credential embedded in it,
348
- // which RFC-005 §6.1 forbids a server to mint and lets a host refuse
349
- // (vector 6). This host neither sends nor repeats it: fetch refuses such
350
- // a url with a message that holds it whole.
351
- if (refUrl.username !== '' || refUrl.password !== '') {
352
- return refused("its address carries a user name or password, which this host doesn't send", NOTHING_TO_DO);
353
- }
354
- // The uri's own scheme, not its origin's: an origin reads ws: as http:
355
- // and wss: as https:, and fetch refuses a ws: or wss: uri ('unknown
356
- // scheme') before connecting, which would read as a failed request a
357
- // later try can mend.
358
- const scheme = refUrl.protocol;
359
- if (scheme !== 'https:' && scheme !== 'http:') {
360
- return refused(`its address uses ${scheme}, and this host fetches only http: and https: addresses`, NOTHING_TO_DO);
361
- }
362
- // Origins compare with their schemes, so this also refuses an http:
363
- // reference from a server dialed over TLS (wss://).
364
- if (refOrigin !== dialedOrigin) {
365
- return refused(`it points to ${refOrigin}, not to the "${serverId}" server's own address (${configuredOrigin(binding.url)}), ` +
366
- 'and this host fetches only from the server itself', NOTHING_TO_DO);
380
+ /** Fetch a reference to workspace storage. Idempotent per record. Callers
381
+ * of one record share the fetch in flight, each under its own bounds: it
382
+ * waits no longer than its own timeout, counted from its call and covering
383
+ * the save. When the fetch it joined stopped on its starter's byte ceiling
384
+ * and its own is wider, it fetches again under its own. When that fetch
385
+ * stopped at its starter's deadline, it fetches again only if it has more
386
+ * time left than that starter's whole timeout, since a fresh fetch with no
387
+ * more time than one that ran out unanswered would likely be stopped too;
388
+ * otherwise it takes that ending. (A refetch's flight had less than its
389
+ * starter's timeout, so this errs toward not dialing.) A fetch that
390
+ * succeeds answers every caller waiting on it, whatever its own ceiling. A
391
+ * save still running when a caller stops waiting goes on, and the record
392
+ * is marked fetched when it lands. */
393
+ async fetch(record: ReferenceRecord, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<FetchOutcome> {
394
+ const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
395
+ const timeoutMs = opts?.timeoutMs ?? FETCH_TIMEOUT_MS;
396
+ const deadline = performance.now() + timeoutMs;
397
+ for (;;) {
398
+ if (record.fetchedPath) {
399
+ return { ok: true, path: record.fetchedPath, bytes: record.verifiedBytes,
400
+ mimeType: record.verifiedMimeType, digestVerified: record.digestVerified };
401
+ }
402
+ const flight = this.inFlight.get(record.refId) ?? this.start(record, ceiling, deadline, timeoutMs);
403
+ const ended = await settledBy(flight.ended, deadline);
404
+ if (ended === undefined) return tookLonger(timeoutMs);
405
+ const { stoppedBy, atCeiling, ...outcome } = ended;
406
+ if (stoppedBy === 'ceiling') {
407
+ if (ceiling > flight.ceiling) continue;
408
+ return (atCeiling ? { ...outcome, error: atCeiling(ceiling) } : outcome) as FetchOutcome;
409
+ }
410
+ if (stoppedBy === 'deadline' && deadline - performance.now() > flight.timeoutMs) continue;
411
+ return outcome as FetchOutcome;
367
412
  }
368
- return null;
369
413
  }
370
414
 
371
- /** Fetch a reference to workspace storage. Idempotent per record; concurrent
372
- * callers share one in-flight fetch. */
373
- fetch(record: ReferenceRecord, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<FetchOutcome> {
374
- if (record.fetchedPath) {
375
- return Promise.resolve({ ok: true, path: record.fetchedPath, bytes: record.verifiedBytes,
376
- mimeType: record.verifiedMimeType, digestVerified: record.digestVerified });
377
- }
378
- const existing = this.inFlight.get(record.refId);
379
- if (existing) return existing;
380
- const p = this.doFetch(record, opts).finally(() => this.inFlight.delete(record.refId));
381
- this.inFlight.set(record.refId, p);
382
- return p;
415
+ private start(record: ReferenceRecord, ceiling: number, deadline: number, timeoutMs: number): Flight {
416
+ const flight: Flight = {
417
+ ceiling,
418
+ timeoutMs,
419
+ ended: this.doFetch(record, ceiling, deadline, timeoutMs).finally(() => this.inFlight.delete(record.refId)),
420
+ };
421
+ this.inFlight.set(record.refId, flight);
422
+ return flight;
383
423
  }
384
424
 
385
- private async doFetch(record: ReferenceRecord, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<FetchOutcome> {
425
+ private async doFetch(record: ReferenceRecord, ceiling: number, deadline: number, timeoutMs: number): Promise<Ended> {
386
426
  const t = record.testimony;
387
- const check = this.beforeDial(record, opts);
388
- if ('refusal' in check) return { ok: false, ...check.refusal };
389
- const { serverId, binding, ceiling, budget } = check;
390
- const server = `the "${serverId}" server`;
427
+ const check = this.beforeDial(record, { maxBytes: ceiling });
428
+ if ('refusal' in check) {
429
+ const refusal = fail(check.refusal.error, check.refusal.remedy);
430
+ return check.byCeiling ? { ...refusal, stoppedBy: 'ceiling', atCeiling: check.atCeiling } : refusal;
431
+ }
432
+ const { serverId, binding, budget } = check;
433
+ const server = `the "${shownServerId(serverId)}" server`;
391
434
 
392
- const timeoutMs = opts?.timeoutMs ?? FETCH_TIMEOUT_MS;
393
435
  const controller = new AbortController();
394
- const timer = setTimeout(() => controller.abort(), timeoutMs);
436
+ const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - performance.now()));
395
437
  try {
396
438
  const headers: Record<string, string> = {};
397
439
  if (binding.token) headers.authorization = `Bearer ${binding.token}`;
@@ -430,8 +472,8 @@ export class ReferenceFetcher {
430
472
  }
431
473
  if (received > ceiling) {
432
474
  controller.abort();
433
- return fail(`the download had reached ${received} bytes, past the ${ceiling} bytes this call allows, ` +
434
- 'so it was stopped and nothing was kept', largerMaxBytes(received, budget - total, server));
475
+ const atCeiling = readPast(received);
476
+ return { ...fail(atCeiling(ceiling), largerMaxBytes(received, budget - total, server)), stoppedBy: 'ceiling', atCeiling };
435
477
  }
436
478
  const buf = Buffer.from(value);
437
479
  hash.update(buf);
@@ -486,16 +528,13 @@ export class ReferenceFetcher {
486
528
  return { ok: true, path, bytes: data.length, mimeType: observedMime, digestVerified };
487
529
  } catch (e) {
488
530
  // The timer is the only abort that throws: the budget and the ceiling
489
- // abort the download and return at once.
490
- if ((e as Error)?.name === 'AbortError') {
491
- const seconds = timeoutMs / 1000;
492
- return fail(`it took longer than ${seconds} second${seconds === 1 ? '' : 's'}`, TRY_LATER);
493
- }
531
+ // abort the download and return at once. It fires at the starter's
532
+ // deadline, and a caller with more time left than the starter's whole
533
+ // timeout fetches again (fetch()).
534
+ if ((e as Error)?.name === 'AbortError') return { ...tookLonger(timeoutMs), stoppedBy: 'deadline' };
494
535
  return fail(`the request to ${server} failed: ${boundedForLog(whyFetchFailed(e))}`, REQUEST_FAILED);
495
536
  } finally {
496
537
  clearTimeout(timer);
497
538
  }
498
539
  }
499
540
  }
500
-
501
- export { EAGER_FETCH_TIMEOUT_MS };