@animalabs/agent-framework 0.21.0 → 0.22.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 (271) hide show
  1. package/README.md +43 -0
  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 +102 -42
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/py-runner.d.ts +22 -1
  7. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.js +64 -9
  9. package/dist/src/code-execution/py-runner.js.map +1 -1
  10. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  11. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  12. package/dist/src/code-execution/runtime-py.js +215 -38
  13. package/dist/src/code-execution/runtime-py.js.map +1 -1
  14. package/dist/src/framework.d.ts +250 -39
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +1602 -371
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts +21 -0
  19. package/dist/src/gate/event-gate.d.ts.map +1 -1
  20. package/dist/src/gate/event-gate.js +40 -7
  21. package/dist/src/gate/event-gate.js.map +1 -1
  22. package/dist/src/index.d.ts +5 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +26 -3
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  30. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  31. package/dist/src/mcpl/conversation-router.js +17 -0
  32. package/dist/src/mcpl/conversation-router.js.map +1 -1
  33. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  34. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  35. package/dist/src/mcpl/feature-set-manager.js +31 -7
  36. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  37. package/dist/src/mcpl/index.d.ts +2 -0
  38. package/dist/src/mcpl/index.d.ts.map +1 -1
  39. package/dist/src/mcpl/index.js +1 -0
  40. package/dist/src/mcpl/index.js.map +1 -1
  41. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  42. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  43. package/dist/src/mcpl/prose-stream-router.js +85 -22
  44. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  45. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  46. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  47. package/dist/src/mcpl/push-coalescer.js +46 -13
  48. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  49. package/dist/src/mcpl/push-handler.d.ts +5 -3
  50. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-handler.js +41 -11
  52. package/dist/src/mcpl/push-handler.js.map +1 -1
  53. package/dist/src/mcpl/server-connection.d.ts +4 -0
  54. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  55. package/dist/src/mcpl/server-connection.js +45 -8
  56. package/dist/src/mcpl/server-connection.js.map +1 -1
  57. package/dist/src/mcpl/server-registry.d.ts +5 -2
  58. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-registry.js +25 -7
  60. package/dist/src/mcpl/server-registry.js.map +1 -1
  61. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  62. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  63. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  64. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  65. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  66. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  67. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  68. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  69. package/dist/src/mcpl/types.d.ts +15 -5
  70. package/dist/src/mcpl/types.d.ts.map +1 -1
  71. package/dist/src/mcpl/types.js.map +1 -1
  72. package/dist/src/mcpl/visible-content.d.ts +39 -0
  73. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  74. package/dist/src/mcpl/visible-content.js +54 -0
  75. package/dist/src/mcpl/visible-content.js.map +1 -0
  76. package/dist/src/module-registry.d.ts +81 -3
  77. package/dist/src/module-registry.d.ts.map +1 -1
  78. package/dist/src/module-registry.js +257 -16
  79. package/dist/src/module-registry.js.map +1 -1
  80. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  81. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  82. package/dist/src/modules/workspace/branch-intent.js +83 -0
  83. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  84. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  85. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  86. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  87. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  88. package/dist/src/modules/workspace/effects.d.ts +84 -0
  89. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  90. package/dist/src/modules/workspace/effects.js +368 -0
  91. package/dist/src/modules/workspace/effects.js.map +1 -0
  92. package/dist/src/modules/workspace/index.d.ts +155 -17
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +1059 -424
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/observe.d.ts +278 -0
  97. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  98. package/dist/src/modules/workspace/observe.js +805 -0
  99. package/dist/src/modules/workspace/observe.js.map +1 -0
  100. package/dist/src/modules/workspace/reconcile.d.ts +224 -0
  101. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  102. package/dist/src/modules/workspace/reconcile.js +901 -0
  103. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  104. package/dist/src/modules/workspace/sync.d.ts +6 -59
  105. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/sync.js +6 -257
  107. package/dist/src/modules/workspace/sync.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +77 -10
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/record-journal.d.ts +127 -0
  112. package/dist/src/record-journal.d.ts.map +1 -0
  113. package/dist/src/record-journal.js +202 -0
  114. package/dist/src/record-journal.js.map +1 -0
  115. package/dist/src/shutdown.d.ts +13 -0
  116. package/dist/src/shutdown.d.ts.map +1 -0
  117. package/dist/src/shutdown.js +46 -0
  118. package/dist/src/shutdown.js.map +1 -0
  119. package/dist/src/silent-heartbeat.d.ts +74 -0
  120. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  121. package/dist/src/silent-heartbeat.js +77 -0
  122. package/dist/src/silent-heartbeat.js.map +1 -0
  123. package/dist/src/tool-presentation.d.ts +50 -0
  124. package/dist/src/tool-presentation.d.ts.map +1 -0
  125. package/dist/src/tool-presentation.js +235 -0
  126. package/dist/src/tool-presentation.js.map +1 -0
  127. package/dist/src/tool-result-guard.d.ts +125 -6
  128. package/dist/src/tool-result-guard.d.ts.map +1 -1
  129. package/dist/src/tool-result-guard.js +310 -35
  130. package/dist/src/tool-result-guard.js.map +1 -1
  131. package/dist/src/tool-result-history.d.ts +13 -0
  132. package/dist/src/tool-result-history.d.ts.map +1 -1
  133. package/dist/src/tool-result-history.js +21 -1
  134. package/dist/src/tool-result-history.js.map +1 -1
  135. package/dist/src/types/agent.d.ts +18 -2
  136. package/dist/src/types/agent.d.ts.map +1 -1
  137. package/dist/src/types/events.d.ts +4 -0
  138. package/dist/src/types/events.d.ts.map +1 -1
  139. package/dist/src/types/framework.d.ts +12 -0
  140. package/dist/src/types/framework.d.ts.map +1 -1
  141. package/dist/src/types/module.d.ts +14 -0
  142. package/dist/src/types/module.d.ts.map +1 -1
  143. package/dist/src/types/trace.d.ts +73 -0
  144. package/dist/src/types/trace.d.ts.map +1 -1
  145. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  146. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  147. package/dist/test/batched-wake-locus.test.js +166 -0
  148. package/dist/test/batched-wake-locus.test.js.map +1 -0
  149. package/dist/test/code-execution.test.js +386 -2
  150. package/dist/test/code-execution.test.js.map +1 -1
  151. package/dist/test/component-defaults.test.d.ts +2 -0
  152. package/dist/test/component-defaults.test.d.ts.map +1 -0
  153. package/dist/test/component-defaults.test.js +54 -0
  154. package/dist/test/component-defaults.test.js.map +1 -0
  155. package/dist/test/conversation-routing.test.js +57 -1
  156. package/dist/test/conversation-routing.test.js.map +1 -1
  157. package/dist/test/ephemeral-settle.test.js +3 -0
  158. package/dist/test/ephemeral-settle.test.js.map +1 -1
  159. package/dist/test/event-gate.test.js +118 -0
  160. package/dist/test/event-gate.test.js.map +1 -1
  161. package/dist/test/explicit-prose-routing.test.js +977 -0
  162. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  163. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  164. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  165. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  166. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  167. package/dist/test/mcpl-coalescing.test.js +19 -6
  168. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  169. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  170. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  171. package/dist/test/mcpl-config-diagnostics.test.js +449 -0
  172. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  173. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  174. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  175. package/dist/test/mcpl-empty-content.test.js +368 -0
  176. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  177. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  178. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  179. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  180. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  181. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  182. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  183. package/dist/test/prose-stream-router.test.js +41 -0
  184. package/dist/test/prose-stream-router.test.js.map +1 -1
  185. package/dist/test/record-journal.test.d.ts +6 -0
  186. package/dist/test/record-journal.test.d.ts.map +1 -0
  187. package/dist/test/record-journal.test.js +269 -0
  188. package/dist/test/record-journal.test.js.map +1 -0
  189. package/dist/test/save-recent-image.test.js +102 -0
  190. package/dist/test/save-recent-image.test.js.map +1 -1
  191. package/dist/test/shutdown-errors.test.d.ts +2 -0
  192. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  193. package/dist/test/shutdown-errors.test.js +662 -0
  194. package/dist/test/shutdown-errors.test.js.map +1 -0
  195. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  196. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  197. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  198. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  199. package/dist/test/silent-heartbeat.test.js +49 -38
  200. package/dist/test/silent-heartbeat.test.js.map +1 -1
  201. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  202. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  203. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  204. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  205. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  206. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  207. package/dist/test/tool-batch-hook.test.js +275 -0
  208. package/dist/test/tool-batch-hook.test.js.map +1 -0
  209. package/dist/test/tool-presentation.test.d.ts +2 -0
  210. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  211. package/dist/test/tool-presentation.test.js +268 -0
  212. package/dist/test/tool-presentation.test.js.map +1 -0
  213. package/dist/test/tool-result-guard.test.js +2330 -18
  214. package/dist/test/tool-result-guard.test.js.map +1 -1
  215. package/dist/test/tool-result-history.test.js +2 -1
  216. package/dist/test/tool-result-history.test.js.map +1 -1
  217. package/dist/test/turn-trigger-provenance.test.js +35 -0
  218. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  219. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  220. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  221. package/dist/test/unconvertible-tool-result.test.js +358 -0
  222. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  223. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  224. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  225. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  226. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  227. package/dist/test/workspace-three-way.test.d.ts +12 -0
  228. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  229. package/dist/test/workspace-three-way.test.js +3079 -0
  230. package/dist/test/workspace-three-way.test.js.map +1 -0
  231. package/dist/tsconfig.tsbuildinfo +1 -1
  232. package/package.json +3 -3
  233. package/src/agent.ts +120 -44
  234. package/src/code-execution/py-runner.ts +66 -9
  235. package/src/code-execution/runtime-py.ts +215 -38
  236. package/src/framework.ts +1661 -363
  237. package/src/gate/event-gate.ts +59 -7
  238. package/src/index.ts +11 -0
  239. package/src/mcpl/channel-registry.ts +24 -3
  240. package/src/mcpl/conversation-router.ts +34 -1
  241. package/src/mcpl/feature-set-manager.ts +36 -11
  242. package/src/mcpl/index.ts +2 -0
  243. package/src/mcpl/prose-stream-router.ts +79 -23
  244. package/src/mcpl/push-coalescer.ts +62 -13
  245. package/src/mcpl/push-handler.ts +41 -12
  246. package/src/mcpl/server-connection.ts +41 -10
  247. package/src/mcpl/server-registry.ts +24 -7
  248. package/src/mcpl/tool-lifecycle.ts +77 -2
  249. package/src/mcpl/tool-pattern-check.ts +106 -0
  250. package/src/mcpl/types.ts +15 -5
  251. package/src/mcpl/visible-content.ts +57 -0
  252. package/src/module-registry.ts +252 -16
  253. package/src/modules/workspace/branch-intent.ts +123 -0
  254. package/src/modules/workspace/disk-agreement.ts +325 -0
  255. package/src/modules/workspace/effects.ts +362 -0
  256. package/src/modules/workspace/index.ts +1058 -446
  257. package/src/modules/workspace/observe.ts +839 -0
  258. package/src/modules/workspace/reconcile.ts +1136 -0
  259. package/src/modules/workspace/sync.ts +6 -336
  260. package/src/modules/workspace/types.ts +75 -10
  261. package/src/record-journal.ts +246 -0
  262. package/src/shutdown.ts +49 -0
  263. package/src/silent-heartbeat.ts +141 -0
  264. package/src/tool-presentation.ts +188 -0
  265. package/src/tool-result-guard.ts +335 -33
  266. package/src/tool-result-history.ts +25 -1
  267. package/src/types/agent.ts +18 -2
  268. package/src/types/events.ts +4 -0
  269. package/src/types/framework.ts +12 -0
  270. package/src/types/module.ts +15 -0
  271. package/src/types/trace.ts +80 -0
