@animalabs/agent-framework 0.23.1 → 0.24.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 (338) hide show
  1. package/README.md +212 -42
  2. package/dist/src/agent.d.ts +60 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +92 -12
  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 +14 -12
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/engine.d.ts +177 -0
  10. package/dist/src/code-execution/engine.d.ts.map +1 -0
  11. package/dist/src/code-execution/engine.js +494 -0
  12. package/dist/src/code-execution/engine.js.map +1 -0
  13. package/dist/src/code-execution/index.d.ts +5 -10
  14. package/dist/src/code-execution/index.d.ts.map +1 -1
  15. package/dist/src/code-execution/index.js +5 -9
  16. package/dist/src/code-execution/index.js.map +1 -1
  17. package/dist/src/code-execution/isolate/calls.d.ts +114 -0
  18. package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
  19. package/dist/src/code-execution/isolate/calls.js +145 -0
  20. package/dist/src/code-execution/isolate/calls.js.map +1 -0
  21. package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
  22. package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
  23. package/dist/src/code-execution/isolate/child-entry.js +5 -0
  24. package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
  25. package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
  26. package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
  27. package/dist/src/code-execution/isolate/child-main.js +132 -0
  28. package/dist/src/code-execution/isolate/child-main.js.map +1 -0
  29. package/dist/src/code-execution/isolate/child.d.ts +44 -0
  30. package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
  31. package/dist/src/code-execution/isolate/child.js +315 -0
  32. package/dist/src/code-execution/isolate/child.js.map +1 -0
  33. package/dist/src/code-execution/isolate/frames.d.ts +80 -0
  34. package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
  35. package/dist/src/code-execution/isolate/frames.js +210 -0
  36. package/dist/src/code-execution/isolate/frames.js.map +1 -0
  37. package/dist/src/code-execution/isolate/output.d.ts +68 -0
  38. package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
  39. package/dist/src/code-execution/isolate/output.js +143 -0
  40. package/dist/src/code-execution/isolate/output.js.map +1 -0
  41. package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
  42. package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
  43. package/dist/src/code-execution/isolate/prelude.js +401 -0
  44. package/dist/src/code-execution/isolate/prelude.js.map +1 -0
  45. package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
  46. package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
  47. package/dist/src/code-execution/isolate/protocol.js +150 -0
  48. package/dist/src/code-execution/isolate/protocol.js.map +1 -0
  49. package/dist/src/code-execution/isolate/render.d.ts +57 -0
  50. package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
  51. package/dist/src/code-execution/isolate/render.js +214 -0
  52. package/dist/src/code-execution/isolate/render.js.map +1 -0
  53. package/dist/src/code-execution/isolate/runner.d.ts +250 -0
  54. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
  55. package/dist/src/code-execution/isolate/runner.js +854 -0
  56. package/dist/src/code-execution/isolate/runner.js.map +1 -0
  57. package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
  58. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
  59. package/dist/src/code-execution/isolate/scripts.js +357 -0
  60. package/dist/src/code-execution/isolate/scripts.js.map +1 -0
  61. package/dist/src/code-execution/quickjs.wasm +0 -0
  62. package/dist/src/code-execution/tool-definition.d.ts +93 -43
  63. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  64. package/dist/src/code-execution/tool-definition.js +176 -142
  65. package/dist/src/code-execution/tool-definition.js.map +1 -1
  66. package/dist/src/deadline-timer.d.ts +3 -0
  67. package/dist/src/deadline-timer.d.ts.map +1 -1
  68. package/dist/src/deadline-timer.js +1 -1
  69. package/dist/src/deadline-timer.js.map +1 -1
  70. package/dist/src/framework.d.ts +1036 -440
  71. package/dist/src/framework.d.ts.map +1 -1
  72. package/dist/src/framework.js +4131 -2228
  73. package/dist/src/framework.js.map +1 -1
  74. package/dist/src/gate/event-gate.d.ts +28 -1
  75. package/dist/src/gate/event-gate.d.ts.map +1 -1
  76. package/dist/src/gate/event-gate.js +88 -10
  77. package/dist/src/gate/event-gate.js.map +1 -1
  78. package/dist/src/held-writes.d.ts +16 -3
  79. package/dist/src/held-writes.d.ts.map +1 -1
  80. package/dist/src/held-writes.js +16 -3
  81. package/dist/src/held-writes.js.map +1 -1
  82. package/dist/src/index.d.ts +6 -6
  83. package/dist/src/index.d.ts.map +1 -1
  84. package/dist/src/index.js +9 -8
  85. package/dist/src/index.js.map +1 -1
  86. package/dist/src/mcpl/channel-registry.d.ts +471 -75
  87. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  88. package/dist/src/mcpl/channel-registry.js +1673 -337
  89. package/dist/src/mcpl/channel-registry.js.map +1 -1
  90. package/dist/src/mcpl/conversation-router.d.ts +12 -7
  91. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  92. package/dist/src/mcpl/conversation-router.js +12 -7
  93. package/dist/src/mcpl/conversation-router.js.map +1 -1
  94. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  95. package/dist/src/mcpl/convert-block.js +9 -5
  96. package/dist/src/mcpl/convert-block.js.map +1 -1
  97. package/dist/src/mcpl/inbound-source.d.ts +50 -0
  98. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  99. package/dist/src/mcpl/inbound-source.js +130 -0
  100. package/dist/src/mcpl/inbound-source.js.map +1 -1
  101. package/dist/src/mcpl/index.d.ts +2 -1
  102. package/dist/src/mcpl/index.d.ts.map +1 -1
  103. package/dist/src/mcpl/index.js +3 -2
  104. package/dist/src/mcpl/index.js.map +1 -1
  105. package/dist/src/mcpl/log-value.d.ts +6 -0
  106. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  107. package/dist/src/mcpl/log-value.js +8 -0
  108. package/dist/src/mcpl/log-value.js.map +1 -1
  109. package/dist/src/mcpl/modern-connection.d.ts +15 -6
  110. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  111. package/dist/src/mcpl/modern-connection.js +168 -53
  112. package/dist/src/mcpl/modern-connection.js.map +1 -1
  113. package/dist/src/mcpl/prose-grammar.d.ts +4 -5
  114. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  115. package/dist/src/mcpl/prose-grammar.js +4 -5
  116. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  117. package/dist/src/mcpl/protocol-family.d.ts +97 -10
  118. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  119. package/dist/src/mcpl/protocol-family.js +173 -16
  120. package/dist/src/mcpl/protocol-family.js.map +1 -1
  121. package/dist/src/mcpl/push-coalescer.d.ts +132 -14
  122. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  123. package/dist/src/mcpl/push-coalescer.js +227 -50
  124. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  125. package/dist/src/mcpl/push-handler.d.ts +6 -1
  126. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  127. package/dist/src/mcpl/push-handler.js +4 -1
  128. package/dist/src/mcpl/push-handler.js.map +1 -1
  129. package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
  130. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  131. package/dist/src/mcpl/reference-fetcher.js +4 -4
  132. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  133. package/dist/src/mcpl/server-connection.d.ts +50 -21
  134. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  135. package/dist/src/mcpl/server-connection.js +158 -74
  136. package/dist/src/mcpl/server-connection.js.map +1 -1
  137. package/dist/src/mcpl/tags.d.ts +24 -0
  138. package/dist/src/mcpl/tags.d.ts.map +1 -1
  139. package/dist/src/mcpl/tags.js +33 -0
  140. package/dist/src/mcpl/tags.js.map +1 -1
  141. package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
  142. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  143. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  144. package/dist/src/mcpl/transport.d.ts +55 -7
  145. package/dist/src/mcpl/transport.d.ts.map +1 -1
  146. package/dist/src/mcpl/transport.js +163 -34
  147. package/dist/src/mcpl/transport.js.map +1 -1
  148. package/dist/src/mcpl/types.d.ts +22 -6
  149. package/dist/src/mcpl/types.d.ts.map +1 -1
  150. package/dist/src/mcpl/types.js.map +1 -1
  151. package/dist/src/module-registry.d.ts +4 -2
  152. package/dist/src/module-registry.d.ts.map +1 -1
  153. package/dist/src/module-registry.js +100 -13
  154. package/dist/src/module-registry.js.map +1 -1
  155. package/dist/src/modules/discord/index.d.ts.map +1 -1
  156. package/dist/src/modules/discord/index.js +8 -13
  157. package/dist/src/modules/discord/index.js.map +1 -1
  158. package/dist/src/modules/history/index.d.ts.map +1 -1
  159. package/dist/src/modules/history/index.js +27 -4
  160. package/dist/src/modules/history/index.js.map +1 -1
  161. package/dist/src/modules/history/semantic.d.ts +15 -3
  162. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  163. package/dist/src/modules/history/semantic.js +27 -5
  164. package/dist/src/modules/history/semantic.js.map +1 -1
  165. package/dist/src/modules/index.d.ts +1 -1
  166. package/dist/src/modules/index.d.ts.map +1 -1
  167. package/dist/src/modules/index.js.map +1 -1
  168. package/dist/src/modules/unknown-tool.d.ts +4 -1
  169. package/dist/src/modules/unknown-tool.d.ts.map +1 -1
  170. package/dist/src/modules/unknown-tool.js +4 -1
  171. package/dist/src/modules/unknown-tool.js.map +1 -1
  172. package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
  173. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  174. package/dist/src/modules/workspace/branch-intent.js +3 -1
  175. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  176. package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
  177. package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
  178. package/dist/src/modules/workspace/edit-plan.js +794 -0
  179. package/dist/src/modules/workspace/edit-plan.js.map +1 -0
  180. package/dist/src/modules/workspace/edit-text.d.ts +117 -0
  181. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
  182. package/dist/src/modules/workspace/edit-text.js +584 -0
  183. package/dist/src/modules/workspace/edit-text.js.map +1 -0
  184. package/dist/src/modules/workspace/effects.d.ts +5 -4
  185. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/effects.js +78 -25
  187. package/dist/src/modules/workspace/effects.js.map +1 -1
  188. package/dist/src/modules/workspace/index.d.ts +484 -61
  189. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/index.js +2173 -645
  191. package/dist/src/modules/workspace/index.js.map +1 -1
  192. package/dist/src/modules/workspace/observe.d.ts +213 -38
  193. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/observe.js +416 -36
  195. package/dist/src/modules/workspace/observe.js.map +1 -1
  196. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/reconcile.js +15 -8
  198. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  199. package/dist/src/modules/workspace/text.d.ts +32 -0
  200. package/dist/src/modules/workspace/text.d.ts.map +1 -0
  201. package/dist/src/modules/workspace/text.js +67 -0
  202. package/dist/src/modules/workspace/text.js.map +1 -0
  203. package/dist/src/modules/workspace/types.d.ts +210 -6
  204. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/types.js.map +1 -1
  206. package/dist/src/modules/workspace/watcher.d.ts +3 -2
  207. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  208. package/dist/src/modules/workspace/watcher.js.map +1 -1
  209. package/dist/src/operator-log.d.ts +52 -24
  210. package/dist/src/operator-log.d.ts.map +1 -1
  211. package/dist/src/operator-log.js.map +1 -1
  212. package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
  213. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  214. package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
  215. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  216. package/dist/src/recovery/offline-branch.d.ts +26 -1
  217. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  218. package/dist/src/recovery/offline-branch.js +164 -39
  219. package/dist/src/recovery/offline-branch.js.map +1 -1
  220. package/dist/src/recovery/recover-cli.js +23 -4
  221. package/dist/src/recovery/recover-cli.js.map +1 -1
  222. package/dist/src/send-tools.d.ts +11 -0
  223. package/dist/src/send-tools.d.ts.map +1 -0
  224. package/dist/src/send-tools.js +11 -0
  225. package/dist/src/send-tools.js.map +1 -0
  226. package/dist/src/tool-result-guard.d.ts +4 -3
  227. package/dist/src/tool-result-guard.d.ts.map +1 -1
  228. package/dist/src/tool-result-guard.js +5 -4
  229. package/dist/src/tool-result-guard.js.map +1 -1
  230. package/dist/src/tool-result-history.d.ts +1 -1
  231. package/dist/src/tool-result-history.js +1 -1
  232. package/dist/src/tune-out/coordinator.d.ts +21 -0
  233. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  234. package/dist/src/tune-out/coordinator.js +139 -37
  235. package/dist/src/tune-out/coordinator.js.map +1 -1
  236. package/dist/src/types/agent.d.ts +4 -0
  237. package/dist/src/types/agent.d.ts.map +1 -1
  238. package/dist/src/types/events.d.ts +62 -9
  239. package/dist/src/types/events.d.ts.map +1 -1
  240. package/dist/src/types/framework.d.ts +62 -58
  241. package/dist/src/types/framework.d.ts.map +1 -1
  242. package/dist/src/types/module.d.ts +40 -8
  243. package/dist/src/types/module.d.ts.map +1 -1
  244. package/dist/src/types/trace.d.ts +111 -10
  245. package/dist/src/types/trace.d.ts.map +1 -1
  246. package/dist/src/usage/types.d.ts +16 -0
  247. package/dist/src/usage/types.d.ts.map +1 -1
  248. package/dist/src/usage/usage-tracker.d.ts +14 -1
  249. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  250. package/dist/src/usage/usage-tracker.js +81 -41
  251. package/dist/src/usage/usage-tracker.js.map +1 -1
  252. package/package.json +6 -5
  253. package/src/agent.ts +93 -12
  254. package/src/api/server.ts +14 -12
  255. package/src/code-execution/engine.ts +595 -0
  256. package/src/code-execution/index.ts +5 -17
  257. package/src/code-execution/isolate/calls.ts +199 -0
  258. package/src/code-execution/isolate/child-entry.ts +5 -0
  259. package/src/code-execution/isolate/child-main.ts +132 -0
  260. package/src/code-execution/isolate/child.ts +327 -0
  261. package/src/code-execution/isolate/frames.ts +209 -0
  262. package/src/code-execution/isolate/output.ts +161 -0
  263. package/src/code-execution/isolate/prelude.ts +409 -0
  264. package/src/code-execution/isolate/protocol.ts +240 -0
  265. package/src/code-execution/isolate/render.ts +230 -0
  266. package/src/code-execution/isolate/runner.ts +911 -0
  267. package/src/code-execution/isolate/scripts.ts +479 -0
  268. package/src/code-execution/quickjs.wasm +0 -0
  269. package/src/code-execution/quickjs.wasm.LICENSES +149 -0
  270. package/src/code-execution/tool-definition.ts +236 -167
  271. package/src/deadline-timer.ts +1 -1
  272. package/src/framework.ts +4507 -2406
  273. package/src/gate/event-gate.ts +78 -11
  274. package/src/held-writes.ts +17 -3
  275. package/src/index.ts +30 -22
  276. package/src/mcpl/channel-registry.ts +1825 -368
  277. package/src/mcpl/conversation-router.ts +12 -7
  278. package/src/mcpl/convert-block.ts +9 -5
  279. package/src/mcpl/inbound-source.ts +131 -0
  280. package/src/mcpl/index.ts +4 -2
  281. package/src/mcpl/log-value.ts +9 -0
  282. package/src/mcpl/modern-connection.ts +162 -53
  283. package/src/mcpl/prose-grammar.ts +4 -5
  284. package/src/mcpl/protocol-family.ts +199 -21
  285. package/src/mcpl/push-coalescer.ts +306 -62
  286. package/src/mcpl/push-handler.ts +10 -2
  287. package/src/mcpl/reference-fetcher.ts +4 -4
  288. package/src/mcpl/server-connection.ts +180 -79
  289. package/src/mcpl/tags.ts +36 -0
  290. package/src/mcpl/tool-result-normalize.ts +2 -1
  291. package/src/mcpl/transport.ts +162 -33
  292. package/src/mcpl/types.ts +23 -6
  293. package/src/module-registry.ts +92 -13
  294. package/src/modules/discord/index.ts +8 -13
  295. package/src/modules/history/index.ts +25 -3
  296. package/src/modules/history/semantic.ts +29 -6
  297. package/src/modules/index.ts +1 -0
  298. package/src/modules/unknown-tool.ts +4 -1
  299. package/src/modules/workspace/branch-intent.ts +3 -1
  300. package/src/modules/workspace/edit-plan.ts +954 -0
  301. package/src/modules/workspace/edit-text.ts +618 -0
  302. package/src/modules/workspace/effects.ts +70 -19
  303. package/src/modules/workspace/index.ts +2247 -642
  304. package/src/modules/workspace/observe.ts +465 -56
  305. package/src/modules/workspace/reconcile.ts +14 -7
  306. package/src/modules/workspace/text.ts +68 -0
  307. package/src/modules/workspace/types.ts +211 -6
  308. package/src/modules/workspace/watcher.ts +3 -2
  309. package/src/operator-log.ts +47 -19
  310. package/src/recovery/discord-awareness-outbox.ts +1500 -356
  311. package/src/recovery/offline-branch.ts +189 -55
  312. package/src/recovery/recover-cli.ts +25 -4
  313. package/src/send-tools.ts +11 -0
  314. package/src/tool-result-guard.ts +6 -5
  315. package/src/tool-result-history.ts +1 -1
  316. package/src/tune-out/coordinator.ts +163 -42
  317. package/src/types/agent.ts +4 -0
  318. package/src/types/events.ts +62 -9
  319. package/src/types/framework.ts +62 -58
  320. package/src/types/module.ts +40 -8
  321. package/src/types/trace.ts +121 -15
  322. package/src/usage/types.ts +16 -0
  323. package/src/usage/usage-tracker.ts +86 -43
  324. package/dist/src/code-execution/py-runner.d.ts +0 -191
  325. package/dist/src/code-execution/py-runner.d.ts.map +0 -1
  326. package/dist/src/code-execution/py-runner.js +0 -655
  327. package/dist/src/code-execution/py-runner.js.map +0 -1
  328. package/dist/src/code-execution/runtime-py.d.ts +0 -50
  329. package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
  330. package/dist/src/code-execution/runtime-py.js +0 -677
  331. package/dist/src/code-execution/runtime-py.js.map +0 -1
  332. package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
  333. package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
  334. package/dist/src/mcpl/prose-stream-router.js +0 -291
  335. package/dist/src/mcpl/prose-stream-router.js.map +0 -1
  336. package/src/code-execution/py-runner.ts +0 -791
  337. package/src/code-execution/runtime-py.ts +0 -677
  338. package/src/mcpl/prose-stream-router.ts +0 -268
