@animalabs/agent-framework 0.21.1 → 0.22.1

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 (348) hide show
  1. package/README.md +54 -4
  2. package/dist/src/agent.d.ts +17 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +105 -43
  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 +41 -4
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +22 -1
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +64 -9
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +215 -38
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/framework.d.ts +569 -59
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3091 -542
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +21 -0
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +40 -7
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/index.d.ts +7 -2
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +26 -3
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  33. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  34. package/dist/src/mcpl/conversation-router.js +17 -0
  35. package/dist/src/mcpl/conversation-router.js.map +1 -1
  36. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  37. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  38. package/dist/src/mcpl/feature-set-manager.js +31 -7
  39. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  40. package/dist/src/mcpl/index.d.ts +4 -0
  41. package/dist/src/mcpl/index.d.ts.map +1 -1
  42. package/dist/src/mcpl/index.js +1 -0
  43. package/dist/src/mcpl/index.js.map +1 -1
  44. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  45. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  46. package/dist/src/mcpl/prose-stream-router.js +85 -22
  47. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  48. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  49. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  50. package/dist/src/mcpl/push-coalescer.js +46 -13
  51. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  52. package/dist/src/mcpl/push-handler.d.ts +5 -3
  53. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  54. package/dist/src/mcpl/push-handler.js +41 -11
  55. package/dist/src/mcpl/push-handler.js.map +1 -1
  56. package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
  57. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  58. package/dist/src/mcpl/reference-fetcher.js +20 -9
  59. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  60. package/dist/src/mcpl/references.d.ts +29 -6
  61. package/dist/src/mcpl/references.d.ts.map +1 -1
  62. package/dist/src/mcpl/references.js +57 -16
  63. package/dist/src/mcpl/references.js.map +1 -1
  64. package/dist/src/mcpl/server-connection.d.ts +32 -1
  65. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  66. package/dist/src/mcpl/server-connection.js +76 -11
  67. package/dist/src/mcpl/server-connection.js.map +1 -1
  68. package/dist/src/mcpl/server-registry.d.ts +5 -2
  69. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  70. package/dist/src/mcpl/server-registry.js +25 -7
  71. package/dist/src/mcpl/server-registry.js.map +1 -1
  72. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  73. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  74. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  75. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  76. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  77. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  78. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  79. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  80. package/dist/src/mcpl/types.d.ts +15 -5
  81. package/dist/src/mcpl/types.d.ts.map +1 -1
  82. package/dist/src/mcpl/types.js.map +1 -1
  83. package/dist/src/mcpl/visible-content.d.ts +39 -0
  84. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  85. package/dist/src/mcpl/visible-content.js +54 -0
  86. package/dist/src/mcpl/visible-content.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +90 -6
  88. package/dist/src/module-registry.d.ts.map +1 -1
  89. package/dist/src/module-registry.js +260 -18
  90. package/dist/src/module-registry.js.map +1 -1
  91. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  92. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/branch-intent.js +83 -0
  94. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  95. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  96. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  97. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  98. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  99. package/dist/src/modules/workspace/effects.d.ts +84 -0
  100. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  101. package/dist/src/modules/workspace/effects.js +368 -0
  102. package/dist/src/modules/workspace/effects.js.map +1 -0
  103. package/dist/src/modules/workspace/index.d.ts +155 -17
  104. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/index.js +1101 -424
  106. package/dist/src/modules/workspace/index.js.map +1 -1
  107. package/dist/src/modules/workspace/observe.d.ts +288 -0
  108. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  109. package/dist/src/modules/workspace/observe.js +825 -0
  110. package/dist/src/modules/workspace/observe.js.map +1 -0
  111. package/dist/src/modules/workspace/reconcile.d.ts +238 -0
  112. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/reconcile.js +909 -0
  114. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +6 -59
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  117. package/dist/src/modules/workspace/sync.js +6 -257
  118. package/dist/src/modules/workspace/sync.js.map +1 -1
  119. package/dist/src/modules/workspace/types.d.ts +77 -10
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  121. package/dist/src/modules/workspace/types.js.map +1 -1
  122. package/dist/src/operator-log.d.ts +41 -0
  123. package/dist/src/operator-log.d.ts.map +1 -1
  124. package/dist/src/operator-log.js.map +1 -1
  125. package/dist/src/provider-waits.d.ts +216 -0
  126. package/dist/src/provider-waits.d.ts.map +1 -0
  127. package/dist/src/provider-waits.js +482 -0
  128. package/dist/src/provider-waits.js.map +1 -0
  129. package/dist/src/record-journal.d.ts +127 -0
  130. package/dist/src/record-journal.d.ts.map +1 -0
  131. package/dist/src/record-journal.js +202 -0
  132. package/dist/src/record-journal.js.map +1 -0
  133. package/dist/src/refusal-reactions.d.ts +14 -12
  134. package/dist/src/refusal-reactions.d.ts.map +1 -1
  135. package/dist/src/refusal-reactions.js +14 -12
  136. package/dist/src/refusal-reactions.js.map +1 -1
  137. package/dist/src/shutdown.d.ts +13 -0
  138. package/dist/src/shutdown.d.ts.map +1 -0
  139. package/dist/src/shutdown.js +46 -0
  140. package/dist/src/shutdown.js.map +1 -0
  141. package/dist/src/silent-heartbeat.d.ts +74 -0
  142. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  143. package/dist/src/silent-heartbeat.js +77 -0
  144. package/dist/src/silent-heartbeat.js.map +1 -0
  145. package/dist/src/tool-presentation.d.ts +50 -0
  146. package/dist/src/tool-presentation.d.ts.map +1 -0
  147. package/dist/src/tool-presentation.js +235 -0
  148. package/dist/src/tool-presentation.js.map +1 -0
  149. package/dist/src/tool-result-guard.d.ts +142 -6
  150. package/dist/src/tool-result-guard.d.ts.map +1 -1
  151. package/dist/src/tool-result-guard.js +357 -36
  152. package/dist/src/tool-result-guard.js.map +1 -1
  153. package/dist/src/tool-result-history.d.ts +13 -0
  154. package/dist/src/tool-result-history.d.ts.map +1 -1
  155. package/dist/src/tool-result-history.js +21 -1
  156. package/dist/src/tool-result-history.js.map +1 -1
  157. package/dist/src/types/agent.d.ts +18 -2
  158. package/dist/src/types/agent.d.ts.map +1 -1
  159. package/dist/src/types/events.d.ts +16 -0
  160. package/dist/src/types/events.d.ts.map +1 -1
  161. package/dist/src/types/framework.d.ts +12 -0
  162. package/dist/src/types/framework.d.ts.map +1 -1
  163. package/dist/src/types/module.d.ts +26 -4
  164. package/dist/src/types/module.d.ts.map +1 -1
  165. package/dist/src/types/trace.d.ts +87 -3
  166. package/dist/src/types/trace.d.ts.map +1 -1
  167. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  168. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  169. package/dist/test/batched-wake-locus.test.js +166 -0
  170. package/dist/test/batched-wake-locus.test.js.map +1 -0
  171. package/dist/test/code-execution.test.js +386 -2
  172. package/dist/test/code-execution.test.js.map +1 -1
  173. package/dist/test/component-defaults.test.d.ts +2 -0
  174. package/dist/test/component-defaults.test.d.ts.map +1 -0
  175. package/dist/test/component-defaults.test.js +54 -0
  176. package/dist/test/component-defaults.test.js.map +1 -0
  177. package/dist/test/conversation-routing.test.js +57 -1
  178. package/dist/test/conversation-routing.test.js.map +1 -1
  179. package/dist/test/discord-awareness-outbox.test.js +3 -0
  180. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  181. package/dist/test/ephemeral-settle.test.js +3 -0
  182. package/dist/test/ephemeral-settle.test.js.map +1 -1
  183. package/dist/test/event-gate.test.js +118 -0
  184. package/dist/test/event-gate.test.js.map +1 -1
  185. package/dist/test/explicit-prose-routing.test.js +975 -0
  186. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  187. package/dist/test/framework.test.js +160 -1
  188. package/dist/test/framework.test.js.map +1 -1
  189. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  190. package/dist/test/helpers/coalescing-fixture.js +3 -2
  191. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  192. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  193. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  194. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  195. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  196. package/dist/test/inference-exhaustion-rewind.test.js +142 -14
  197. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  198. package/dist/test/inference-failure-bounds.test.d.ts +2 -0
  199. package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
  200. package/dist/test/inference-failure-bounds.test.js +1201 -0
  201. package/dist/test/inference-failure-bounds.test.js.map +1 -0
  202. package/dist/test/late-events-after-stop.test.d.ts +2 -0
  203. package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
  204. package/dist/test/late-events-after-stop.test.js +122 -0
  205. package/dist/test/late-events-after-stop.test.js.map +1 -0
  206. package/dist/test/mcpl-awareness-barrier.test.js +141 -0
  207. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  208. package/dist/test/mcpl-coalescing.test.js +97 -22
  209. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  210. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  211. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  212. package/dist/test/mcpl-config-diagnostics.test.js +452 -0
  213. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  214. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  215. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  216. package/dist/test/mcpl-empty-content.test.js +368 -0
  217. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  218. package/dist/test/mcpl-manifest-changed.test.js +2 -1
  219. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  220. package/dist/test/mcpl-reference-fetcher.test.js +232 -4
  221. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  222. package/dist/test/mcpl-reference-fields.test.js +69 -2
  223. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  224. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  225. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  226. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  227. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  228. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  229. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  230. package/dist/test/mcpl-ws-transport.test.js +103 -0
  231. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  232. package/dist/test/ops-alert.test.js +2 -1
  233. package/dist/test/ops-alert.test.js.map +1 -1
  234. package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
  235. package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
  236. package/dist/test/placed-reaction-baseline.test.js +312 -0
  237. package/dist/test/placed-reaction-baseline.test.js.map +1 -0
  238. package/dist/test/prose-stream-router.test.js +41 -0
  239. package/dist/test/prose-stream-router.test.js.map +1 -1
  240. package/dist/test/provider-acceleration-cooldown.test.js +5 -1
  241. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  242. package/dist/test/provider-waits.test.d.ts +2 -0
  243. package/dist/test/provider-waits.test.d.ts.map +1 -0
  244. package/dist/test/provider-waits.test.js +2196 -0
  245. package/dist/test/provider-waits.test.js.map +1 -0
  246. package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
  247. package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
  248. package/dist/test/reaction-baseline-platform.test.js +124 -0
  249. package/dist/test/reaction-baseline-platform.test.js.map +1 -0
  250. package/dist/test/record-journal.test.d.ts +6 -0
  251. package/dist/test/record-journal.test.d.ts.map +1 -0
  252. package/dist/test/record-journal.test.js +269 -0
  253. package/dist/test/record-journal.test.js.map +1 -0
  254. package/dist/test/refusal-reaction-baseline.test.js +10 -3
  255. package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
  256. package/dist/test/save-recent-image.test.js +240 -2
  257. package/dist/test/save-recent-image.test.js.map +1 -1
  258. package/dist/test/shutdown-errors.test.d.ts +2 -0
  259. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  260. package/dist/test/shutdown-errors.test.js +662 -0
  261. package/dist/test/shutdown-errors.test.js.map +1 -0
  262. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  263. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  264. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  265. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  266. package/dist/test/silent-heartbeat.test.js +49 -38
  267. package/dist/test/silent-heartbeat.test.js.map +1 -1
  268. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  269. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  270. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  271. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  272. package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
  273. package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
  274. package/dist/test/surgery-marks-receipt.test.js +540 -0
  275. package/dist/test/surgery-marks-receipt.test.js.map +1 -0
  276. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  277. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  278. package/dist/test/tool-batch-hook.test.js +275 -0
  279. package/dist/test/tool-batch-hook.test.js.map +1 -0
  280. package/dist/test/tool-presentation.test.d.ts +2 -0
  281. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  282. package/dist/test/tool-presentation.test.js +268 -0
  283. package/dist/test/tool-presentation.test.js.map +1 -0
  284. package/dist/test/tool-result-guard.test.js +2545 -19
  285. package/dist/test/tool-result-guard.test.js.map +1 -1
  286. package/dist/test/tool-result-history.test.js +2 -1
  287. package/dist/test/tool-result-history.test.js.map +1 -1
  288. package/dist/test/turn-trigger-provenance.test.js +35 -0
  289. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  290. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  291. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  292. package/dist/test/unconvertible-tool-result.test.js +358 -0
  293. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  294. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  295. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  296. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  297. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  298. package/dist/test/workspace-three-way.test.d.ts +12 -0
  299. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  300. package/dist/test/workspace-three-way.test.js +3194 -0
  301. package/dist/test/workspace-three-way.test.js.map +1 -0
  302. package/dist/tsconfig.tsbuildinfo +1 -1
  303. package/package.json +2 -2
  304. package/src/agent.ts +124 -45
  305. package/src/api/server.ts +43 -4
  306. package/src/code-execution/py-runner.ts +66 -9
  307. package/src/code-execution/runtime-py.ts +215 -38
  308. package/src/framework.ts +3191 -544
  309. package/src/gate/event-gate.ts +59 -7
  310. package/src/index.ts +18 -2
  311. package/src/mcpl/channel-registry.ts +24 -3
  312. package/src/mcpl/conversation-router.ts +34 -1
  313. package/src/mcpl/feature-set-manager.ts +36 -11
  314. package/src/mcpl/index.ts +4 -0
  315. package/src/mcpl/prose-stream-router.ts +79 -23
  316. package/src/mcpl/push-coalescer.ts +62 -13
  317. package/src/mcpl/push-handler.ts +41 -12
  318. package/src/mcpl/reference-fetcher.ts +18 -8
  319. package/src/mcpl/references.ts +57 -13
  320. package/src/mcpl/server-connection.ts +95 -13
  321. package/src/mcpl/server-registry.ts +24 -7
  322. package/src/mcpl/tool-lifecycle.ts +77 -2
  323. package/src/mcpl/tool-pattern-check.ts +106 -0
  324. package/src/mcpl/types.ts +15 -5
  325. package/src/mcpl/visible-content.ts +57 -0
  326. package/src/module-registry.ts +269 -25
  327. package/src/modules/workspace/branch-intent.ts +123 -0
  328. package/src/modules/workspace/disk-agreement.ts +325 -0
  329. package/src/modules/workspace/effects.ts +362 -0
  330. package/src/modules/workspace/index.ts +1098 -446
  331. package/src/modules/workspace/observe.ts +865 -0
  332. package/src/modules/workspace/reconcile.ts +1157 -0
  333. package/src/modules/workspace/sync.ts +6 -336
  334. package/src/modules/workspace/types.ts +75 -10
  335. package/src/operator-log.ts +30 -0
  336. package/src/provider-waits.ts +523 -0
  337. package/src/record-journal.ts +246 -0
  338. package/src/refusal-reactions.ts +14 -12
  339. package/src/shutdown.ts +49 -0
  340. package/src/silent-heartbeat.ts +141 -0
  341. package/src/tool-presentation.ts +188 -0
  342. package/src/tool-result-guard.ts +384 -34
  343. package/src/tool-result-history.ts +25 -1
  344. package/src/types/agent.ts +18 -2
  345. package/src/types/events.ts +16 -0
  346. package/src/types/framework.ts +12 -0
  347. package/src/types/module.ts +27 -4
  348. package/src/types/trace.ts +94 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.21.1",