@@ -37,6 +37,10 @@
37
37
  * {op:"wake", id, exec_id, line, payload}
38
38
  * {op:"exec_result", id, stdout, stderr, return_code, tail?}
39
39
  *
40
+ * At a deadline the host sends cancel and then SIGINT, repeated until the
41
+ * script reports (not on Windows): the cancel stops a script waiting on an
42
+ * await, the signal also one stuck in blocking code (see _on_sigint).
43
+ *
40
44
  * IMPORTANT: the python source below must not contain backticks or the
41
45
  * sequence dollar+brace (TS template literal syntax). String.raw preserves
42
46
  * backslashes, so \n inside python string literals is fine.
@@ -49,6 +53,7 @@ import inspect
49
53
  import io
50
54
  import json
51
55
  import os
56
+ import signal
52
57
  import sys
53
58
  import threading
54
59
  import traceback
@@ -169,6 +174,90 @@ _next_wake_id = 0
169
174
  _current_exec_id = None
170
175
  _current_exec_task = None
171
176
 
177
+ # How the host stops a script (its time limit, or an abort): ONE per-script
178
+ # state, so the script is stopped exactly once, by whichever path gets there
179
+ # first. Every other path then does nothing to that script.
180
+ # PENDING -> nothing asked yet.
181
+ # REQUESTED -> asked while the script was outside its own code (waiting on
182
+ # an await, writing a protocol line, or not started yet). A
183
+ # cancellation is queued on the loop, or delivered as the
184
+ # script starts.
185
+ # DELIVERED -> KeyboardInterrupt raised in its code, or its task cancelled.
186
+ # Final: whatever the script does next (except/finally, awaits
187
+ # in its cleanup) runs to completion or to the host's kill.
188
+ # Transitions happen only in the main thread: in the SIGINT handler (between
189
+ # bytecodes, so it never cancels anything itself -- see _on_sigint) and in
190
+ # loop callbacks. Neither can interleave a transition of the other halfway
191
+ # in a way that matters: the handler only moves PENDING to REQUESTED or, with
192
+ # the script's own frame on the stack, to DELIVERED; a loop callback never
193
+ # runs while the script's frame is on the stack.
194
+ #
195
+ # Where the signal (or the cancel op) can land, and what happens:
196
+ # 1. script's own code, running or blocking -> KeyboardInterrupt there
197
+ # 2. script waiting on an await (loop idle) -> cancellation queued; it wakes
198
+ # the loop and lands at that await
199
+ # 3. main() mid-dispatch, between the done() check of a future the script
200
+ # awaits and its set_result (reply behind the cancellation) -> queued;
201
+ # set_result completes; the script gets CancelledError instead of it
202
+ # 4. the script writing a protocol line (send) -> queued; the line stays
203
+ # whole; the cancellation lands at the script's next await
204
+ # 5. reply ahead of the queued cancellation, script then awaits -> the
205
+ # cancellation lands at that await
206
+ # 6. reply ahead, script then blocks -> the next signal (the host repeats
207
+ # it) raises KeyboardInterrupt there; the queued cancellation then does
208
+ # nothing, so cleanup that awaits is not cut short
209
+ # 7. reply ahead, script finishes before the queued cancellation runs ->
210
+ # it does nothing, and never reaches the next script
211
+ # 8. script not started yet (signal, or the cancel op) -> delivered as
212
+ # CancelledError when it starts, so it still reports
213
+ # 9. cancel op before the signal -> delivered; the signal and its repeats
214
+ # do nothing, so cleanup after a caught CancelledError runs on
215
+ # 10. cancel op or repeated signal after delivery -> nothing
216
+ # 11. script finished, reporting its result -> nothing
217
+ # 12. between scripts -> nothing
218
+ # 13. a previous script installed an eager task factory -> no effect: the
219
+ # runtime builds its script task directly, and the state is in place
220
+ # before the script's first step
221
+ _PENDING = "pending"
222
+ _REQUESTED = "requested"
223
+ _DELIVERED = "delivered"
224
+
225
+
226
+ class _ScriptStop:
227
+ __slots__ = ("task", "running", "state", "interrupt")
228
+
229
+ def __init__(self):
230
+ self.task = None
231
+ self.running = False # the script's own code may be on the stack
232
+ self.state = _PENDING
233
+ self.interrupt = None # the KeyboardInterrupt raised in its code
234
+
235
+
236
+ _stop = None # the running script's _ScriptStop; None between scripts
237
+
238
+
239
+ def _deliver_cancel(st):
240
+ # Loop context only: the cancel op in main()'s dispatch, or queued by the
241
+ # SIGINT handler. Never from inside the handler, where a cancellation
242
+ # could land between main()'s done() check of a future the script awaits
243
+ # and its set_result.
244
+ if st is None or st is not _stop or st.state == _DELIVERED:
245
+ return
246
+ if not st.running:
247
+ st.state = _REQUESTED # not started yet: delivered as it starts
248
+ return
249
+ st.state = _DELIVERED
250
+ st.task.cancel()
251
+
252
+
253
+ def _resolve(fut, value):
254
+ # Defense in depth: the script may have been cancelled since the lookup.
255
+ if fut is not None and not fut.done():
256
+ try:
257
+ fut.set_result(value)
258
+ except asyncio.InvalidStateError:
259
+ pass
260
+
172
261
 
