@animalabs/agent-framework 0.23.2 → 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 +1027 -440
  71. package/dist/src/framework.d.ts.map +1 -1
  72. package/dist/src/framework.js +4093 -2238
  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 +4468 -2418
  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
@@ -1,791 +0,0 @@
1
- /**
2
- * PyRunner — subprocess lifecycle + protocol driver for the client-side
3
- * programmatic tool calling (`code_execution`) runtime.
4
- *
5
- * One runner per agent. The interpreter is spawned lazily on first exec,
6
- * persists between execs (script globals survive — container-reuse
7
- * semantics), and is reclaimed after an idle period, mirroring the managed
8
- * runtime's ~5-minute container reclaim.
9
- *
10
- * This is a ROBUSTNESS boundary, not a security sandbox — same doctrine as
11
- * GateScript: the agent already has broader host access through its tools.
12
- * What this class guarantees is liveness: a wedged or runaway script cannot
13
- * hang the agent turn (cancel + SIGINT -> grace -> SIGKILL -> respawn) and a crashed
14
- * interpreter surfaces as a tool result, never as an unhandled rejection.
15
- */
16
-
17
- import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
18
- import { buildChildEnv } from '../mcpl/transport.js';
19
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
- import { tmpdir } from 'node:os';
21
- import { join, resolve } from 'node:path';
22
- import { createInterface, type Interface } from 'node:readline';
23
- import { boundedErrorMessage } from '../error-message.js';
24
- import { PYTHON_RUNTIME_SOURCE } from './runtime-py.js';
25
-
26
- export interface InjectedTool {
27
- /** Python identifier the tool is bound to (sanitized, `--` -> `__`). */
28
- pyName: string;
29
- /** Exact framework tool name, sent back on tool_call ops. */
30
- toolName: string;
31
- }
32
-
33
- export interface ExecResult {
34
- stdout: string;
35
- stderr: string;
36
- returnCode: number;
37
- /** Set when the host killed the script (deadline / abort / crash). */
38
- aborted?: boolean;
39
- /** Background execs: rolling tail of interleaved stdout+stderr (for the
40
- * crash/finish envelope; the full journal is in the log file). */
41
- tail?: string;
42
- }
43
-
44
- /** Per-exec options for background (daemon) scripts. */
45
- export interface BackgroundExecOptions {
46
- /** Absolute path for the line-buffered stdout+stderr journal (null: tail only). */
47
- logPath: string | null;
48
- /**
49
- * Called on each wake_agent() from the script. Resolve with null to ack
50
- * (deliver), or an error string to refuse (raises RuntimeError in-script).
51
- * May resolve late — rate limiting backpressures inside wake_agent().
52
- */
53
- onWake: (line: number, payload: unknown) => Promise<string | null>;
54
- /** Overrides the runner's scriptTimeoutMs (background lifetime). */
55
- lifetimeMs: number;
56
- }
57
-
58
- /**
59
- * An inner tool call's result as the script gets it. `text` is the string
60
- * its await returns, and the runner sets `.is_error` on that string from
61
- * `isError`. With `structuredJson`, `text` is the JSON object a structured
62
- * result reaches a script as, in any of its forms, and `.structured` reads
63
- * its `structuredContent`, None for a form that holds none, such as one past
64
- * the size cap; without it, `.structured` is None.
65
- */
66
- export interface ScriptToolResult {
67
- text: string;
68
- isError: boolean;
69
- structuredJson?: boolean;
70
- }
71
-
72
- /**
73
- * Resolves inner tool calls. Must never reject — map errors to results; a
74
- * rejection reaches the script as an `Error: …` result. A plain string is
75
- * the result's text with nothing said about it, so the script's
76
- * `.is_error` and `.structured` are None.
77
- */
78
- export type ScriptToolCallHandler = (
79
- toolName: string,
80
- args: Record<string, unknown>,
81
- ) => Promise<string | ScriptToolResult>;
82
-
83
- export interface PyRunnerOptions {
84
- pythonPath?: string;
85
- /** Per-tool-call timeout surfaced as TimeoutError inside the script. */
86
- toolCallTimeoutMs?: number;
87
- /** Whole-script deadline; exceeded -> cancel, grace, kill. */
88
- scriptTimeoutMs?: number;
89
- /** How long a script gets to stop after its deadline before the interpreter is killed (default 10s). */
90
- cancelGraceMs?: number;
91
- /** Idle interpreter reclaim (state lost), mirroring container reclaim. */
92
- idleReclaimMs?: number;
93
- onToolCall: ScriptToolCallHandler;
94
- /** Log prefix, typically the agent name. */
95
- label?: string;
96
- /** Variables for the interpreter beyond what it inherits, such as
97
- * PYTHONPATH or a venv's. It inherits only what stdio MCPL children do
98
- * (CHILD_ENV_ALLOWLIST and LC_*, CA bundles and proxy variables among
99
- * them, and a proxy URL can carry credentials), then these, then
100
- * PYTHONUNBUFFERED and PYTHONIOENCODING, which the runner sets last.
101
- * Like the allowlist, this limits what the model's code inherits, not
102
- * what a process running as the same user can read. */
103
- env?: Record<string, string>;
104
- /** Pass the host's whole environment instead of the allowlist, as before
105
- * `env` existed. That hands every host variable, provider keys included,
106
- * to the model's code and to every subprocess a script starts; listing
107
- * what scripts need in `env` is the safer route. */
108
- inheritEnv?: boolean;
109
- }
110
-
111
- const DEFAULT_TOOL_CALL_TIMEOUT_MS = 270_000;
112
- const DEFAULT_SCRIPT_TIMEOUT_MS = 600_000;
113
- const DEFAULT_IDLE_RECLAIM_MS = 300_000;
114
- const CANCEL_GRACE_MS = 10_000;
115
- /** After the deadline, SIGINT is repeated at this interval until the script reports or is killed. */
116
- const INTERRUPT_REPEAT_MS = 200;
117
-
118
- /** The longest delay Node's timers honour (~24.8 days); a longer one fires after ~1 ms. */
119
- export const MAX_TIMER_MS = 2_147_483_647;
120
-
121
- /** A time limit for a message: "45s", "10 min", "2.5 h". */
122
- export function formatLimit(ms: number): string {
123
- if (ms < 120_000) return `${Math.round(ms / 1000)}s`;
124
- if (ms < 7_200_000) return `${Math.round(ms / 6_000) / 10} min`;
125
- return `${Math.round(ms / 360_000) / 10} h`;
126
- }
127
-
128
- /**
129
- * What a script stopped at its time limit is told after why: that the tool
130
- * calls it made keep their effects, and whether a longer limit can help,
131
- * which only the deployment's ceiling for its kind of script can say. A
132
- * foreground script at its ceiling is offered background mode too, when its
133
- * caller passes `backgroundCeilingMs` (the framework does for an agent that
134
- * could start a background script as the script starts) and a background
135
- * script may run longer.
136
- */
137
- function limitRemedy(limitMs: number, ceilingMs: number | undefined, background: boolean, backgroundCeilingMs?: number): string {
138
- const effects = 'Tool calls it had already made keep their effects, so check them before running it again.';
139
- if (ceilingMs === undefined) return effects;
140
- if (limitMs < ceilingMs) return `${effects} To give it longer, ${background ? 'start it with' : 'pass'} time_limit_ms up to ${ceilingMs} (${formatLimit(ceilingMs)}).`;
141
- if (background) return `${effects} ${formatLimit(limitMs)} is the longest a background script runs here; starting it again runs it from the top.`;
142
- const inBackground = backgroundCeilingMs !== undefined && backgroundCeilingMs > limitMs
143
- ? `, or run it with background: true, where a script can run up to ${formatLimit(backgroundCeilingMs)}` : '';
144
- return `${effects} ${formatLimit(limitMs)} is the longest a script can run here, so split the work across shorter scripts${inBackground}.`;
145
- }
146
-
147
- /**
148
- * Why a script didn't run when its interpreter didn't start. A spawn that
149
- * found nothing it could run (ENOENT, EACCES) fails the same way until the
150
- * host changes, so that one says code_execution can't run here rather than
151
- * to try again. A failure before the spawn, while creating or writing the
152
- * runtime's files, is about those files rather than the interpreter, and
153
- * keeps Node's own text, which names the path for an open or a mkdtemp.
154
- */
155
- function startFailure(pythonPath: string, err: unknown): string {
156
- const { code, syscall } = (err ?? {}) as { code?: unknown; syscall?: unknown };
157
- const spawning = typeof syscall === 'string' && syscall.startsWith('spawn');
158
- if (spawning && (code === 'ENOENT' || code === 'EACCES')) {
159
- const until = code === 'ENOENT' ? 'installs it or points this host at one' : 'makes it executable or points this host at another';
160
- return `script didn't run: this host can't start its Python interpreter, ${pythonPath} (${code}), so code_execution can't run scripts here until whoever runs this host ${until}. Calling it again before then fails the same way.`;
161
- }
162
- if (typeof syscall === 'string' && !spawning) {
163
- return `script didn't run: this host couldn't create or write the files its Python runtime starts from (${boundedErrorMessage(err)}), so none of the script ran. Run it again; if it fails the same way, ask whoever runs this host.`;
164
- }
165
- const why = typeof code === 'string' ? `couldn't be started (${code})` : boundedErrorMessage(err);
166
- return `script didn't run: the Python interpreter, ${pythonPath}, ${why}, so none of the script ran. Run it again; if it fails the same way, ask whoever runs this host.`;
167
- }
168
-
169
- /** Why a script stopped when its interpreter exited under it, and what running it again does. */
170
- function interpreterExit(exitCode: number | null, signal: NodeJS.Signals | null, background: boolean): string {
171
- const how = signal ? `was ended by ${signal}` : `exited with code ${exitCode}`;
172
- return `script stopped: the Python interpreter ${how} while it ran, so the rest of the script didn't run`
173
- + `${background ? '' : ', and variables from earlier scripts are gone'}. Tool calls it had already made keep their effects, `
174
- + 'so check them before you run it again in a new interpreter. If it stops the same way, check whether the script itself '
175
- + `ends the process, for example ${signal ? 'by using too much memory' : 'with os._exit()'}; if not, ask whoever runs this host.`;
176
- }
177
-
178
- interface PendingExec {
179
- id: string;
180
- resolve: (result: ExecResult) => void;
181
- deadlineTimer: ReturnType<typeof setTimeout> | null;
182
- killTimer: ReturnType<typeof setTimeout> | null;
183
- /** Repeats the deadline SIGINT (see interruptChild). */
184
- interruptTimer?: ReturnType<typeof setInterval>;
185
- settled: boolean;
186
- /** Set once the deadline fired: the result then says the script ran out of time. */
187
- deadlineMs: number | null;
188
- /** The longest limit this exec could have had (exec's opts): whether a longer one would help. */
189
- ceilingMs: number | undefined;
190
- /** For a foreground exec, the longest a background script may run (exec's opts): whether background mode would help. */
191
- backgroundCeilingMs: number | undefined;
192
- /** A background exec has its interpreter to itself, so no earlier script's state is lost with it. */
193
- background: boolean;
194
- /** Set once the script went to the interpreter. Before that, the exec is
195
- * waiting for the interpreter to start, and an interpreter that exits then
196
- * failed to start (see exec). */
197
- sent: boolean;
198
- }
199
-
200
- /**
201
- * The interpreter to spawn. A bare name is looked up on PATH. A path with a
202
- * separator is resolved against the host's working directory, where it
203
- * resolved before the interpreter got a working directory of its own: spawn
204
- * would resolve it against the runtime directory instead.
205
- */
206
- function spawnablePath(pythonPath: string): string {
207
- const hasSeparator = pythonPath.includes('/') || (process.platform === 'win32' && pythonPath.includes('\\'));
208
- return hasSeparator ? resolve(pythonPath) : pythonPath;
209
- }
210
-
211
- export class PyRunner {
212
- private readonly pythonPath: string;
213
- private readonly toolCallTimeoutMs: number;
214
- private readonly scriptTimeoutMs: number;
215
- private readonly cancelGraceMs: number;
216
- private readonly idleReclaimMs: number;
217
- private readonly onToolCall: ScriptToolCallHandler;
218
- private readonly label: string;
219
- private readonly envOptions: { env?: Record<string, string>; inheritEnv?: boolean };
220
-
221
- private onWake: ((line: number, payload: unknown) => Promise<string | null>) | null = null;
222
- private child: ChildProcessWithoutNullStreams | null = null;
223
- private childReady: Promise<void> | null = null;
224
- private reader: Interface | null = null;
225
- private runtimeDir: string | null = null;
226
- private pending: PendingExec | null = null;
227
- private idleTimer: ReturnType<typeof setTimeout> | null = null;
228
- private execCounter = 0;
229
- private disposed = false;
230
-
231
- constructor(options: PyRunnerOptions) {
232
- this.pythonPath = options.pythonPath ?? 'python3';
233
- this.toolCallTimeoutMs = options.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS;
234
- this.scriptTimeoutMs = options.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS;
235
- this.cancelGraceMs = options.cancelGraceMs ?? CANCEL_GRACE_MS;
236
- this.idleReclaimMs = options.idleReclaimMs ?? DEFAULT_IDLE_RECLAIM_MS;
237
- this.onToolCall = options.onToolCall;
238
- this.label = options.label ?? 'pytc';
239
- // The interpreter runs model-authored code: the same env discipline as an
240
- // MCPL connector child (#175), for a child that deserves it more. Stored
241
- // as options, not a snapshot — the env is rebuilt at every spawn so a
242
- // respawn after idle reclaim sees current host values, as the old
243
- // per-spawn `{ ...process.env }` did.
244
- this.envOptions = { env: options.env, inheritEnv: options.inheritEnv };
245
- }
246
-
247
- get busy(): boolean {
248
- return this.pending !== null;
249
- }
250
-
251
- /**
252
- * Run one script. Tools are (re-)injected before every exec so the
253
- * interpreter always reflects the current tool surface (list_changed etc.).
254
- * Never rejects: every failure mode resolves to an ExecResult.
255
- *
256
- * With `background` options the exec is a daemon: output journals to the
257
- * log file, wake_agent() is available in-script, and the deadline is the
258
- * background lifetime. A background runner should be DEDICATED to that one
259
- * script (the framework creates one per background script).
260
- *
261
- * `opts.deadlineMs` replaces the runner's scriptTimeoutMs for this exec (a
262
- * per-call time limit); a background exec uses its `lifetimeMs` instead.
263
- * `opts.ceilingMs` is the longest limit the deployment allows this kind of
264
- * script: a script stopped at its limit is offered a longer one only below
265
- * it. Without it, the stop doesn't say whether a longer limit would help.
266
- * `opts.backgroundCeilingMs`, for a foreground exec, is the longest a
267
- * background script may run: one stopped at its ceiling is offered
268
- * background mode when that is longer.
269
- */
270
- async exec(
271
- code: string,
272
- tools: InjectedTool[],
273
- background?: BackgroundExecOptions,
274
- opts?: { deadlineMs?: number; ceilingMs?: number; backgroundCeilingMs?: number },
275
- ): Promise<ExecResult> {
276
- if (this.disposed) {
277
- return { stdout: '', stderr: 'code_execution runner disposed', returnCode: 1, aborted: true };
278
- }
279
- if (this.busy) {
280
- return {
281
- stdout: '',
282
- stderr: "script didn't run: another code_execution script of yours is already running, and they run one at a time. "
283
- + 'Run this one again once that one has finished, or combine the two into one script.',
284
- returnCode: 1,
285
- aborted: true,
286
- };
287
- }
288
- this.clearIdleTimer();
289
-
290
- // The exec claims the runner before the await: a same-tick second exec
291
- // sees it busy, and abort() or dispose() while the interpreter starts
292
- // settles this exec, whose script then never runs.
293
- let resolveResult!: (result: ExecResult) => void;
294
- const settled = new Promise<ExecResult>((resolve) => {
295
- resolveResult = resolve;
296
- });
297
- const pending: PendingExec = {
298
- id: `e${++this.execCounter}`,
299
- resolve: resolveResult,
300
- deadlineTimer: null,
301
- killTimer: null,
302
- settled: false,
303
- deadlineMs: null,
304
- ceilingMs: opts?.ceilingMs,
305
- backgroundCeilingMs: opts?.backgroundCeilingMs,
306
- background: background !== undefined,
307
- sent: false,
308
- };
309
- this.pending = pending;
310
- try {
311
- await this.ensureChild();
312
- } catch (err) {
313
- // Settled already when an abort or dispose reclaimed the interpreter
314
- // as it started; another exec may have claimed the runner since.
315
- if (!pending.settled) {
316
- this.settlePending({
317
- stdout: '',
318
- stderr: startFailure(this.pythonPath, err),
319
- returnCode: 1,
320
- aborted: true,
321
- });
322
- this.reclaim('spawn-failed');
323
- }
324
- return settled;
325
- }
326
- if (pending.settled) return settled;
327
-
328
- const execId = pending.id;
329
- // Clamped: a deadline past Node's timer range would fire at once instead of never.
330
- const deadlineMs = Math.min(MAX_TIMER_MS, background?.lifetimeMs ?? opts?.deadlineMs ?? this.scriptTimeoutMs);
331
- this.onWake = background?.onWake ?? null;
332
- pending.deadlineTimer = setTimeout(() => {
333
- pending.deadlineMs = deadlineMs;
334
- // Deadline: ask politely first (script sees CancelledError and its
335
- // exec_result still flows back), then kill on unresponsiveness.
336
- this.send({ op: 'cancel', id: execId, reason: 'deadline' });
337
- // The cancel lands only when the script awaits. Blocking code (time.sleep, a
338
- // busy loop, a blocking read) never does, so interrupt it as well: the runtime
339
- // raises KeyboardInterrupt in the script's own code, or schedules the
340
- // cancellation of a script that is waiting. Repeated, because a waiting script
341
- // can resume into blocking code before that cancellation runs; the runtime
342
- // ignores the repeats once the script was interrupted or cancelled.
343
- this.interruptChild();
344
- pending.interruptTimer = setInterval(() => this.interruptChild(), INTERRUPT_REPEAT_MS);
345
- pending.killTimer = setTimeout(() => {
346
- pending.deadlineMs = null; // this message already says why
347
- this.settlePending({
348
- stdout: '',
349
- stderr:
350
- `script stopped: it reached its ${formatLimit(deadlineMs)} time limit and did not respond, so it was killed` +
351
- (pending.background ? '. ' : ' and the interpreter restarted (variables from earlier scripts are gone). ') +
352
- limitRemedy(deadlineMs, pending.ceilingMs, pending.background, pending.backgroundCeilingMs),
353
- returnCode: 1,
354
- aborted: true,
355
- });
356
- this.reclaim('deadline-kill');
357
- }, this.cancelGraceMs);
358
- }, deadlineMs);
359
- // A day-scale background deadline must not hold the process open.
360
- if (background) pending.deadlineTimer.unref?.();
361
-
362
- pending.sent = true;
363
- this.send({
364
- op: 'init',
365
- tools: tools.map((t) => ({ py_name: t.pyName, tool_name: t.toolName })),
366
- call_timeout_s: Math.round(this.toolCallTimeoutMs / 1000),
367
- ...(background
368
- ? { background: true, log_path: background.logPath ?? null }
369
- : {}),
370
- });
371
- this.send({ op: 'exec', id: execId, code });
372
-
373
- const result = await settled;
374
-
375
- this.onWake = null;
376
- if (!background) this.armIdleTimer();
377
- return result;
378
- }
379
-
380
- /**
381
- * Abort the runner's script (its turn ended or is reset to run again, or the
382
- * agent was reset or stopped), whether it's running or still waiting for its
383
- * interpreter to start; a waiting script then never runs. The exec settles
384
- * at once, and the interpreter is reclaimed, a starting one too: after an
385
- * abort its state is suspect (a script died midway), and the next exec gets
386
- * a fresh one.
387
- */
388
- abort(reason: string): void {
389
- if (!this.pending) return;
390
- this.send({ op: 'cancel', id: this.pending.id, reason });
391
- this.settlePending({
392
- stdout: '',
393
- stderr: `script aborted by host: ${reason}`,
394
- returnCode: 1,
395
- aborted: true,
396
- });
397
- this.reclaim(`abort: ${reason}`);
398
- }
399
-
400
- dispose(): void {
401
- if (this.disposed) return;
402
- this.disposed = true;
403
- if (this.pending) {
404
- this.settlePending({
405
- stdout: '',
406
- stderr: 'code_execution runner disposed',
407
- returnCode: 1,
408
- aborted: true,
409
- });
410
- }
411
- this.reclaim('dispose');
412
- }
413
-
414
- // -------------------------------------------------------------------------
415
-
416
- private async ensureChild(): Promise<void> {
417
- if (this.child && this.child.exitCode === null && !this.child.killed) {
418
- return this.childReady ?? Promise.resolve();
419
- }
420
- this.teardownChild();
421
-
422
- // Absolute, since the interpreter runs in it: from a relative TMPDIR,
423
- // runtime.py's path would otherwise resolve against the directory itself.
424
- this.runtimeDir = resolve(mkdtempSync(join(tmpdir(), 'af-pytc-')));
425
- const runtimePath = join(this.runtimeDir, 'runtime.py');
426
- writeFileSync(runtimePath, PYTHON_RUNTIME_SOURCE, 'utf8');
427
-
428
- const child = spawn(spawnablePath(this.pythonPath), [runtimePath], {
429
- stdio: ['pipe', 'pipe', 'pipe'],
430
- // The runtime directory, not the host's working directory: a script's
431
- // relative paths land here, and go at teardown, rather than beside the
432
- // host's .env and data. An absolute path still reaches the host's files.
433
- cwd: this.runtimeDir,
434
- // Set last, over a declared env: the protocol needs both. It writes
435
- // non-ASCII JSON on piped stdio, which Python before 3.15 on Windows
436
- // encodes in the ANSI code page unless told otherwise, and a host's
437
- // own PYTHONUTF8 no longer reaches the interpreter.
438
- env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' },
439
- });
440
- this.child = child;
441
-
442
- child.stderr.setEncoding('utf8');
443
- child.stderr.on('data', (chunk: string) => {
444
- // Outside an exec, python stderr is runtime diagnostics (crash
445
- // tracebacks etc.) — surface them in host logs.
446
- console.error(`[pytc:${this.label}] ${chunk.trimEnd()}`);
447
- });
448
-
449
- // A write to an interpreter that no longer reads its input fails on the
450
- // stream after write() returns; with no listener, that error is thrown as
451
- // an uncaught exception. Such an interpreter can go on running, as after a
452
- // script closes file descriptor 0, and every later message to it is lost:
453
- // its exec would wait out its time limit. So end it, and its exit settles
454
- // the exec now. One already dead or killed is left to its own exit.
455
- let inputFailed = false;
456
- child.stdin.on('error', (err) => {
457
- console.error(`[pytc:${this.label}] write to the python runtime failed: ${err.message}`);
458
- if (child.exitCode === null && !child.killed && child.pid !== undefined) {
459
- inputFailed = true;
460
- child.kill('SIGKILL');
461
- }
462
- });
463
-
464
- child.on('error', (err) => {
465
- // Spawn failure lands here (e.g. python3 missing); readiness rejects.
466
- console.error(`[pytc:${this.label}] python process error: ${err.message}`);
467
- });
468
-
469
- child.on('exit', (exitCode, signal) => {
470
- // An exec still waiting for the interpreter is settled by exec, as a
471
- // runtime that failed to start.
472
- if (this.pending?.sent) {
473
- this.settlePending({
474
- stdout: '',
475
- stderr:
476
- inputFailed && signal === 'SIGKILL'
477
- ? 'python runtime stopped reading its input, so it was killed and the interpreter restarted ' +
478
- '(variables from earlier scripts are gone). A script that closes its standard input ' +
479
- '(file descriptor 0) does this.'
480
- : interpreterExit(exitCode, signal, this.pending.background),
481
- returnCode: 1,
482
- aborted: true,
483
- });
484
- }
485
- if (this.child === child) {
486
- this.teardownChild();
487
- }
488
- });
489
-
490
- this.reader = createInterface({ input: child.stdout });
491
- this.reader.on('line', (line) => this.handleLine(line, child));
492
-
493
- this.childReady = new Promise<void>((resolve, reject) => {
494
- const onReady = () => {
495
- cleanup();
496
- resolve();
497
- };
498
- const onExit = () => {
499
- cleanup();
500
- reject(new Error('exited as it started'));
501
- };
502
- const onError = (err: Error) => {
503
- cleanup();
504
- reject(err);
505
- };
506
- const timeout = setTimeout(() => {
507
- cleanup();
508
- reject(new Error("didn't start within 15 seconds"));
509
- }, 15_000);
510
- const cleanup = () => {
511
- clearTimeout(timeout);
512
- this.readyResolver = null;
513
- this.readyFailer = null;
514
- child.off('exit', onExit);
515
- child.off('error', onError);
516
- };
517
- this.readyResolver = onReady;
518
- this.readyFailer = onError;
519
- child.on('exit', onExit);
520
- child.on('error', onError);
521
- });
522
- return this.childReady;
523
- }
524
-
525
- private readyResolver: (() => void) | null = null;
526
- /** Fails the readiness of an interpreter that is still starting (see reclaim). */
527
- private readyFailer: ((err: Error) => void) | null = null;
528
-
529
- private handleLine(line: string, child: ChildProcessWithoutNullStreams): void {
530
- let msg: { op?: string; id?: string; name?: string; args?: unknown; stdout?: string; stderr?: string; return_code?: number };
531
- try {
532
- msg = JSON.parse(line);
533
- } catch {
534
- console.error(`[pytc:${this.label}] non-protocol stdout line: ${line.slice(0, 200)}`);
535
- return;
536
- }
537
-
538
- switch (msg.op) {
539
- case 'ready':
540
- this.readyResolver?.();
541
- return;
542
-
543
- case 'tool_call': {
544
- const callId = msg.id;
545
- const toolName = msg.name;
546
- if (!callId || !toolName) return;
547
- const args =
548
- msg.args && typeof msg.args === 'object' && !Array.isArray(msg.args)
549
- ? (msg.args as Record<string, unknown>)
550
- : {};
551
- this.onToolCall(toolName, args)
552
- .catch((err): ScriptToolResult => ({
553
- text: `Error: ${err instanceof Error ? err.message : String(err)}`,
554
- isError: true,
555
- }))
556
- .then((result) => {
557
- // Only an object is a ScriptToolResult. Anything else a handler
558
- // resolves with, a string or a value its type rules out, is sent
559
- // as it is, so the call is still answered.
560
- const reply = typeof result === 'object' && result !== null
561
- ? {
562
- op: 'tool_result',
563
- id: callId,
564
- result: result.text,
565
- is_error: result.isError,
566
- ...(result.structuredJson ? { structured_json: true } : {}),
567
- }
568
- : { op: 'tool_result', id: callId, result };
569
- this.reply(child, reply, `result of ${toolName} call ${callId}`);
570
- });
571
- return;
572
- }
573
-
574
- case 'wake': {
575
- const wakeId = msg.id;
576
- if (!wakeId) return;
577
- const line = typeof (msg as { line?: unknown }).line === 'number'
578
- ? (msg as { line: number }).line
579
- : -1;
580
- const payload = (msg as { payload?: unknown }).payload;
581
- const handler = this.onWake;
582
- const refuse = handler
583
- ? handler(line, payload).catch((err: unknown) =>
584
- `wake handler failed: ${err instanceof Error ? err.message : String(err)}`)
585
- : Promise.resolve('this script is not allowed to wake the agent');
586
- void refuse.then((error) => {
587
- this.reply(child, { op: 'wake_ack', id: wakeId, ...(error ? { error } : {}) }, `ack of wake ${wakeId}`);
588
- });
589
- return;
590
- }
591
-
592
- case 'exec_result': {
593
- if (this.pending && msg.id === this.pending.id) {
594
- this.settlePending({
595
- stdout: msg.stdout ?? '',
596
- stderr: msg.stderr ?? '',
597
- returnCode: typeof msg.return_code === 'number' ? msg.return_code : 1,
598
- ...(typeof (msg as { tail?: unknown }).tail === 'string'
599
- ? { tail: (msg as { tail: string }).tail }
600
- : {}),
601
- });
602
- }
603
- return;
604
- }
605
-
606
- default:
607
- console.error(`[pytc:${this.label}] unknown protocol op: ${String(msg.op)}`);
608
- }
609
- }
610
-
611
- private settlePending(result: ExecResult): void {
612
- const pending = this.pending;
613
- if (!pending || pending.settled) return;
614
- pending.settled = true;
615
- // Say why the script stopped: the in-script cancellation only reads "cancelled by host".
616
- // Only a script that ended unsuccessfully was stopped: one that caught the cancellation
617
- // and finished its work returns 0, and must not read as stopped.
618
- if (pending.deadlineMs !== null && result.returnCode !== 0) {
619
- const note = `script stopped: it reached its ${formatLimit(pending.deadlineMs)} time limit. `
620
- + `${limitRemedy(pending.deadlineMs, pending.ceilingMs, pending.background, pending.backgroundCeilingMs)}\n`;
621
- // On a line of its own: the runner's own texts, such as an interpreter's exit, end without a newline.
622
- const withNote = (text: string) => (text === '' || text.endsWith('\n') ? text : `${text}\n`) + note;
623
- // A background script reports its output tail, so the note goes there too.
624
- result = { ...result, stderr: withNote(result.stderr), ...(result.tail !== undefined ? { tail: withNote(result.tail) } : {}) };
625
- }
626
- if (pending.deadlineTimer) clearTimeout(pending.deadlineTimer);
627
- if (pending.killTimer) clearTimeout(pending.killTimer);
628
- if (pending.interruptTimer) clearInterval(pending.interruptTimer);
629
- this.pending = null;
630
- pending.resolve(result);
631
- }
632
-
633
- /**
634
- * Answer the interpreter that asked. Call and wake ids restart in every
635
- * interpreter, so an answer that outlives its interpreter (the script was
636
- * aborted, killed or crashed, or the interpreter reclaimed) would resolve
637
- * the same id in the one that replaced it, inside another script: drop it.
638
- */
639
- private reply(child: ChildProcessWithoutNullStreams, msg: unknown, what: string): void {
640
- if (child !== this.child) {
641
- console.error(`[pytc:${this.label}] dropped late ${what}: the interpreter that asked for it is gone`);
642
- return;
643
- }
644
- this.send(msg);
645
- }
646
-
647
- private send(obj: unknown): void {
648
- const child = this.child;
649
- if (!child || child.exitCode !== null || child.killed) return;
650
- try {
651
- child.stdin.write(JSON.stringify(obj) + '\n');
652
- } catch (err) {
653
- console.error(
654
- `[pytc:${this.label}] protocol write failed: ${err instanceof Error ? err.message : String(err)}`,
655
- );
656
- }
657
- }
658
-
659
- /**
660
- * SIGINT the interpreter: blocking code in the running script raises
661
- * KeyboardInterrupt. Not on Windows, where Node's kill() ends the process
662
- * outright; there the cancel op and the kill grace remain.
663
- * process.kill rather than child.kill, which would mark the child `killed`:
664
- * a killed child gets no more protocol messages and is respawned on the
665
- * next exec, losing the interpreter state this keeps.
666
- */
667
- private interruptChild(): void {
668
- const child = this.child;
669
- if (process.platform === 'win32' || !child || child.exitCode !== null || child.killed) return;
670
- if (child.pid === undefined) return;
671
- try {
672
- process.kill(child.pid, 'SIGINT');
673
- } catch {
674
- // already gone: the exit handler settles the exec
675
- }
676
- }
677
-
678
- /** Kill interpreter + clean temp dir. State is lost (by design). */
679
- private reclaim(reason: string): void {
680
- if (this.child) {
681
- console.error(`[pytc:${this.label}] reclaiming python interpreter (${reason})`);
682
- }
683
- // An interpreter killed as it starts never says it's ready: fail its
684
- // readiness now, so an exec waiting on it returns at once rather than at
685
- // the 15 s timeout. Here rather than in teardownChild, which an
686
- // interpreter's own exit also runs, so that one still reads "exited as
687
- // it started".
688
- this.readyFailer?.(new Error(`python runtime reclaimed (${reason}) before it was ready`));
689
- this.teardownChild();
690
- this.clearIdleTimer();
691
- }
692
-
693
- private teardownChild(): void {
694
- if (this.reader) {
695
- this.reader.close();
696
- this.reader = null;
697
- }
698
- const child = this.child;
699
- if (child) {
700
- this.child = null;
701
- this.childReady = null;
702
- this.readyResolver = null;
703
- this.readyFailer = null;
704
- child.removeAllListeners('exit');
705
- try {
706
- child.stdin.end();
707
- } catch {
708
- // stream may already be destroyed
709
- }
710
- // A child whose spawn failed has no pid, and its exitCode stays null
711
- // until Node's 'error' for it, a tick later. A kill() in that tick, as
712
- // from an abort or dispose on the tick exec was called, signals no
713
- // child of ours but whatever pid Node's handle held: the host's own
714
- // process group on Node 20.20.2, another process's pid on 24.19.
715
- if (child.exitCode === null && !child.killed && child.pid !== undefined) {
716
- child.kill('SIGKILL');
717
- }
718
- }
719
- const dir = this.runtimeDir;
720
- this.runtimeDir = null;
721
- if (dir) {
722
- const remove = (): void => {
723
- try {
724
- rmSync(dir, { recursive: true, force: true });
725
- } catch {
726
- // temp cleanup is best-effort
727
- }
728
- };
729
- // The directory is the interpreter's working directory, which Windows
730
- // won't remove while the process lives, so there it goes once the
731
- // process has. Elsewhere it goes now, so a host that exits right after
732
- // this doesn't leave it behind. A child that never started has no exit
733
- // to wait for.
734
- const running = child !== null && child.pid !== undefined && child.exitCode === null && child.signalCode === null;
735
- if (running && process.platform === 'win32') child.once('exit', remove);
736
- else remove();
737
- }
738
- }
739
-
740
- private armIdleTimer(): void {
741
- this.clearIdleTimer();
742
- if (this.idleReclaimMs <= 0 || !this.child) return;
743
- this.idleTimer = setTimeout(() => {
744
- if (!this.pending) this.reclaim('idle');
745
- }, this.idleReclaimMs);
746
- // Do not hold the process open just to reclaim an idle interpreter.
747
- this.idleTimer.unref?.();
748
- }
749
-
750
- private clearIdleTimer(): void {
751
- if (this.idleTimer) {
752
- clearTimeout(this.idleTimer);
753
- this.idleTimer = null;
754
- }
755
- }
756
- }
757
-
758
- /**
759
- * Map framework tool names to python identifiers: `--` (the framework's
760
- * prefix separator) becomes `__`, and every remaining character that is not
761
- * valid in a python identifier becomes `_` (fleet reality: module and server
762
- * ids contain single hyphens — `mcpl-admin`, `dog-events` — found live on
763
- * the first canary run, 2026-07-26). A leading digit gets a `_` prefix.
764
- *
765
- * Names that sanitize to nothing, or that collide after sanitization, are
766
- * skipped loudly — a skipped tool is unreachable from scripts (the
767
- * exact-name tools[...] dict is built from this same list).
768
- */
769
- export function buildInjectedTools(
770
- toolNames: string[],
771
- log: (message: string) => void = (m) => console.error(m),
772
- ): InjectedTool[] {
773
- const seen = new Map<string, string>();
774
- const injected: InjectedTool[] = [];
775
- for (const toolName of toolNames) {
776
- let pyName = toolName.replace(/--/g, '__').replace(/[^A-Za-z0-9_]/g, '_');
777
- if (/^[0-9]/.test(pyName)) pyName = '_' + pyName;
778
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(pyName)) {
779
- log(`[pytc] tool '${toolName}' skipped: cannot derive a python identifier`);
780
- continue;
781
- }
782
- const existing = seen.get(pyName);
783
- if (existing) {
784
- log(`[pytc] tool '${toolName}' skipped: python name '${pyName}' collides with '${existing}'`);
785
- continue;
786
- }
787
- seen.set(pyName, toolName);
788
- injected.push({ pyName, toolName });
789
- }
790
- return injected;
791
- }