3
+ "version": "0.22.1",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,7 +48,7 @@
48
48
  "dependencies": {
49
49
  "@animalabs/chronicle": "^0.4.0",
50
50
  "@animalabs/context-manager": "^0.14.0",
51
- "@animalabs/membrane": "^0.5.78",
51
+ "@animalabs/membrane": "^0.5.87",
52
52
  "chokidar": "^4.0.3",
53
53
  "discord.js": "^14.25.1",
54
54
  "ws": "^8.18.0"
package/src/agent.ts CHANGED
@@ -2,13 +2,26 @@ import type { Membrane, NormalizedMessage, NormalizedRequest, ContentBlock, Yiel
2
2
  import { isAbortedResponse } from '@animalabs/membrane';
3
3
  import { createHash } from 'node:crypto';
4
4
  import type { CacheWireReceipt, KvUnifiedRequestHooks } from './kv-unified-wire.js';
5
- import { ToolResultGuard, TOOL_RESULT_GUARD_NOTICE } from './tool-result-guard.js';
5
+ import { ToolResultGuard } from './tool-result-guard.js';
6
6
  import {
7
- toolResultDataToHistoryString,
7
+ indexSilentTickRows,
8
+ separateSilentHeartbeatTicks,
9
+ silentHeartbeatSeparatorTurn,
10
+ type SilentHeartbeatTick,
11
+ type SilentTickIndex,
12
+ type StoredRowLike,
13
+ } from './silent-heartbeat.js';
14
+ import {
15
+ nonErrorResultText,
8
16
  truncateForHistory,
9
17
  DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
10
18
  } from './tool-result-history.js';
11
19
 
20
+ export interface ActivationRequestOptions {
21
+ /** The silent heartbeat tick this request answers, if any. */
22
+ silentHeartbeat?: SilentHeartbeatTick;
23
+ }
24
+
12
25
  export interface StartStreamResult {
13
26
  stream: YieldingStream;
14
27
  request: NormalizedRequest;
@@ -22,6 +35,7 @@ import type {
22
35
  CompileResult,
23
36
  HotContextSettingsStatus,
24
37
  HotContextSettingsUpdate,
38
+ MessageMetadata,
25
39
  } from '@animalabs/context-manager';
26
40
  import type {
27
41
  AgentConfig,
@@ -653,31 +667,43 @@ export class Agent {
653
667
  const wireResults = content.flatMap((block) => block.type === 'tool_result'
654
668
  // buildToolResultMessages serializes every payload to a string.
655
669
  ? [{ toolUseId: block.toolUseId, content: block.content as string, isError: block.isError }] : []);
656
- this.toolResultGuard.storeResults(content, wireResults, this._state.toolResults);
670
+ // Stored under the house default cap (buildToolResultMessages), as what a
671
+ // stub keeps is too.
672
+ this.toolResultGuard.storeResults(content, wireResults, this._state.toolResults, undefined,
673
+ DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS);
657
674
  }
658
675
 
659
- // Compile context (with optional injections)
660
- const { messages, systemInjections } = await this.compileWithInjections(budget, injections);
661
-
662
- // If we have pending tool results, add them
663
- if (this._state.status === 'ready' && !guardedResults) {
664
- const toolResultMessages = this.buildToolResultMessages(this._state.toolResults);
665
- messages.push(...toolResultMessages);
666
- }
667
-
668
- const request: NormalizedRequest = {
669
- messages: this.toolResultGuard.prepareRequest(messages, true),
670
- system: this.buildSystemPrompt(systemInjections),
671
- config: {
672
- model: this.model,
673
- maxTokens: this.maxTokens,
674
- ...(this.temperature !== undefined && { temperature: this.temperature }),
675
- ...(this.thinking !== undefined && { thinking: this.thinking }),
676
- },
677
- tools: tools.length > 0 ? tools : undefined,
678
- ...(this.providerParams && { providerParams: this.providerParams }),
679
- assistantParticipant: this.name,
676
+ // Compile the context (with optional injections) into a request. The
677
+ // guard's refusal retry is assembled the same way, afresh. It is compiled
678
+ // only once each withheld stub's annotation has finished, including one
679
+ // that began during the compile, and after a stub edit that failed has
680
+ // been retried; one whose store still fails is compiled as the bare
681
+ // notice.
682
+ let unguardedResults: ReturnType<Agent['buildToolResultMessages']> | undefined;
683
+ const assemble = async (): Promise<NormalizedRequest> => {
684
+ const { messages, systemInjections } = await this.toolResultGuard.afterAnnotations(
685
+ () => this.compileWithInjections(budget, injections));
686
+ // Read once, after the first compile, since results may have become
687
+ // ready while it ran; the refusal retry, assembled while the agent is
688
+ // inferring, sends the same ones.
689
+ unguardedResults ??= this._state.status === 'ready' && !guardedResults
690
+ ? this.buildToolResultMessages(this._state.toolResults) : [];
691
+ messages.push(...unguardedResults);
692
+ return {
693
+ messages: this.toolResultGuard.prepareRequest(messages, true),
694
+ system: this.buildSystemPrompt(systemInjections),
695
+ config: {
696
+ model: this.model,
697
+ maxTokens: this.maxTokens,
698
+ ...(this.temperature !== undefined && { temperature: this.temperature }),
699
+ ...(this.thinking !== undefined && { thinking: this.thinking }),
700
+ },
701
+ tools: tools.length > 0 ? tools : undefined,
702
+ ...(this.providerParams && { providerParams: this.providerParams }),
703
+ assistantParticipant: this.name,
704
+ };
680
705
  };
706
+ const request = await assemble();
681
707
 
682
708
  const abortController = new AbortController();
683
709
  if (options?.signal) {
@@ -690,7 +716,7 @@ export class Agent {
690
716
 
691
717
  // Set state to inferring
692
718
  this._inferenceStartedAt = Date.now();
693
- const inferencePromise = this.doInference(request, abortController.signal);
719
+ const inferencePromise = this.doInference(request, abortController.signal, assemble);
694
720
  this._state = { status: 'inferring', promise: inferencePromise, abortController };
695
721
 
696
722
  try {
@@ -779,20 +805,27 @@ export class Agent {
779
805
  * it does not touch agent state and does not call the membrane, and
780
806
  * `ContextManager.compile` is itself side-effect-free (compression runs in
781
807
  * the background). Safe to call regardless of the agent's current status.
808
+ * It doesn't wait for the tool-result guard's annotations, so a preview
809
+ * never waits on a workspace write (rollback, undo and redo report one), and
810
+ * may show a withheld stub before its place is added; the activation waits
811
+ * for them around its call.
782
812
  */
783
813
  async buildActivationRequest(
784
814
  availableTools: ToolDefinition[],
785
815
  injections?: ContextInjection[],
786
- budget?: TokenBudget
816
+ budget?: TokenBudget,
817
+ compressionTools: ToolDefinition[] = availableTools,
818
+ options: ActivationRequestOptions = {},
787
819
  ): Promise<NormalizedRequest> {
788
- // Keep the context manager's view of the live tool surface current: the
820
+ // Compression may revisit hidden tools in recorded history. Keep its full
821
+ // definition set separate from the resident's advertised tools: the
789
822
  // autobiographical strategy must declare the same tools on its
790
823
  // summarizer/compression requests, or transcripts containing tool blocks
791
824
  // are refused by Anthropic's reasoning_extraction classifier (labclaude
792
825
  // incident, 2026-07-09). Optional chaining: older context-manager
793
826
  // versions don't have the hook.
794
827
  (this.contextManager as unknown as { setToolDefinitions?: (t: ToolDefinition[]) => void })
795
- .setToolDefinitions?.(availableTools);
828
+ .setToolDefinitions?.(compressionTools);
796
829
 
797
830
  const strategy = (this.contextManager as unknown as { getStrategy?: () => unknown })
798
831
  .getStrategy?.() as {
@@ -837,10 +870,24 @@ export class Agent {
837
870
  }))
838
871
  .filter((m) => m.content.length > 0);
839
872
 
840
- // Safety: ensure messages don't end with an assistant message.
841
- // Some models reject trailing assistant messages ("prefill not supported"),
842
- // and after context compression a stale assistant turn can end up last.
843
- if (messages.length > 0 && messages[messages.length - 1]!.participant === this.name) {
873
+ // Silent heartbeat ticks store no prompt row. Render a request-only
874
+ // separator before each tick's first surviving row so the wire formatter
875
+ // cannot merge the tick into the previous assistant message (see
876
+ // silent-heartbeat.ts for what can and cannot be matched). This agent's
877
+ // own first request for a tick ends on that same turn, so the prefix it
878
+ // caches is the one every later compile renders. "First" is per agent: a
879
+ // broadcast tick shares its eventId with every resident.
880
+ const ticks = this.indexSilentHeartbeatTicks();
881
+ messages = separateSilentHeartbeatTicks(messages, ticks.rows, this.name);
882
+ const openingTick = options.silentHeartbeat !== undefined
883
+ && !ticks.started.has(options.silentHeartbeat.eventId);
884
+
885
+ if (openingTick) {
886
+ messages = [...messages, silentHeartbeatSeparatorTurn()];
887
+ } else if (messages.length > 0 && messages[messages.length - 1]!.participant === this.name) {
888
+ // Safety: ensure messages don't end with an assistant message.
889
+ // Some models reject trailing assistant messages ("prefill not supported"),
890
+ // and after context compression a stale assistant turn can end up last.
844
891
  messages = [...messages, {
845
892
  participant: 'user',
846
893
  content: [{ type: 'text', text: '[Continue]' }],
@@ -866,6 +913,26 @@ export class Agent {
866
913
  };
867
914
  }
868
915
 
916
+ /** Stored silent-tick rows, memoized on the store's cached message array
917
+ * (ContextManager returns the same array until the store changes). */
918
+ private silentTickIndex: { source: readonly unknown[]; index: SilentTickIndex } | null = null;
919
+
920
+ private indexSilentHeartbeatTicks(): SilentTickIndex {
921
+ const empty: SilentTickIndex = { rows: new Map(), started: new Set() };
922
+ const cm = this.contextManager as Partial<ContextManager>;
923
+ if (typeof cm.getAllMessages !== 'function') return empty;
924
+ try {
925
+ const stored = cm.getAllMessages() as unknown as readonly StoredRowLike[];
926
+ if (this.silentTickIndex?.source === stored) return this.silentTickIndex.index;
927
+ const index = indexSilentTickRows(stored, this.name);
928
+ this.silentTickIndex = { source: stored, index };
929
+ return index;
930
+ } catch (error) {
931
+ console.error(`[silent-heartbeat] ${this.name}: could not index tick rows; separators omitted:`, error);
932
+ return empty;
933
+ }
934
+ }
935
+
869
936
  /**
870
937
  * Start a yielding stream with context injections.
871
938
  * Same as startStream but passes injections through to compile.
@@ -873,7 +940,9 @@ export class Agent {
873
940
  async startStreamWithInjections(
874
941
  availableTools: ToolDefinition[],
875
942
  injections?: ContextInjection[],
876
- budget?: TokenBudget
943
+ budget?: TokenBudget,
944
+ compressionTools: ToolDefinition[] = availableTools,
945
+ options: ActivationRequestOptions = {},
877
946
  ): Promise<StartStreamResult> {
878
947
  if (this._state.status !== 'idle') {
879
948
  throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`);
@@ -908,7 +977,11 @@ export class Agent {
908
977
  this.lastStreamRealInputTokens = 0;
909
978
  this.lastStreamOutputTokens = 0;
910
979
 
911
- const request = await this.buildActivationRequest(availableTools, injections, budget);
980
+ // Compiled only once each withheld stub's annotation has finished,
981
+ // including one that began during the compile, and after a stub edit that
982
+ // failed has been retried (see afterAnnotations).
983
+ const request = await this.toolResultGuard.afterAnnotations(
984
+ () => this.buildActivationRequest(availableTools, injections, budget, compressionTools, options));
912
985
  request.messages = this.toolResultGuard.prepareRequest(request.messages, true);
913
986
 
914
987
  const receiptAware = (this.contextManager as unknown as { getStrategy?: () => unknown })
@@ -1009,7 +1082,7 @@ export class Agent {
1009
1082
  * Add an assistant response to context.
1010
1083
  * Called by framework when stream completes.
1011
1084
  */
1012
- addAssistantResponse(content: ContentBlock[]): void {
1085
+ addAssistantResponse(content: ContentBlock[], metadata?: MessageMetadata): void {
1013
1086
  // A turn whose entire output is thinking blocks produced NOTHING: no
1014
1087
  // speech, no tool call. That is what a refusal looks like on the wire —
1015
1088
  // the provider returns signed thinking (often with empty text, the
@@ -1035,7 +1108,7 @@ export class Agent {
1035
1108
  );
1036
1109
  return;
1037
1110
  }
1038
- this.contextManager.addMessage(this.name, content);
1111
+ this.contextManager.addMessage(this.name, content, metadata);
1039
1112
  }
1040
1113
 
1041
1114
  /**
@@ -1126,9 +1199,13 @@ export class Agent {
1126
1199
 
1127
1200
  private async doInference(
1128
1201
  request: NormalizedRequest,
1129
- signal?: AbortSignal
1202
+ signal: AbortSignal | undefined,
1203
+ /** Compiles the context into a request again, for the guard's retry. */
1204
+ reassemble: () => Promise<NormalizedRequest>,
1130
1205
  ): Promise<InferenceResult> {
1131
- let response = await this.membrane.stream(request, { signal });
1206
+ let response = signal?.aborted
1207
+ ? { aborted: true as const, reason: 'user' as const }
1208
+ : await this.membrane.stream(request, { signal });
1132
1209
  // Usage of a refused attempt abandoned by the guard; it was billed and
1133
1210
  // is added to the retry's usage below rather than overwritten.
1134
1211
  let abandonedUsage: { inputTokens: number; outputTokens: number } | undefined;
@@ -1138,12 +1215,14 @@ export class Agent {
1138
1215
  const ids = this.toolResultGuard.withhold('unknown');
1139
1216
  if (ids) {
1140
1217
  abandonedUsage = response.usage;
1141
- const withheld = new Set(ids);
1142
- request = { ...request, messages: request.messages.map((message) => ({
1143
- ...message, content: message.content.map((block) => block.type === 'tool_result' && withheld.has(block.toolUseId)
1144
- ? { type: 'tool_result', toolUseId: block.toolUseId, content: TOOL_RESULT_GUARD_NOTICE, isError: block.isError } : block),
1145
- })) };
1146
- response = await this.membrane.stream(request, { signal });
1218
+ // The retry is compiled afresh, like every request: once each stub's
1219
+ // annotation has finished (agent-framework #277), and against the
1220
+ // budget without the batch's reservation, so a stub longer than its
1221
+ // original can't carry the request past the budget.
1222
+ const retry = await reassemble();
1223
+ response = signal?.aborted
1224
+ ? { aborted: true, reason: 'user', partialUsage: response.usage }
1225
+ : await this.membrane.stream(retry, { signal });
1147
1226
  }
1148
1227
  }
1149
1228
 
@@ -1224,7 +1303,7 @@ export class Agent {
1224
1303
  toolUseId: r.id,
1225
1304
  content: r.result.isError
1226
1305
  ? truncateForHistory(r.result.error ?? 'Unknown error', DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS)
1227
- : toolResultDataToHistoryString(r.result.data, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
1306
+ : nonErrorResultText(r.result, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS),
1228
1307
  isError: r.result.isError,
1229
1308
  }));
1230
1309
 
package/src/api/server.ts CHANGED
@@ -12,6 +12,7 @@ import { timingSafeEqual } from 'node:crypto';
12
12
  import type { ContentBlock } from '@animalabs/membrane';
13
13
  import type { AgentFramework } from '../framework.js';
14
14
  import { ResumeBlockedError } from '../framework.js';
15
+ import { isReleaseModel } from '../provider-waits.js';
15
16
  import type { TraceEvent, ProcessEvent } from '../types/index.js';
16
17
  import type {
17
18
  ApiServerConfig,
@@ -420,6 +421,37 @@ export class ApiServer {
420
421
  case 'host.maintenanceTick':
421
422
  this.requireAdminToken(params);
422
423
  return this.framework.maintenanceTick();
424
+ // A provider's stated retry-after is honoured across restarts; this is
425
+ // the explicit operator override (recorded, so a restart keeps it).
426
+ case 'host.releaseProviderWait': {
427
+ this.requireAdminToken(params);
428
+ const p = (params ?? {}) as { agentName?: unknown; model?: unknown; requester?: unknown };
429
+ if (typeof p.agentName !== 'string' || p.agentName === '') throw new Error('agentName is required');
430
+ // Only an omitted model, or '*', releases every model: a malformed one
431
+ // is refused before anything changes, never widened.
432
+ const model = p.model;
433
+ if (!isReleaseModel(model)) throw new Error("model must be a model name, or '*' (or omitted) for every model");
434
+ // A requester over this surface names itself, so the record says
435
+ // where the name came from ('api:<name>'), and it can't pass for a
436
+ // host command's requester.
437
+ return {
438
+ released: this.framework.releaseProviderWait(
439
+ p.agentName,
440
+ model,
441
+ typeof p.requester === 'string' && p.requester !== '' ? `api:${p.requester}` : 'api',
442
+ ),
443
+ };
444
+ }
445
+ // A read, like host.status: the admin token gates the verbs that change
446
+ // something. (The HTTP /hostmode read takes it because that response is
447
+ // readable cross-origin; this surface checks the origin.)
448
+ case 'host.providerWaits': {
449
+ const agentName = params?.agentName;
450
+ if (agentName !== undefined && typeof agentName !== 'string') {
451
+ throw new Error('agentName must be an agent name, or omitted for every agent');
452
+ }
453
+ return { waits: this.framework.providerWaitSnapshot(agentName) };
454
+ }
423
455
 
424
456
  default:
425
457
  throw new Error(`Unknown command: ${command}`);
@@ -543,8 +575,13 @@ export class ApiServer {
543
575
  this.currentBranch = params.name;
544
576
 
545
577
  // Reversible Discord awareness markers are part of the branch projection.
546
- // Do not acknowledge the switch until add/remove operations were attempted.
547
- await this.framework.syncDiscordAwarenessMarkers();
578
+ // The switch is acknowledged once it has landed: delivery of the marks it
579
+ // adds and removes starts behind the MCPL data-plane gate, which holds
580
+ // channel traffic that arrives after it until they are attempted, or
581
+ // deferred while their server is disconnected, and a failure is an ops
582
+ // alert rather than a failed switch. (It used to await the awareness
583
+ // sync, which after a large rollback took the drain's whole length.)
584
+ this.framework.startDiscordAwarenessDelivery('branch switch');
548
585
 
549
586
  // Materialize config files from the new branch
550
587
  const ws = this.framework.getModule('workspace');
@@ -598,7 +635,8 @@ export class ApiServer {
598
635
 
599
636
  if (result.undone) {
600
637
  this.currentBranch = result.toBranch!;
601
- await this.framework.syncDiscordAwarenessMarkers();
638
+ // Marks follow behind the data-plane gate; the undo doesn't wait for Discord.
639
+ this.framework.startDiscordAwarenessDelivery('undo', params.agentName);
602
640
  this.broadcast('branch:switched', {
603
641
  from: result.fromBranch,
604
642
  to: result.toBranch,
@@ -618,7 +656,8 @@ export class ApiServer {
618
656
 
619
657
  if (result.redone) {
620
658
  this.currentBranch = result.toBranch!;
621
- await this.framework.syncDiscordAwarenessMarkers();
659
+ // Marks follow behind the data-plane gate; the redo doesn't wait for Discord.
660
+ this.framework.startDiscordAwarenessDelivery('redo', params.agentName);
622
661
  this.broadcast('branch:switched', {
623
662
  from: result.fromBranch,
624
663
  to: result.toBranch,
@@ -10,7 +10,7 @@
10
10
  * This is a ROBUSTNESS boundary, not a security sandbox — same doctrine as
11
11
  * GateScript: the agent already has broader host access through its tools.
12
12
  * What this class guarantees is liveness: a wedged or runaway script cannot
13
- * hang the agent turn (cancel -> grace -> SIGKILL -> respawn) and a crashed
13
+ * hang the agent turn (cancel + SIGINT -> grace -> SIGKILL -> respawn) and a crashed
14
14
  * interpreter surfaces as a tool result, never as an unhandled rejection.
15
15
  */
16
16
 
@@ -65,6 +65,8 @@ export interface PyRunnerOptions {
65
65
  toolCallTimeoutMs?: number;
66
66
  /** Whole-script deadline; exceeded -> cancel, grace, kill. */
67
67
  scriptTimeoutMs?: number;
68
+ /** How long a script gets to stop after its deadline before the interpreter is killed (default 10s). */
69
+ cancelGraceMs?: number;
68
70
  /** Idle interpreter reclaim (state lost), mirroring container reclaim. */
69
71
  idleReclaimMs?: number;
70
72
  onToolCall: ScriptToolCallHandler;
@@ -76,6 +78,8 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 270_000;
76
78
  const DEFAULT_SCRIPT_TIMEOUT_MS = 600_000;
77
79
  const DEFAULT_IDLE_RECLAIM_MS = 300_000;
78
80
  const CANCEL_GRACE_MS = 10_000;
81
+ /** After the deadline, SIGINT is repeated at this interval until the script reports or is killed. */
82
+ const INTERRUPT_REPEAT_MS = 200;
79
83
 
80
84
  /** The longest delay Node's timers honour (~24.8 days); a longer one fires after ~1 ms. */
81
85
  export const MAX_TIMER_MS = 2_147_483_647;
@@ -92,6 +96,8 @@ interface PendingExec {
92
96
  resolve: (result: ExecResult) => void;
93
97
  deadlineTimer: ReturnType<typeof setTimeout> | null;
94
98
  killTimer: ReturnType<typeof setTimeout> | null;
99
+ /** Repeats the deadline SIGINT (see interruptChild). */
100
+ interruptTimer?: ReturnType<typeof setInterval>;
95
101
  settled: boolean;
96
102
  /** Set once the deadline fired: the result then says the script ran out of time. */
97
103
  deadlineMs: number | null;
@@ -101,6 +107,7 @@ export class PyRunner {
101
107
  private readonly pythonPath: string;
102
108
  private readonly toolCallTimeoutMs: number;
103
109
  private readonly scriptTimeoutMs: number;
110
+ private readonly cancelGraceMs: number;
104
111
  private readonly idleReclaimMs: number;
105
112
  private readonly onToolCall: ScriptToolCallHandler;
106
113
  private readonly label: string;
@@ -114,18 +121,21 @@ export class PyRunner {
114
121
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
115
122
  private execCounter = 0;
116
123
  private disposed = false;
124
+ /** An exec has passed the busy check but is still awaiting the interpreter. */
125
+ private starting = false;
117
126
 
118
127
  constructor(options: PyRunnerOptions) {
119
128
  this.pythonPath = options.pythonPath ?? 'python3';
120
129
  this.toolCallTimeoutMs = options.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS;
121
130
  this.scriptTimeoutMs = options.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS;
131
+ this.cancelGraceMs = options.cancelGraceMs ?? CANCEL_GRACE_MS;
122
132
  this.idleReclaimMs = options.idleReclaimMs ?? DEFAULT_IDLE_RECLAIM_MS;
123
133
  this.onToolCall = options.onToolCall;
124
134
  this.label = options.label ?? 'pytc';
125
135
  }
126
136
 
127
137
  get busy(): boolean {
128
- return this.pending !== null;
138
+ return this.pending !== null || this.starting;
129
139
  }
130
140
 
131
141
  /**
@@ -150,7 +160,7 @@ export class PyRunner {
150
160
  if (this.disposed) {
151
161
  return { stdout: '', stderr: 'code_execution runner disposed', returnCode: 1, aborted: true };
152
162
  }
153
- if (this.pending) {
163
+ if (this.busy) {
154
164
  return {
155
165
  stdout: '',
156
166
  stderr: 'RuntimeError: another code_execution script is already running for this agent',
@@ -160,6 +170,7 @@ export class PyRunner {
160
170
  }
161
171
  this.clearIdleTimer();
162
172
 
173
+ this.starting = true; // claimed before the await: a same-tick second exec must see it
163
174
  try {
164
175
  await this.ensureChild();
165
176
  } catch (err) {
@@ -171,6 +182,8 @@ export class PyRunner {
171
182
  returnCode: 1,
172
183
  aborted: true,
173
184
  };
185
+ } finally {
186
+ this.starting = false; // `pending` is set below, with no await in between
174
187
  }
175
188
 
176
189
  const execId = `e${++this.execCounter}`;
@@ -193,16 +206,26 @@ export class PyRunner {
193
206
  // Deadline: ask politely first (script sees CancelledError and its
194
207
  // exec_result still flows back), then kill on unresponsiveness.
195
208
  this.send({ op: 'cancel', id: execId, reason: 'deadline' });
209
+ // The cancel lands only when the script awaits. Blocking code (time.sleep, a
210
+ // busy loop, a blocking read) never does, so interrupt it as well: the runtime
211
+ // raises KeyboardInterrupt in the script's own code, or schedules the
212
+ // cancellation of a script that is waiting. Repeated, because a waiting script
213
+ // can resume into blocking code before that cancellation runs; the runtime
214
+ // ignores the repeats once the script was interrupted or cancelled.
215
+ this.interruptChild();
216
+ pending.interruptTimer = setInterval(() => this.interruptChild(), INTERRUPT_REPEAT_MS);
196
217
  pending.killTimer = setTimeout(() => {
197
218
  pending.deadlineMs = null; // this message already says why
198
219
  this.settlePending({
199
220
  stdout: '',
200
- stderr: `script killed after exceeding ${Math.round(deadlineMs / 1000)}s deadline`,
221
+ stderr:
222
+ `script stopped: it reached its ${formatLimit(deadlineMs)} time limit and did not respond, ` +
223
+ 'so it was killed and the interpreter restarted (variables from earlier scripts are gone)',
201
224
  returnCode: 1,
202
225
  aborted: true,
203
226
  });
204
227
  this.reclaim('deadline-kill');
205
- }, CANCEL_GRACE_MS);
228
+ }, this.cancelGraceMs);
206
229
  }, deadlineMs);
207
230
  // A day-scale background deadline must not hold the process open.
208
231
  if (background) pending.deadlineTimer.unref?.();
@@ -299,7 +322,7 @@ export class PyRunner {
299
322
  });
300
323
 
301
324
  this.reader = createInterface({ input: child.stdout });
302
- this.reader.on('line', (line) => this.handleLine(line));
325
+ this.reader.on('line', (line) => this.handleLine(line, child));
303
326
 
304
327
  this.childReady = new Promise<void>((resolve, reject) => {
305
328
  const onReady = () => {
@@ -333,7 +356,7 @@ export class PyRunner {
333
356
 
334
357
  private readyResolver: (() => void) | null = null;
335
358
 
336
- private handleLine(line: string): void {
359
+ private handleLine(line: string, child: ChildProcessWithoutNullStreams): void {
337
360
  let msg: { op?: string; id?: string; name?: string; args?: unknown; stdout?: string; stderr?: string; return_code?: number };
338
361
  try {
339
362
  msg = JSON.parse(line);
@@ -358,7 +381,7 @@ export class PyRunner {
358
381
  this.onToolCall(toolName, args)
359
382
  .catch((err) => `Error: ${err instanceof Error ? err.message : String(err)}`)
360
383
  .then((result) => {
361
- this.send({ op: 'tool_result', id: callId, result });
384
+ this.reply(child, { op: 'tool_result', id: callId, result }, `result of ${toolName} call ${callId}`);
362
385
  });
363
386
  return;
364
387
  }
@@ -376,7 +399,7 @@ export class PyRunner {
376
399
  `wake handler failed: ${err instanceof Error ? err.message : String(err)}`)
377
400
  : Promise.resolve('this script is not allowed to wake the agent');
378
401
  void refuse.then((error) => {
379
- this.send({ op: 'wake_ack', id: wakeId, ...(error ? { error } : {}) });
402
+ this.reply(child, { op: 'wake_ack', id: wakeId, ...(error ? { error } : {}) }, `ack of wake ${wakeId}`);
380
403
  });
381
404
  return;
382
405
  }
@@ -414,10 +437,25 @@ export class PyRunner {
414
437
  }
415
438
  if (pending.deadlineTimer) clearTimeout(pending.deadlineTimer);
416
439
  if (pending.killTimer) clearTimeout(pending.killTimer);
440
+ if (pending.interruptTimer) clearInterval(pending.interruptTimer);
417
441
  this.pending = null;
418
442
  pending.resolve(result);
419
443
  }
420
444
 
445
+ /**
446
+ * Answer the interpreter that asked. Call and wake ids restart in every
447
+ * interpreter, so an answer that outlives its interpreter (the script was
448
+ * aborted, killed or crashed, or the interpreter reclaimed) would resolve
449
+ * the same id in the one that replaced it, inside another script: drop it.
450
+ */
451
+ private reply(child: ChildProcessWithoutNullStreams, msg: unknown, what: string): void {
452
+ if (child !== this.child) {
453
+ console.error(`[pytc:${this.label}] dropped late ${what}: the interpreter that asked for it is gone`);
454
+ return;
455
+ }
456
+ this.send(msg);
457
+ }
458
+
421
459
  private send(obj: unknown): void {
422
460
  const child = this.child;
423
461
  if (!child || child.exitCode !== null || child.killed) return;
@@ -430,6 +468,25 @@ export class PyRunner {
430
468
  }
431
469
  }
432
470
 
471
+ /**
472
+ * SIGINT the interpreter: blocking code in the running script raises
473
+ * KeyboardInterrupt. Not on Windows, where Node's kill() ends the process
474
+ * outright; there the cancel op and the kill grace remain.
475
+ * process.kill rather than child.kill, which would mark the child `killed`:
476
+ * a killed child gets no more protocol messages and is respawned on the
477
+ * next exec, losing the interpreter state this keeps.
478
+ */
479
+ private interruptChild(): void {
480
+ const child = this.child;
481
+ if (process.platform === 'win32' || !child || child.exitCode !== null || child.killed) return;
482
+ if (child.pid === undefined) return;
483
+ try {
484
+ process.kill(child.pid, 'SIGINT');
485
+ } catch {
486
+ // already gone: the exit handler settles the exec
487
+ }
488
+ }
489
+
433
490
  /** Kill interpreter + clean temp dir. State is lost (by design). */
434
491
  private reclaim(reason: string): void {
435
492
  if (this.child) {