173
262
  def _make_tool_fn(tool_name, py_name):
174
263
  async def tool_fn(args=None):
@@ -279,8 +368,8 @@ def handle_init(msg):
279
368
  SCRIPT_GLOBALS.pop("wake_agent", None)
280
369
 
281
370
 
282
- async def _run_script(exec_id, code):
283
- global _current_exec_id, _current_exec_task
371
+ async def _run_script(exec_id, code, st):
372
+ global _current_exec_id, _current_exec_task, _stop
284
373
  if BACKGROUND:
285
374
  out = LogTee(LOG_PATH, TAIL_CHARS)
286
375
  err = out # interleave, terminal-style; tail is shared
@@ -293,17 +382,38 @@ async def _run_script(exec_id, code):
293
382
  return_code = 0
294
383
  try:
295
384
  compiled = compile(code, "<script>", "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)
296
- result = eval(compiled, SCRIPT_GLOBALS)
297
- if inspect.iscoroutine(result):
298
- await result
385
+ st.running = True
386
+ try:
387
+ if st.state == _REQUESTED:
388
+ # Stopped before it started (row 8).
389
+ st.state = _DELIVERED
390
+ raise asyncio.CancelledError()
391
+ result = eval(compiled, SCRIPT_GLOBALS)
392
+ if inspect.iscoroutine(result):
393
+ await result
394
+ finally:
395
+ st.running = False
299
396
  except asyncio.CancelledError:
300
397
  err.write("\nKeyboardInterrupt: script cancelled by host\n")
301
398
  return_code = 1
302
- except BaseException:
303
- traceback.print_exc(file=err)
399
+ except BaseException as exc:
400
+ if exc is st.interrupt:
401
+ # Where the script was when it was stopped, without the runtime's frames.
402
+ frames = [
403
+ f for f in traceback.extract_tb(exc.__traceback__)
404
+ if f.filename != _RUN_SCRIPT_CODE.co_filename
405
+ ]
406
+ err.write("Traceback (most recent call last):\n")
407
+ err.write("".join(traceback.format_list(frames)))
408
+ err.write("KeyboardInterrupt: script interrupted by host\n")
409
+ else:
410
+ traceback.print_exc(file=err)
304
411
  return_code = 1
305
412
  finally:
306
413
  sys.stdout, sys.stderr, sys.stdin = old_out, old_err, old_in
414
+ st.interrupt = None
415
+ if _stop is st:
416
+ _stop = None
307
417
  _current_exec_id = None
308
418
  _current_exec_task = None
309
419
  for fut in list(_pending_tool_futures.values()):
@@ -343,8 +453,47 @@ async def _run_script(exec_id, code):
343
453
  })