@@ -0,0 +1,911 @@
1
+ /**
2
+ * The parent's side of one script: its child process, the calls it makes, its
3
+ * output, and how it ends. One `ScriptRun` per script, and one child per run,
4
+ * which ends with it, so nothing a script held stays after it.
5
+ *
6
+ * Every frame from the child is decoded and checked before the parent acts on
7
+ * it, since the child is untrusted. A frame that breaks the protocol, a child
8
+ * that exits without saying how the script ended, or one that doesn't stop
9
+ * when asked, ends the run as crashed. A tool call goes to `onToolCall`, the
10
+ * bridge to the framework's dispatcher, and its outcome goes into the run's
11
+ * call record at dispatch, as running until it's answered.
12
+ *
13
+ * What the host holds for a script is bounded: at most MAX_CALLS_IN_FLIGHT of
14
+ * its calls wait for answers at once, and at most MAX_CALLS_IN_FLIGHT are out
15
+ * in the host, timed-out ones whose tools still run included, with any past
16
+ * those waiting here as their text. Each call's arguments are within
17
+ * MAX_ARGUMENT_BYTES and MAX_ARGUMENT_VALUES, and each answer is at most
18
+ * `maxResultBytes`. Answers wait for the child as their frames, written while
19
+ * its stdin takes them and after its 'drain' once it hasn't, so each is held
20
+ * once, in bytes.
21
+ */
22
+
23
+ import { spawn, type ChildProcess } from 'node:child_process';
24
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { extname, join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import type { Readable, Writable } from 'node:stream';
29
+ import { encodeFrame, encodeResultFrame, FrameDecoder, FrameError, RESULT_FRAME_OVERHEAD } from './frames.js';
30
+ import { MAX_BATCH_CHARS, WindowOutput, type OutputBounds, type OutputWindow } from './output.js';
31
+ import { WAKE_CALL_NAME, type PreludeTool } from './prelude.js';
32
+ import { CallLog, callBrief, type CallEntry, type CallOutcome, type CallSummary } from './calls.js';
33
+ import {
34
+ argumentsRefusal,
35
+ capText,
36
+ MAX_CHILD_FRAME_BYTES,
37
+ decodeChildMessage,
38
+ MAX_ARGUMENT_BYTES,
39
+ MAX_ARGUMENT_VALUES,
40
+ MAX_CALLS_IN_FLIGHT,
41
+ MAX_WAKE_PAYLOAD_BYTES,
42
+ RESULT_TOO_LARGE,
43
+ wakeRefusal,
44
+ type ChildMessage,
45
+ type GuestError,
46
+ type StopReason,
47
+ } from './protocol.js';
48
+ import { bounded, CUT_MARK_CHARS, formatMs, glimpse, NAME_CHARS, VALUE_CHARS } from './render.js';
49
+
50
+ /** A call's answer for the guest: `ok` resolves its promise with the parsed JSON, otherwise rejects with it. */
51
+ export interface CallAnswer {
52
+ ok: boolean;
53
+ json: string;
54
+ }
55
+
56
+ /** A tool call's answer from the bridge. */
57
+ export interface ToolAnswer extends CallAnswer {
58
+ /** The result was marked as an error. */
59
+ isError?: boolean;
60
+ /** The start of what the tool answered, as an agent would read it, for naming a call the script stopped waiting for. */
61
+ brief?: string;
62
+ /**
63
+ * No result came from the host in time, so the bridge answered for it: the
64
+ * tool may still be running, holding its work, or its result was lost, and
65
+ * the host can't tell which. Its place stays taken until the run ends.
66
+ */
67
+ held?: boolean;
68
+ }
69
+
70
+ /** How a script ended. */
71
+ export type Ending =
72
+ | { how: 'finished'; value?: string }
73
+ | { how: 'failed'; error: GuestError; stalled?: boolean }
74
+ /** `short` when the child had to be ended, so its output so far may be short. */
75
+ | { how: 'stopped'; reason: StopReason; short?: boolean; why?: string }
76
+ | { how: 'crashed'; detail: string };
77
+
78
+ /** A run as it stands: its output since the last return, its calls, and its ending once it has one. */
79
+ export interface RunSnapshot {
80
+ output: string;
81
+ calls: CallSummary;
82
+ ending: Ending | null;
83
+ }
84
+
85
+ /** How many script children a host runs at once, counted until each child's process has exited. */
86
+ export class ChildCapacity {
87
+ private live = 0;
88
+ constructor(readonly max: number) {}
89
+ get running(): number {
90
+ return this.live;
91
+ }
92
+ /** Take a place, or false when the host is full. */
93
+ take(): boolean {
94
+ if (this.live >= this.max) return false;
95
+ this.live += 1;
96
+ return true;
97
+ }
98
+ release(): void {
99
+ this.live = Math.max(0, this.live - 1);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The child's entry, beside this module and with its extension: `.ts` where
105
+ * the host runs this package's source, as a Bun host does through the
106
+ * package's `bun` export, and `.js` where it runs the build. So the runtime
107
+ * is given a file that exists: given a `.js` path that it maps to the `.ts`
108
+ * beside it, Bun runs it as `bun run` would, with npm_* variables and a PATH
109
+ * of its own in the child's environment.
110
+ */
111
+ export const CHILD_ENTRY = fileURLToPath(new URL(`./child-entry${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
112
+
113
+ export interface ScriptRunOptions {
114
+ /** The child's entry: a script the runtime runs as the child process (CHILD_ENTRY, but for tests). */
115
+ childEntry: string;
116
+ /** Arguments after the entry, if any. */
117
+ childArgs?: readonly string[];
118
+ /** The runtime to run it with: by default the parent's own. */
119
+ execPath?: string;
120
+ memoryMaxBytes: number;
121
+ maxStackBytes?: number;
122
+ output: OutputBounds;
123
+ /** How often, at most, the child flushes output while its guest computes. */
124
+ flushMs: number;
125
+ /** The script's limit: at this age it's stopped, as "stopped at its limit". */
126
+ lifetimeMs: number;
127
+ /** How long a call may go unanswered before the script is told it timed out. */
128
+ toolCallTimeoutMs: number;
129
+ /** How long a stopped child has to report its ending before it's ended. */
130
+ stopGraceMs: number;
131
+ /** The least time between two of the script's wakes: an early one waits for its turn. */
132
+ wakeMinIntervalMs: number;
133
+ /** The most wakes one script may send; past it, a wake is refused. */
134
+ maxWakes: number;
135
+ /** The bridge: dispatch one tool call. Never rejects; a refusal is `ok: false`. */
136
+ onToolCall(name: string, argsJson: string): Promise<ToolAnswer>;
137
+ /** Send `wake_agent(payload)`, the script's `n`th wake, once its turn has come: answers the guest's promise. */
138
+ onWake(payloadJson: string, n: number): Promise<CallAnswer>;
139
+ /** Called once, when the run has an ending. */
140
+ onEnd?(ending: Ending): void;
141
+ }
142
+
143
+ /** The most of a child's stderr kept, for a crash's detail. */
144
+ const STDERR_KEEP_CHARS = 4_000;
145
+ /**
146
+ * The most values a frame from the child may hold. Its largest, a failure's
147
+ * ending, holds eight, since arguments, output and values travel as strings.
148
+ */
149
+ const MAX_CHILD_FRAME_VALUES = 32;
150
+ /** How much of a refusal too long for the script's memory reaches it. */
151
+ const REFUSAL_GLIMPSE_CHARS = 2000;
152
+
153
+ /** Remove a run's directory. A failure leaves only an empty directory behind, which mustn't stop the run's ending. */
154
+ function removeDir(dir: string): void {
155
+ try {
156
+ rmSync(dir, { recursive: true, force: true });
157
+ } catch {
158
+ // Nothing else to do: the directory is left.
159
+ }
160
+ }
161
+
162
+ export { MAX_CALLS_IN_FLIGHT };
163
+
164
+ /**
165
+ * The largest result the parent sends a script: a third of its linear memory,
166
+ * since parsing one takes about three times its size there (a heap run under
167
+ * Bun 1.4.3 and Node 24.19.0). A larger one would fail in the guest anyway,
168
+ * after growing the child past its budget, so it's refused before it's sent,
169
+ * and the script's ResultTooLarge says the call took effect.
170
+ */
171
+ export function maxResultBytes(memoryMaxBytes: number): number {
172
+ return Math.max(0, Math.floor(memoryMaxBytes / 3));
173
+ }
174
+
175
+ /** A refusal's own text, from its JSON, for a refusal that came with no brief. */
176
+ function refusalText(json: string): string {
177
+ try {
178
+ const message = (JSON.parse(json) as { message?: unknown }).message;
179
+ if (typeof message === 'string') return message;
180
+ } catch {
181
+ // Not a refusal's JSON: its text is what there is.
182
+ }
183
+ return json;
184
+ }
185
+
186
+ export class ScriptRun {
187
+ private child: ChildProcess | null = null;
188
+ private dir: string | null = null;
189
+ private readonly output: WindowOutput;
190
+ private readonly log = new CallLog();
191
+ /** The ids of the guest's calls the child named, reserved until it says the script has their answer. */
192
+ private readonly pendingIds = new Set<number>();
193
+ /** The calls answered and not yet seen: only these may the child say the script has. */
194
+ private readonly answeredIds = new Set<number>();
195
+ /** The script's tools, the only names its calls may have. */
196
+ private toolNames = new Set<string>();
197
+ /** Results sent to the guest and not yet seen: each is counted once the child says the script has it, and whether it fit. */
198
+ private readonly delivered = new Map<number, { entry: CallEntry; outcome: CallOutcome; brief: string }>();
199
+ private ending: Ending | null = null;
200
+ private readonly waiters = new Set<() => void>();
201
+ private stderr = '';
202
+ private lifetimeTimer: ReturnType<typeof setTimeout> | null = null;
203
+ private killTimer: ReturnType<typeof setTimeout> | null = null;
204
+ private processExited = false;
205
+ private stopAsked: { reason: StopReason; why?: string } | null = null;
206
+ /** Each unanswered call's timeout, cleared when it's answered or the run ends. */
207
+ private readonly callTimers = new Set<ReturnType<typeof setTimeout>>();
208
+ /** The calls holding a place among those in flight, by id, until the child says the script has their answer. */
209
+ private readonly inFlight = new Set<number>();
210
+ /**
211
+ * How many calls the bridge holds, until it answers each: a call that timed
212
+ * out in the script, and so left its place there, may still hold its
213
+ * arguments in the host. Counted, not keyed by id, since the child may name
214
+ * a timed-out call's id again once the script has seen its TimeoutError.
215
+ * At most MAX_CALLS_IN_FLIGHT; a call past them waits in `dispatchWaiting`,
216
+ * as its text, until one is answered. One the bridge answered for (`held`)
217
+ * keeps its place until the run ends.
218
+ */
219
+ private hostCalls = 0;
220
+ /** Calls waiting for the bridge to hold fewer: each resolved with true when its turn comes, false when it won't be sent. */
221
+ private readonly dispatchWaiting = new Map<number, (go: boolean) => void>();
222
+ /** Wakes the child has sent, which the cap counts: those refused once a stop was asked included, so a child that ignores the stop is still bounded. */
223
+ private wakeFrames = 0;
224
+ /** Wakes handed to the host to deliver, whatever it answered: a wake's number, which the host is given. */
225
+ private wakes = 0;
226
+ /** Wakes the host delivered, its answer neither a refusal nor a failure. */
227
+ private delivered_wakes = 0;
228
+ /** Wakes waiting for their turn, which the run's end counts as never sent. */
229
+ private wakesWaiting = 0;
230
+ /** When the next wake may go out, on the monotonic clock: the spacing counts from when the last wake's delivery ended. */
231
+ private nextWakeAt = 0;
232
+ /** Settles once the latest wake has gone out, or won't: wakes go out in the order they were made. */
233
+ private wakeChain: Promise<void> = Promise.resolve();
234
+ /** Wakes waiting out the spacing, each released at its time or when the run ends. */
235
+ private readonly wakeWaiters = new Set<() => void>();
236
+ /**
237
+ * Results waiting for the child's stdin to drain, as their frames: each holds
238
+ * its JSON's bytes, which the result bound counts. A result's id becomes one
239
+ * the child may say it has seen once its frame is written.
240
+ */
241
+ private readonly resultQueue: Array<{ id: number; frame: Buffer }> = [];
242
+ private draining = false;
243
+ private exitResolve!: () => void;
244
+ private endedResolve!: () => void;
245
+ /** Resolves when the run ends: a wake waiting behind another's delivery stops waiting then. */
246
+ private readonly endedSignal: Promise<void> = new Promise((resolve) => (this.endedResolve = resolve));
247
+ /** Resolves when the child's process has exited, or at once if it never started. */
248
+ readonly exited: Promise<void> = new Promise((resolve) => (this.exitResolve = resolve));
249
+
250
+ constructor(private readonly options: ScriptRunOptions) {
251
+ this.output = new WindowOutput(options.output);
252
+ }
253
+
254
+ /**
255
+ * Spawn the child and send it the script. A start frame too long for a frame
256
+ * throws before anything is spawned, with `exited` resolved, so nothing is
257
+ * left running and the place it took frees at once. A child that can't be
258
+ * spawned crashes the run.
259
+ */
260
+ start(script: string, tools: readonly PreludeTool[]): void {
261
+ let dir: string | null = null;
262
+ let startFrame: Buffer;
263
+ try {
264
+ this.toolNames = new Set(tools.map((t) => t.name));
265
+ // Absolute, since the child starts in it: a relative TMPDIR would name
266
+ // the stop file from two different places.
267
+ dir = resolve(mkdtempSync(join(tmpdir(), 'af-script-')));
268
+ startFrame = encodeFrame({
269
+ t: 'start',
270
+ script,
271
+ tools: tools.map((t) => ({ name: t.name, description: t.description, ...(t.inputSchema === undefined ? {} : { inputSchema: t.inputSchema }) })),
272
+ memoryMaxBytes: this.options.memoryMaxBytes,
273
+ ...(this.options.maxStackBytes === undefined ? {} : { maxStackBytes: this.options.maxStackBytes }),
274
+ stopFile: join(dir, 'stop'),
275
+ parentPid: process.pid,
276
+ flushMs: this.options.flushMs,
277
+ maxWakes: this.options.maxWakes,
278
+ });
279
+ } catch (err) {
280
+ if (dir) removeDir(dir);
281
+ this.exitResolve();
282
+ throw err;
283
+ }
284
+ this.dir = dir;
285
+ const runDir = dir;
286
+ // A process that never started never exits: the run crashes, and its place
287
+ // is free now. It counts as exited, so the ending arms no kill and holds no child.
288
+ const neverStarted = (why: unknown): void => {
289
+ this.processExited = true;
290
+ this.crash(`its process could not run: ${why instanceof Error ? why.message : String(why)}`);
291
+ removeDir(runDir);
292
+ this.exitResolve();
293
+ };
294
+ let child: ChildProcess;
295
+ try {
296
+ child = spawn(this.options.execPath ?? process.execPath, [this.options.childEntry, ...(this.options.childArgs ?? [])], {
297
+ // The parent's frames go in on stdin, and the child's come back on fd 3 (child-main.ts says why).
298
+ stdio: ['pipe', 'ignore', 'pipe', 'pipe'],
299
+ // The child is ours and reads nothing from its environment; the guest has none at all.
300
+ env: {},
301
+ // Its own directory, which holds nothing but the stop file: Bun reads a
302
+ // .env file, and its bunfig.toml, from the directory it starts in, so
303
+ // in the host's it would load the host's .env into the child.
304
+ cwd: runDir,
305
+ });
306
+ } catch (err) {
307
+ // Node throws here for some arguments it refuses at once.
308
+ neverStarted(err);
309
+ return;
310
+ }
311
+ // Node emits a spawn's 'error' on a later tick, which this takes. Bun's can
312
+ // come while spawn runs, before this is attached: spawn then throws it into
313
+ // the catch above, or returns with its pipes null, which the check below
314
+ // takes.
315
+ child.on('error', (err) => {
316
+ if (child.pid === undefined) neverStarted(err);
317
+ else this.crash(`its process could not run: ${err.message}`);
318
+ });
319
+ this.child = child;
320
+ const toChild = child.stdin as Writable | null;
321
+ const fromChild = child.stdio[3] as Readable | null;
322
+ if (!toChild || !fromChild) {
323
+ // Bun can return with no pipes and emit the spawn's 'error', which names
324
+ // the cause, just after: a turn of the loop lets it come first.
325
+ setImmediate(() => neverStarted(new Error('its pipes were never opened')));
326
+ return;
327
+ }
328
+ // A child's frames are small flat objects, its arguments and output
329
+ // carried as strings, so one holding more values isn't following the
330
+ // protocol, and is refused before it's parsed.
331
+ const decoder = new FrameDecoder(MAX_CHILD_FRAME_BYTES, false, MAX_CHILD_FRAME_VALUES);
332
+ fromChild.on('data', (chunk: Buffer) => {
333
+ let values: unknown[];
334
+ try {
335
+ values = decoder.push(chunk);
336
+ } catch (err) {
337
+ this.crash(`its process sent the host a message it couldn't read (${(err as FrameError).message})`);
338
+ return;
339
+ }
340
+ for (const value of values) {
341
+ const message = decodeChildMessage(value);
342
+ if ('invalid' in message) {
343
+ this.crash(`its process sent the host a message it couldn't read (${message.invalid})`);
344
+ return;
345
+ }
346
+ this.handle(message);
347
+ }
348
+ });
349
+ // Whether the child said how the script ended is known once its process has
350
+ // exited and fd 3 has ended, so every frame it wrote has been read: Node can
351
+ // emit 'exit' before it has read a child's last frames (1 in 2,700 runs of a
352
+ // probe under Node 20.20.2), and Bun 1.4.3 emits 'close' before an extra
353
+ // pipe like fd 3 has ended. A crash's detail waits for stderr's end too, for
354
+ // the same reason.
355
+ let exit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
356
+ let channelEnded = false;
357
+ let stderrEnded = child.stderr === null;
358
+ const decide = (): void => {
359
+ if (!exit || !channelEnded || !stderrEnded || this.ending !== null) return;
360
+ const how = exit.signal ? `was ended by ${exit.signal}` : `exited with code ${exit.code}`;
361
+ if (this.stopAsked) this.end({ how: 'stopped', reason: this.stopAsked.reason, short: true, ...(this.stopAsked.why ? { why: this.stopAsked.why } : {}) });
362
+ else this.crash(`its process ${how} before the script ended${this.stderr ? `: ${this.stderr.trim()}` : ''}`);
363
+ };
364
+ const channelEnd = (): void => {
365
+ if (channelEnded) return;
366
+ channelEnded = true;
367
+ // A frame cut off at the channel's end can't be read: the child broke off
368
+ // mid-message. Once a stop was asked that's the stop's doing, since the
369
+ // grace's kill can cut a frame, and the run ends as stopped.
370
+ try {
371
+ decoder.end();
372
+ } catch (err) {
373
+ if (!this.stopAsked) this.crash(`its process sent the host a message it couldn't read (${(err as FrameError).message})`);
374
+ }
375
+ decide();
376
+ };
377
+ fromChild.on('end', channelEnd);
378
+ fromChild.on('close', channelEnd);
379
+ fromChild.on('error', () => {});
380
+ toChild.on('error', () => {});
381
+ const stderrEnd = (): void => {
382
+ stderrEnded = true;
383
+ decide();
384
+ };
385
+ child.stderr?.on('data', (chunk: Buffer) => {
386
+ this.stderr = (this.stderr + chunk.toString('utf8')).slice(-STDERR_KEEP_CHARS);
387
+ });
388
+ child.stderr?.on('end', stderrEnd);
389
+ child.stderr?.on('close', stderrEnd);
390
+ child.stderr?.on('error', () => {});
391
+ // The host's place for the child frees when its process exits. fd 3 and
392
+ // stderr have the grace to end after it, which only a process the child
393
+ // left holding them could stretch.
394
+ child.on('exit', (code, signal) => {
395
+ exit = { code, signal };
396
+ this.processExited = true;
397
+ if (this.killTimer) clearTimeout(this.killTimer);
398
+ removeDir(runDir);
399
+ this.exitResolve();
400
+ if (!channelEnded || !stderrEnded) {
401
+ setTimeout(() => {
402
+ // After the next poll, so what the child wrote before it exited is
403
+ // read first, even when this loop was too busy to read it in the grace.
404
+ setImmediate(() => {
405
+ fromChild.destroy();
406
+ child.stderr?.destroy();
407
+ channelEnd();
408
+ stderrEnd();
409
+ });
410
+ }, this.options.stopGraceMs).unref();
411
+ }
412
+ decide();
413
+ if (this.ending !== null) this.release();
414
+ });
415
+ this.write(startFrame);
416
+ this.lifetimeTimer = setTimeout(() => this.stop('limit'), this.options.lifetimeMs);
417
+ }
418
+
419
+ /** The run as it stands, its output since the last return included. */
420
+ snapshot(): RunSnapshot {
421
+ // Calls waiting for room in the host, and wakes waiting for their turn, are the script's too.
422
+ return { output: this.output.render(), calls: this.log.summary(this.dispatchWaiting.size + this.wakesWaiting), ending: this.ending };
423
+ }
424
+
425
+ /** The output since the last return, as it stands. */
426
+ outputWindow(): OutputWindow {
427
+ return this.output.window();
428
+ }
429
+
430
+ /** The output since the last return, starting a fresh stretch from here. */
431
+ takeOutput(): OutputWindow {
432
+ return this.output.take();
433
+ }
434
+
435
+ get ended(): boolean {
436
+ return this.ending !== null;
437
+ }
438
+
439
+ /** End the child's process now if it's still alive, whatever the run's state. Resolves once it has exited. */
440
+ kill(): Promise<void> {
441
+ if (!this.processExited) this.child?.kill('SIGKILL');
442
+ return this.exited;
443
+ }
444
+
445
+ /** The wakes the host delivered to the script's agent: those it answered ok and not as an error. A refused wake isn't one, nor a failed one, though a failure may have taken effect. */
446
+ get wakesDelivered(): number {
447
+ return this.delivered_wakes;
448
+ }
449
+
450
+ /** Resolves when the script has ended, or after `ms`, whichever comes first. */
451
+ wait(ms: number): Promise<RunSnapshot> {
452
+ if (this.ending) return Promise.resolve(this.snapshot());
453
+ return new Promise((resolve) => {
454
+ const done = () => {
455
+ clearTimeout(timer);
456
+ this.waiters.delete(done);
457
+ resolve(this.snapshot());
458
+ };
459
+ const timer = setTimeout(done, ms);
460
+ this.waiters.add(done);
461
+ });
462
+ }
463
+
464
+ /**
465
+ * Ask the script to stop: the stop file for a computing guest, a frame for an
466
+ * idle one. If the child hasn't said how it ended within the grace, it's
467
+ * ended, and the ending says its output so far may be short. The reason is
468
+ * the one asked here, whatever the child says.
469
+ */
470
+ stop(reason: StopReason, why?: string): Promise<RunSnapshot> {
471
+ if (this.ending || !this.child) return Promise.resolve(this.snapshot());
472
+ // A process that has exited has no stop to take: its run ends as its last
473
+ // frames say, or as a crash, which a stop asked now mustn't take for its own.
474
+ if (!this.stopAsked && !this.processExited) {
475
+ this.stopAsked = { reason, ...(why ? { why } : {}) };
476
+ try {
477
+ if (this.dir) writeFileSync(join(this.dir, 'stop'), '');
478
+ } catch {
479
+ // The frame below still reaches an idle child, and the grace ends a computing one.
480
+ }
481
+ this.send({ t: 'stop' });
482
+ this.killTimer = setTimeout(() => {
483
+ this.child?.kill('SIGKILL');
484
+ }, this.options.stopGraceMs);
485
+ this.killTimer.unref?.();
486
+ }
487
+ return new Promise((resolve) => {
488
+ const done = () => {
489
+ this.waiters.delete(done);
490
+ resolve(this.snapshot());
491
+ };
492
+ if (this.ending) done();
493
+ else this.waiters.add(done);
494
+ });
495
+ }
496
+
497
+ private send(message: unknown): void {
498
+ this.write(encodeFrame(message));
499
+ }
500
+
501
+ /** A call's result, as a result frame once its turn comes: the JSON's bytes go to the engine as they are. */
502
+ private sendResult(id: number, ok: boolean, json: string): void {
503
+ this.resultQueue.push({ id, frame: encodeResultFrame(id, ok, json, maxResultBytes(this.options.memoryMaxBytes) + RESULT_FRAME_OVERHEAD) });
504
+ this.pump();
505
+ }
506
+
507
+ /** Write waiting results, one frame at a time: after a write the pipe didn't take whole, the next waits for 'drain'. */
508
+ private pump(): void {
509
+ const pipe = this.child?.stdin ?? undefined;
510
+ if (!pipe || pipe.destroyed) {
511
+ this.resultQueue.length = 0;
512
+ return;
513
+ }
514
+ while (!this.draining && this.resultQueue.length > 0) {
515
+ const { id, frame } = this.resultQueue.shift()!;
516
+ this.answeredIds.add(id);
517
+ if (!pipe.write(frame)) {
518
+ this.draining = true;
519
+ pipe.once('drain', () => {
520
+ this.draining = false;
521
+ this.pump();
522
+ });
523
+ }
524
+ }
525
+ }
526
+
527
+ private write(frame: Buffer): void {
528
+ const pipe = this.child?.stdin ?? undefined;
529
+ if (!pipe || pipe.destroyed) return;
530
+ pipe.write(frame);
531
+ }
532
+
533
+
534
+ private handle(message: ChildMessage): void {
535
+ if (this.ending && message.t !== 'out') return;
536
+ switch (message.t) {
537
+ case 'out':
538
+ // The child flushes output in pieces of at most a batch.
539
+ if (message.text.length > MAX_BATCH_CHARS) {
540
+ this.crash(`its process sent the host ${message.text.length.toLocaleString('en-US')} characters of output in one frame, past the ${MAX_BATCH_CHARS.toLocaleString('en-US')} it sends at most`);
541
+ return;
542
+ }
543
+ this.output.apply(message.text);
544
+ return;
545
+ case 'call':
546
+ void this.call(message.id, message.name, message.args);
547
+ return;
548
+ case 'seen': {
549
+ // The script has the answer the parent sent, or got ResultTooLarge in
550
+ // its place: the call's id and its place are free. A seen for a call
551
+ // whose answer the parent hasn't written isn't following the protocol.
552
+ if (!this.answeredIds.delete(message.id)) {
553
+ this.crash(`its process said the script had the answer to call ${message.id}, which the host never sent`);
554
+ return;
555
+ }
556
+ this.pendingIds.delete(message.id);
557
+ this.inFlight.delete(message.id);
558
+ const sent = this.delivered.get(message.id);
559
+ if (!sent) return;
560
+ this.delivered.delete(message.id);
561
+ // A refusal stays one though the script couldn't receive it: nothing was done either way.
562
+ this.log.answered(sent.entry, message.unfit && sent.outcome !== 'refused' ? 'too-large' : sent.outcome, sent.brief);
563
+ return;
564
+ }
565
+ case 'end': {
566
+ const { t: _t, ...said } = message;
567
+ // The child cuts the value and a failure's parts as a return shows them.
568
+ // The parent bounds them too, since an ended run is kept for a later
569
+ // wait, with room for the child's cut and its marker, so an honest
570
+ // child's cut, and the count it gives, pass as they are.
571
+ if (said.how === 'finished') {
572
+ this.end(said.value === undefined ? { how: 'finished' } : { how: 'finished', value: bounded(said.value, VALUE_CHARS + CUT_MARK_CHARS) });
573
+ return;
574
+ }
575
+ if (said.how === 'failed') {
576
+ const error = {
577
+ name: bounded(said.error.name, NAME_CHARS + CUT_MARK_CHARS),
578
+ message: bounded(said.error.message, VALUE_CHARS + CUT_MARK_CHARS),
579
+ ...(said.error.stack === undefined ? {} : { stack: bounded(said.error.stack, VALUE_CHARS + CUT_MARK_CHARS) }),
580
+ };
581
+ this.end({ how: 'failed', error, ...(said.stalled ? { stalled: true } : {}) });
582
+ return;
583
+ }
584
+ if (!this.stopAsked) {
585
+ this.crash("its process sent the host a message it couldn't read (the script said it was stopped, and no stop was asked)");
586
+ return;
587
+ }
588
+ this.end({ how: 'stopped', reason: this.stopAsked.reason, ...(this.stopAsked.why ? { why: this.stopAsked.why } : {}) });
589
+ return;
590
+ }
591
+ }
592
+ }
593
+
594
+ private async call(id: number, name: string, args: string): Promise<void> {
595
+ const wake = name === WAKE_CALL_NAME;
596
+ // The prelude offers only the script's tools, so a call to another name
597
+ // isn't following it. The name is the child's, bounded by the protocol.
598
+ if (!wake && !this.toolNames.has(name)) {
599
+ this.crash(`its process sent the host a call to ${JSON.stringify(name)}, which isn't one of the script's tools`);
600
+ return;
601
+ }
602
+ if (this.pendingIds.has(id)) {
603
+ this.crash(`its process sent the host a message it couldn't read (call ${id} was named twice)`);
604
+ return;
605
+ }
606
+ this.pendingIds.add(id);
607
+ const tool = wake ? 'wake_agent' : name;
608
+ // Refused here, before anything is sent: the record counts it, and nothing was done.
609
+ const refuse = (refusal: { name: string; message: string }): void => {
610
+ this.delivered.set(id, { entry: this.log.start(tool, args), outcome: 'refused', brief: callBrief(refusal.message) });
611
+ this.answer(id, false, JSON.stringify(refusal));
612
+ };
613
+ let n = 0;
614
+ let wakeSent = (): void => {};
615
+ if (wake) {
616
+ // The child refuses a wake past the cap itself, and counts every wake it
617
+ // sends, one refused here included, so a child that sends more isn't
618
+ // following it. Counted before a stop's refusal, so wakes sent after a
619
+ // stop are bounded too.
620
+ if (this.wakeFrames >= this.options.maxWakes) {
621
+ this.crash(`its process sent the host more than ${this.options.maxWakes} wakes, the most a script may send`);
622
+ return;
623
+ }
624
+ // The child refuses a payload past its cap before a frame is built.
625
+ const tooLarge = wakeRefusal(args);
626
+ if (tooLarge) {
627
+ this.crash(`its process sent the host a wake of ${tooLarge.bytes.toLocaleString('en-US')} bytes, past the ${capText(MAX_WAKE_PAYLOAD_BYTES)} the script refuses itself`);
628
+ return;
629
+ }
630
+ this.wakeFrames += 1;
631
+ const stopping = this.stopRefusal('WakeRefused', tool);
632
+ if (stopping) {
633
+ refuse(stopping);
634
+ return;
635
+ }
636
+ // An early wake waits for its turn rather than being dropped: once the
637
+ // one before it has gone out, and the spacing since that one's delivery
638
+ // has passed. The run's end stops both waits.
639
+ const previous = this.wakeChain;
640
+ this.wakeChain = new Promise<void>((resolve) => (wakeSent = resolve));
641
+ this.wakesWaiting += 1;
642
+ await Promise.race([previous, this.endedSignal]);
643
+ await this.wakeTurn();
644
+ // A wake still waiting for its turn when the script ended, or once a
645
+ // stop was asked or its process exited, is never sent: the ending is the
646
+ // script's last word, and the endings that should wake its agent already
647
+ // do. The record counts it as never sent: the run's end counts those
648
+ // still waiting when it comes, and one dropped before it is counted here.
649
+ // (A call waiting in the script for a place is likewise never sent when a
650
+ // stop or a crash ends it; one whose script returns goes out, since the
651
+ // child waits for every call it made.)
652
+ if (this.ending) {
653
+ wakeSent();
654
+ return;
655
+ }
656
+ this.wakesWaiting -= 1;
657
+ if (this.stopAsked || this.processExited) {
658
+ wakeSent();
659
+ this.log.unsent();
660
+ return;
661
+ }
662
+ this.wakes += 1;
663
+ n = this.wakes;
664
+ } else {
665
+ // The prelude sends at most MAX_CALLS_IN_FLIGHT at once and keeps the
666
+ // rest waiting in the script, so a child that sends more isn't following it.
667
+ if (this.inFlight.size >= MAX_CALLS_IN_FLIGHT) {
668
+ this.crash(`its process sent the host more than ${MAX_CALLS_IN_FLIGHT} calls at once, the most a script may have out`);
669
+ return;
670
+ }
671
+ this.inFlight.add(id);
672
+ // The child refuses arguments past their caps before a frame is built.
673
+ const tooLarge = argumentsRefusal(name, args);
674
+ if (tooLarge) {
675
+ this.crash(
676
+ `its process sent the host a call to ${name} whose arguments pass their caps of ${capText(MAX_ARGUMENT_BYTES)} and ` +
677
+ `${MAX_ARGUMENT_VALUES.toLocaleString('en-US')} values, which the script refuses itself`,
678
+ );
679
+ return;
680
+ }
681
+ const refusal = this.stopRefusal('ToolRefused', tool);
682
+ if (refusal) {
683
+ refuse(refusal);
684
+ return;
685
+ }
686
+ }
687
+ // The guest has its answer: a result, or a TimeoutError in its place.
688
+ let settled = false;
689
+ const settle = (ok: boolean, json: string): void => {
690
+ settled = true;
691
+ this.answer(id, ok, json);
692
+ };
693
+ let entry: CallEntry | null = null;
694
+ // A call's time runs from when the script made it, a wait for room in the host included.
695
+ const timer = wake ? null : setTimeout(() => {
696
+ if (timer) this.callTimers.delete(timer);
697
+ if (settled || this.ending) return;
698
+ const waiting = this.dispatchWaiting.get(id);
699
+ if (waiting) {
700
+ // It never went out: no place in the host came free for it in time.
701
+ this.dispatchWaiting.delete(id);
702
+ waiting(false);
703
+ this.log.unsent();
704
+ settle(false, JSON.stringify({
705
+ name: 'TimeoutError',
706
+ message: `${name} was never sent: ${MAX_CALLS_IN_FLIGHT} of this script's earlier calls were still running in the host, timed-out ones ` +
707
+ `included, and its turn didn't come within ${formatMs(this.options.toolCallTimeoutMs)}, so nothing was done; calling it again may go through once one of them gets its result, ` +
708
+ 'though a call whose tool the host stopped waiting for stays counted until this script ends, and a new script starts with none',
709
+ }));
710
+ return;
711
+ }
712
+ if (!entry) return;
713
+ this.log.timedOut(entry);
714
+ settle(false, JSON.stringify({
715
+ name: 'TimeoutError',
716
+ message: `${name} was sent, and no answer came within ${formatMs(this.options.toolCallTimeoutMs)}; it may still take effect`,
717
+ }));
718
+ }, this.options.toolCallTimeoutMs);
719
+ if (timer) this.callTimers.add(timer);
720
+ const clearTimer = (): void => {
721
+ if (!timer) return;
722
+ clearTimeout(timer);
723
+ this.callTimers.delete(timer);
724
+ };
725
+ if (!wake) {
726
+ // Past the calls the host holds, it waits as its text, and its place is
727
+ // taken for it when its turn comes (releasePlace).
728
+ if (this.hostCalls < MAX_CALLS_IN_FLIGHT) this.hostCalls += 1;
729
+ else if (!(await new Promise<boolean>((resolve) => this.dispatchWaiting.set(id, resolve)))) return;
730
+ // Once the run has ended or its process has exited, nothing it asks goes
731
+ // out: a frame the child wrote before it exited can be read after the
732
+ // exit, and a call waiting for room can be given its place after it. The
733
+ // record counts such a call as never sent: the script made it, and
734
+ // nothing was done. A stop asked while it waited refuses it, as it would
735
+ // have refused it then.
736
+ if (this.ending || this.processExited) {
737
+ clearTimer();
738
+ this.releasePlace();
739
+ this.log.unsent();
740
+ return;
741
+ }
742
+ const stopping = this.stopRefusal('ToolRefused', tool);
743
+ if (stopping) {
744
+ clearTimer();
745
+ this.releasePlace();
746
+ refuse(stopping);
747
+ return;
748
+ }
749
+ }
750
+ entry = this.log.start(tool, args);
751
+ let answer: ToolAnswer;
752
+ try {
753
+ answer = wake ? await this.options.onWake(args, n) : await this.options.onToolCall(name, args);
754
+ } catch (err) {
755
+ // The bridge never rejects. One that does is answered as a failure,
756
+ // which may have taken effect, never as an unhandled rejection.
757
+ const text = `${tool} failed in the host: ${err instanceof Error ? err.message : String(err)}; it may have taken effect`;
758
+ answer = { ok: true, isError: true, brief: text, json: JSON.stringify({ content: [{ type: 'text', text }], structuredContent: null, isError: true }) };
759
+ }
760
+ if (wake) {
761
+ this.nextWakeAt = performance.now() + this.options.wakeMinIntervalMs;
762
+ if (answer.ok && !answer.isError) this.delivered_wakes += 1;
763
+ wakeSent();
764
+ } else if (!answer.held) this.releasePlace();
765
+ // No result came, and the bridge answered for it, after the script's own
766
+ // timeout (the bridge waits longer): the call stays as the record had it
767
+ // (timed out, or uncertain at an ending), and its place stays taken. Had
768
+ // the script's timeout not come yet, its timer would still give it one.
769
+ if (answer.held) return;
770
+ clearTimer();
771
+ const bytes = Buffer.byteLength(answer.json, 'utf8');
772
+ const limit = maxResultBytes(this.options.memoryMaxBytes);
773
+ const tooLarge = bytes > limit;
774
+ // A refusal stays one however long its text: nothing was done.
775
+ const outcome: CallOutcome = !answer.ok ? 'refused' : tooLarge ? 'too-large' : answer.isError ? 'failed' : 'answered';
776
+ // Only its start is kept, as a copy: the record names a call by that much.
777
+ const brief = callBrief(answer.brief ?? answer.json);
778
+ if (settled || this.ending) {
779
+ // The script stopped waiting for it, or ended: the record takes the answer as it comes.
780
+ this.log.answered(entry, outcome, brief);
781
+ return;
782
+ }
783
+ this.delivered.set(id, { entry, outcome, brief });
784
+ if (tooLarge) {
785
+ settle(false, JSON.stringify(outcome === 'refused'
786
+ ? {
787
+ name: wake ? 'WakeRefused' : 'ToolRefused',
788
+ message: `${glimpse(answer.brief ?? refusalText(answer.json), REFUSAL_GLIMPSE_CHARS)} (the refusal's text, ${bytes} bytes, is too large for this script's memory, so only its start is here; nothing was done)`,
789
+ }
790
+ : {
791
+ name: RESULT_TOO_LARGE,
792
+ message: `${name}'s result, ${bytes} bytes, is too large for this script's memory; the call took effect, and none of its result reached the script`,
793
+ bytes,
794
+ }));
795
+ return;
796
+ }
797
+ settle(answer.ok, answer.json);
798
+ }
799
+
800
+ /**
801
+ * A call no longer holds a place in the host: the bridge answered it, or it
802
+ * won't be sent. The first call waiting for room takes the place, in the
803
+ * same step, so no later call can take it first. Whatever lets a waiting
804
+ * call go takes it off the queue. One given its place once the run has
805
+ * ended or its process has exited isn't sent (the call's own check), and
806
+ * the end lets the calls still waiting go.
807
+ */
808
+ private releasePlace(): void {
809
+ this.hostCalls -= 1;
810
+ const next = this.dispatchWaiting.entries().next();
811
+ if (next.done) return;
812
+ const [nextId, go] = next.value;
813
+ this.dispatchWaiting.delete(nextId);
814
+ this.hostCalls += 1;
815
+ go(true);
816
+ }
817
+
818
+ /** An answer to the call the child named `id`, sent once its turn comes. The id stays reserved until the child says the script has it. */
819
+ private answer(id: number, ok: boolean, json: string): void {
820
+ this.sendResult(id, ok, json);
821
+ }
822
+
823
+ /** A call or wake that comes once a stop has been asked isn't sent: the script is ending. */
824
+ private stopRefusal(name: 'ToolRefused' | 'WakeRefused', tool: string): { name: string; message: string } | null {
825
+ return this.stopAsked ? { name, message: `${tool}: the script is being stopped, so nothing was sent` } : null;
826
+ }
827
+
828
+ /** Resolves once the spacing since the last wake has passed, or at once when the run has ended. */
829
+ private wakeTurn(): Promise<void> {
830
+ const wait = this.nextWakeAt - performance.now();
831
+ if (wait <= 0 || this.ending) return Promise.resolve();
832
+ return new Promise<void>((resolve) => {
833
+ const release = (): void => {
834
+ clearTimeout(timer);
835
+ this.wakeWaiters.delete(release);
836
+ resolve();
837
+ };
838
+ const timer = setTimeout(release, wait);
839
+ this.wakeWaiters.add(release);
840
+ });
841
+ }
842
+
843
+ /**
844
+ * Once the process has exited and the run has ended, nothing more goes to
845
+ * the child: an ended run kept for a later wait holds none of it. Whichever
846
+ * of the two comes second calls this.
847
+ */
848
+ private release(): void {
849
+ this.child?.stdin?.destroy();
850
+ this.child = null;
851
+ }
852
+
853
+ private crash(detail: string): void {
854
+ if (this.ending) return;
855
+ this.end({ how: 'crashed', detail });
856
+ this.child?.kill('SIGKILL');
857
+ }
858
+
859
+ private end(ending: Ending): void {
860
+ if (this.ending) return;
861
+ // Results sent and not yet seen were answered, and the script ended
862
+ // before it read them: each is named, a refusal counted. Calls still
863
+ // running may or may not take effect. A stop or a crash can leave either,
864
+ // whatever the ending: a script that had returned or thrown when a stop
865
+ // came ends as it did, with the calls its stop cut the wait for. So can a
866
+ // failure that escaped the script's promises, which ends the run at once,
867
+ // and a result the engine dropped as a stop came, which gets no seen. A
868
+ // TimeoutError the engine dropped so leaves its call named timed-out, as
869
+ // the host's timer had it, with no answer to name. Otherwise the child
870
+ // reports a finish or a failure only once every call it made is settled
871
+ // and seen. The host's count of the calls the bridge holds stays as it
872
+ // is, since those tools may still run: each frees its place as it
873
+ // answers, and one the bridge answered for with no result (held) never
874
+ // does.
875
+ for (const sent of this.delivered.values()) this.log.unseen(sent.entry, sent.outcome);
876
+ this.delivered.clear();
877
+ this.pendingIds.clear();
878
+ this.answeredIds.clear();
879
+ this.inFlight.clear();
880
+ this.log.ended();
881
+ this.ending = ending;
882
+ if (this.lifetimeTimer) clearTimeout(this.lifetimeTimer);
883
+ // A child exits on its own once it has said how its script ended. One that
884
+ // doesn't, such as one stuck in its cleanup, is ended after the stop's
885
+ // grace, so it can't hold its place or outlive its run.
886
+ if (this.child && !this.processExited && !this.killTimer) {
887
+ this.killTimer = setTimeout(() => this.child?.kill('SIGKILL'), this.options.stopGraceMs);
888
+ this.killTimer.unref?.();
889
+ }
890
+ for (const timer of this.callTimers) clearTimeout(timer);
891
+ this.callTimers.clear();
892
+ // Nothing more goes to the child: waiting results are dropped, and calls
893
+ // waiting for a place go nowhere, so an ended run that's kept for a later
894
+ // wait holds neither.
895
+ this.resultQueue.length = 0;
896
+ for (const release of [...this.wakeWaiters]) release();
897
+ this.endedResolve();
898
+ // Calls waiting for room in the host are never sent, and the record counts
899
+ // them so: the script made them, and nothing was done.
900
+ for (const [waitingId, go] of [...this.dispatchWaiting]) {
901
+ this.dispatchWaiting.delete(waitingId);
902
+ this.log.unsent();
903
+ go(false);
904
+ }
905
+ // So are wakes waiting for their turn: each sees the ending and goes.
906
+ for (; this.wakesWaiting > 0; this.wakesWaiting -= 1) this.log.unsent();
907
+ for (const waiter of [...this.waiters]) waiter();
908
+ if (this.processExited) this.release();
909
+ this.options.onEnd?.(ending);
910
+ }
911
+ }