344
454
 
345
455
 
456
+ def _dispatch(msg):
457
+ """Handle one protocol message from the host; False means stop reading."""
458
+ global _current_exec_id, _current_exec_task, _stop
459
+ op = msg.get("op")
460
+ if op == "init":
461
+ handle_init(msg)
462
+ elif op == "exec":
463
+ if _current_exec_task is not None and not _current_exec_task.done():
464
+ send({
465
+ "op": "exec_result",
466
+ "id": msg.get("id"),
467
+ "stdout": "",
468
+ "stderr": "RuntimeError: another script is already running in this interpreter",
469
+ "return_code": 1,
470
+ })
471
+ return True
472
+ # Everything _on_sigint reads is in place before the script can run
473
+ # (row 13). The task is built directly, not through the loop's task
474
+ # factory: a script may have installed one (interpreter state
475
+ # persists), and an eager one would run the next script inside this
476
+ # call, before the assignments below.
477
+ st = _ScriptStop()
478
+ _stop = st
479
+ _current_exec_id = msg.get("id")
480
+ _current_exec_task = asyncio.Task(
481
+ _run_script(msg.get("id"), msg.get("code") or "", st),
482
+ loop=asyncio.get_running_loop(),
483
+ )
484
+ st.task = _current_exec_task
485
+ elif op == "tool_result":
486
+ _resolve(_pending_tool_futures.get(msg.get("id")), str(msg.get("result", "")))
487
+ elif op == "wake_ack":
488
+ _resolve(_pending_wake_futures.get(msg.get("id")), msg.get("error") or None)
489
+ elif op == "cancel":
490
+ _deliver_cancel(_stop)
491
+ elif op == "exit":
492
+ return False
493
+ return True
494
+
495
+
346
496
  async def main():
347
- global _current_exec_id, _current_exec_task
348
497
  loop = asyncio.get_running_loop()
349
498
  queue = asyncio.Queue()
350
499
 
@@ -353,7 +502,17 @@ async def main():
353
502
  loop.call_soon_threadsafe(queue.put_nowait, raw_line)
354
503
  loop.call_soon_threadsafe(queue.put_nowait, None)
355
504
 
356
- threading.Thread(target=reader, daemon=True).start()
505
+ # The reader thread blocks SIGINT (it inherits the mask while it starts),
506
+ # so the signal reaches the main thread, where a blocking call in the
507
+ # script is waiting to be interrupted.
508
+ masked = hasattr(signal, "pthread_sigmask") and hasattr(signal, "SIGINT")
509
+ if masked:
510
+ previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT})
511
+ try:
512
+ threading.Thread(target=reader, daemon=True).start()
513
+ finally:
514
+ if masked:
515
+ signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
357
516
  send({"op": "ready"})
358
517
 
359
518
  while True:
@@ -369,35 +528,7 @@ async def main():
369
528
  REAL_STDERR.write("[pytc-runtime] bad protocol line\n")
370
529
  REAL_STDERR.flush()
371
530
  continue
372
- op = msg.get("op")
373
- if op == "init":
374
- handle_init(msg)
375
- elif op == "exec":
376
- if _current_exec_task is not None and not _current_exec_task.done():
377
- send({
378
- "op": "exec_result",
379
- "id": msg.get("id"),
380
- "stdout": "",
381
- "stderr": "RuntimeError: another script is already running in this interpreter",
382
- "return_code": 1,
383
- })
384
- continue
385
- _current_exec_id = msg.get("id")
386
- _current_exec_task = asyncio.ensure_future(
387
- _run_script(msg.get("id"), msg.get("code") or "")
388
- )
389
- elif op == "tool_result":
390
- fut = _pending_tool_futures.get(msg.get("id"))
391
- if fut is not None and not fut.done():
392
- fut.set_result(str(msg.get("result", "")))
393
- elif op == "wake_ack":
394
- fut = _pending_wake_futures.get(msg.get("id"))
395
- if fut is not None and not fut.done():
396
- fut.set_result(msg.get("error") or None)
397
- elif op == "cancel":
398
- if _current_exec_task is not None and not _current_exec_task.done():
399
- _current_exec_task.cancel()
400
- elif op == "exit":
531
+ if not _dispatch(msg):
401
532
  break
402
533
 
403
534
  # Drain: give a cancelled exec a moment to emit its exec_result.
@@ -409,6 +540,52 @@ async def main():
409
540
  pass
410
541
 
411
542
 
543
+ # At the deadline the host sends a cancel op, which lands only when the script
544
+ # awaits: a script blocked in time.sleep(), a busy loop or a blocking read
545
+ # never gives the event loop control back. So the host also sends SIGINT,
546
+ # repeated until the script reports, and this stops the script through its
547
+ # _ScriptStop (see the table there):
548
+ # - With the script's own code on the stack (_run_script's frame, reached
549
+ # before any send frame), it raises KeyboardInterrupt there; _run_script
550
+ # catches it, so the output so far and the interpreter's globals survive.
551
+ # Checking the current task instead is not enough: asyncio runs its own
552
+ # code for the task between steps, and an exception raised there escapes
553
+ # the event loop and ends the interpreter.
554
+ # - Anywhere else it only queues the cancellation on the loop (which also
555
+ # wakes select(), otherwise resuming its wait after a signal): cancelling
556
+ # here could cancel a future between main()'s done() check and its
557
+ # set_result, and an exception mid-send would tear a protocol line.
558
+ _RUN_SCRIPT_CODE = _run_script.__code__
559
+ _SEND_CODE = send.__code__
560
+
561
+
562
+ def _on_sigint(signum, frame):
563
+ st = _stop
564
+ if st is None or st.state == _DELIVERED:
565
+ return
566
+ if st.running:
567
+ while frame is not None:
568
+ code = frame.f_code
569
+ if code is _SEND_CODE:
570
+ break
571
+ if code is _RUN_SCRIPT_CODE:
572
+ st.state = _DELIVERED
573
+ st.interrupt = KeyboardInterrupt("script interrupted by host")
574
+ raise st.interrupt
575
+ frame = frame.f_back
576
+ if st.state == _PENDING:
577
+ st.state = _REQUESTED
578
+ if st.running:
579
+ st.task.get_loop().call_soon_threadsafe(_deliver_cancel, st)
580
+
581
+
412
582
  if __name__ == "__main__":
583
+ # A handler of our own also keeps asyncio.run from installing one that
584
+ # would cancel main() and end the interpreter.
585
+ if hasattr(signal, "SIGINT"):
586
+ try:
587
+ signal.signal(signal.SIGINT, _on_sigint)
588
+ except (ValueError, OSError):
589
+ pass
413
590
  asyncio.run(main())
414
591
  `;