@animalabs/agent-framework 0.21.1 → 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 +2 -2
  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
package/src/mcpl/types.ts CHANGED
@@ -282,10 +282,16 @@ export interface McplServerConfig {
282
282
  */
283
283
  allowHostCommands?: boolean;
284
284
 
285
- /** Feature sets to enable on connect */
285
+ /**
286
+ * Feature sets to enable on connect (`.`-segment wildcards). Omitted:
287
+ * every declared set is a candidate. `[]`: none. Either way a set is
288
+ * enabled only if its declared `uses` are recognized capabilities the
289
+ * grant covers (§6.4) — a set declaring no `uses` stays disabled even when
290
+ * listed here (logged, and traced as `mcpl:feature-set-disabled`).
291
+ */
286
292
  enabledFeatureSets?: string[];
287
293
 
288
- /** Feature sets to explicitly disable on connect */
294
+ /** Feature sets to explicitly disable on connect (wins over enabledFeatureSets). */
289
295
  disabledFeatureSets?: string[];
290
296
 
291
297
  /**
@@ -388,11 +394,15 @@ export interface McplServerConfig {
388
394
 
389
395
  /**
390
396
  * Tool name prefix for this server's tools.
391
- * Tools are exposed as `{toolPrefix}:{toolName}`.
397
+ * Tools are exposed as `{toolPrefix}--{toolName}`.
392
398
  *
393
- * Default: `mcpl:{id}` (e.g., `mcpl:zk:lobby_start_game`).
399
+ * Default: `mcpl--{id}` (e.g., `mcpl--zk--lobby_start_game`).
394
400
  * Set to the server ID for cleaner names (e.g., `toolPrefix: 'zk'`
395
- * gives `zk:lobby_start_game`).
401
+ * gives `zk--lobby_start_game`).
402
+ *
403
+ * Tool-name patterns elsewhere (`toolClassOverrides` keys,
404
+ * `toolLifecycle.*.tools`) match these full names; `enabledTools` /
405
+ * `disabledTools` use the bare server-side names.
396
406
  *
397
407
  * Must not collide with any module name or another server's prefix.
398
408
  */
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Visibly-empty MCPL content, and the one empty push that is allowed.
3
+ *
4
+ * A push event or channel message whose content gives the model nothing to
5
+ * read cannot be shown: the request sanitizer strips empty text blocks and
6
+ * drops the message they leave empty, so a wake queued for it presents the
7
+ * model with `[Continue]` or with an older message re-presented as the newest.
8
+ * That is a wake with no visible cause, and it invites the model to invent
9
+ * one. Such content is rejected at the MCPL boundary, and the store/wake site
10
+ * drops it as a backstop.
11
+ *
12
+ * The exception is the silent-heartbeat marker: the heartbeat server's own
13
+ * scheduled tick, which wakes with ephemeral host-supplied context instead of
14
+ * a stored message.
15
+ */
16
+
17
+ /**
18
+ * True when `content` holds nothing the model would see: no non-text block,
19
+ * and no text block whose text has a non-whitespace character. Mirrors the
20
+ * request sanitizer, which drops text that is not a string or trims to ''.
21
+ * Accepts MCPL wire blocks and converted membrane blocks alike.
22
+ */
23
+ export function isVisiblyEmptyContent(content: readonly unknown[] | null | undefined): boolean {
24
+ if (!Array.isArray(content)) return true;
25
+ for (const block of content) {
26
+ if (!block || typeof block !== 'object') continue;
27
+ const b = block as { type?: unknown; text?: unknown };
28
+ if (b.type !== 'text') return false;
29
+ if (typeof b.text === 'string' && b.text.trim() !== '') return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ /** The server id and feature set the marker must carry. */
35
+ export const SILENT_HEARTBEAT_SERVER_ID = 'heartbeat';
36
+ export const SILENT_HEARTBEAT_FEATURE_SET = 'heartbeat';
37
+
38
+ /**
39
+ * The exact silent-heartbeat marker: an empty push from the `heartbeat`
40
+ * server on its `heartbeat` feature set, with `origin.source === 'heartbeat'`
41
+ * and `origin.silent === true`. Anything else that is empty is not a silent
42
+ * tick — arbitrary servers cannot claim the silent path by setting
43
+ * `origin.silent`, and a marker that carries content is ordinary content.
44
+ */
45
+ export function isSilentHeartbeatMarker(event: {
46
+ serverId: string;
47
+ featureSet?: string;
48
+ origin?: Record<string, unknown> | null;
49
+ content: readonly unknown[] | null | undefined;
50
+ }): boolean {
51
+ return event.serverId === SILENT_HEARTBEAT_SERVER_ID
52
+ && event.featureSet === SILENT_HEARTBEAT_FEATURE_SET
53
+ && event.origin?.source === 'heartbeat'
54
+ && event.origin?.silent === true
55
+ && Array.isArray(event.content)
56
+ && event.content.length === 0;
57
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  TraceEventListener,
25
25
  } from './types/index.js';
26
26
  import type { Agent } from './agent.js';
27
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from './shutdown.js';
27
28
 
28
29
  const MODULE_STATE_PREFIX = 'modules/';
29
30
 
@@ -46,6 +47,15 @@ export function isStateExistsError(err: unknown): boolean {
46
47
  */
47
48
  const MAX_CONTEXT_TIMEOUT_MS = 30_000;
48
49
 
50
+ /**
51
+ * Bound on each module's onToolBatchComplete. The hook runs inside the
52
+ * framework's sequential event loop, so a wedged module would stall every
53
+ * agent's events, not just its own round; past this the round goes on without
54
+ * it. A module with its own semantic deadline (the workspace scan's) sets it
55
+ * below this one, so it can record the miss itself.
56
+ */
57
+ export const TOOL_BATCH_HOOK_TIMEOUT_MS = 30_000;
58
+
49
59
  /**
50
60
  * Registered speech handler.
51
61
  */
@@ -60,7 +70,11 @@ interface SpeechHandler {
60
70
  * Registry for managing modules.
61
71
  */
62
72
  export class ModuleRegistry {
73
+ /** Each module's tool-batch hook still running, past its bound or not. */
74
+ private batchHooksRunning = new Map<string, Promise<unknown>>();
75
+
63
76
  private modules: Map<string, Module> = new Map();
77
+ private stopping = new Map<Module, Promise<void>>();
64
78
  private moduleContexts: Map<string, ModuleContextImpl> = new Map();
65
79
  private speechHandlers: SpeechHandler[] = [];
66
80
  private store: JsStore;
@@ -173,9 +187,7 @@ export class ModuleRegistry {
173
187
  throw new Error(`Module not found: ${name}`);
174
188
  }
175
189
 
176
- await module.stop();
177
- this.modules.delete(name);
178
- this.moduleContexts.delete(name);
190
+ await this.stopModule(module);
179
191
  }
180
192
 
181
193
  /**
@@ -261,7 +273,20 @@ export class ModuleRegistry {
261
273
  }
262
274
 
263
275
  /**
264
- * Handle a tool call by routing to the appropriate module.
276
+ * A module tool's call for a path that stores or sends its result: the
277
+ * model's dispatch, a script's inner call, the `utils` meta-tool for a
278
+ * model. The result is checked and read once, in its JSON form
279
+ * ({@link storableToolResult}).
280
+ */
281
+ async storableToolCall(call: ToolCall): Promise<ToolResult> {
282
+ return storableToolResult(call, await this.handleToolCall(call));
283
+ }
284
+
285
+ /**
286
+ * Handle a tool call by routing to the appropriate module: the module's own
287
+ * result, unchecked, as it returned it, for a caller that uses the result
288
+ * in process (`ModuleContext.callTool`, `executeToolCall`, or a host
289
+ * calling the registry). A module's throw is answered as an error result.
265
290
  */
266
291
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
267
292
  // Parse module--tool name
@@ -297,23 +322,37 @@ export class ModuleRegistry {
297
322
  try {
298
323
  return await module.handleToolCall(moduleCall);
299
324
  } catch (error) {
300
- return {
301
- success: false,
302
- error: error instanceof Error ? error.message : String(error),
303
- isError: true,
304
- };
325
+ return thrownResult(error);
305
326
  }
306
327
  }
307
328
 
308
329
  /**
309
- * Stop all modules.
330
+ * Stop all modules. A caller may bound its wait without cancelling cleanup.
331
+ * The default waits for each module's own shutdown contract.
310
332
  */
311
- async stopAll(): Promise<void> {
312
- const stopPromises = Array.from(this.modules.values()).map((m) => m.stop());
313
- await Promise.all(stopPromises);
314
- this.modules.clear();
315
- this.moduleContexts.clear();
316
- this.speechHandlers = [];
333
+ async stopAll(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
334
+ validateShutdownTimeout(timeoutMs);
335
+ await waitForShutdown(Array.from(this.modules.values(), module => ({
336
+ label: `module:${module.name}`, promise: this.stopModule(module),
337
+ })), timeoutMs);
338
+ }
339
+
340
+ private stopModule(module: Module): Promise<void> {
341
+ const existing = this.stopping.get(module);
342
+ if (existing) return existing;
343
+ const attempt = Promise.resolve().then(() => module.stop()).then(() => {
344
+ if (this.modules.get(module.name) === module) {
345
+ this.modules.delete(module.name);
346
+ this.moduleContexts.delete(module.name);
347
+ this.speechHandlers = this.speechHandlers.filter(handler => handler.moduleName !== module.name);
348
+ }
349
+ this.stopping.delete(module);
350
+ }, error => {
351
+ this.stopping.delete(module);
352
+ throw error;
353
+ });
354
+ this.stopping.set(module, attempt);
355
+ return attempt;
317
356
  }
318
357
 
319
358
  /**
@@ -397,6 +436,57 @@ export class ModuleRegistry {
397
436
  return injections;
398
437
  }
399
438
 
439
+ /**
440
+ * Tell modules an agent's tool batch completed (see
441
+ * Module.onToolBatchComplete). Hooks run in parallel, each awaited up to
442
+ * `timeoutMs`; a hook that throws or times out is logged and skipped, so a
443
+ * wedged module can't hold the round. Returns the modules that didn't
444
+ * finish, for the caller's trace.
445
+ */
446
+ async notifyToolBatchComplete(
447
+ agentName: string,
448
+ timeoutMs = TOOL_BATCH_HOOK_TIMEOUT_MS,
449
+ ): Promise<Array<{ module: string; error: string }>> {
450
+ const failures: Array<{ module: string; error: string }> = [];
451
+ const promises: Promise<void>[] = [];
452
+ for (const module of this.modules.values()) {
453
+ if (!module.onToolBatchComplete) continue;
454
+ // A hook still running from an earlier batch (it outlasted its bound)
455
+ // isn't called again or waited for: a wedged module costs one bound,
456
+ // not one per batch.
457
+ if (this.batchHooksRunning.has(module.name)) {
458
+ failures.push({ module: module.name, error: 'still running from an earlier batch' });
459
+ continue;
460
+ }
461
+ // Called from a promise, so a hook that throws synchronously, before
462
+ // returning its promise, is caught below like any other failure
463
+ // instead of escaping the round (the result would never be provided).
464
+ const call = Promise.resolve().then(() => module.onToolBatchComplete!(agentName));
465
+ this.batchHooksRunning.set(module.name, call);
466
+ const settled = (): void => {
467
+ if (this.batchHooksRunning.get(module.name) === call) this.batchHooksRunning.delete(module.name);
468
+ };
469
+ call.then(settled, settled);
470
+ let timer: ReturnType<typeof setTimeout> | undefined;
471
+ promises.push(
472
+ Promise.race([
473
+ call,
474
+ new Promise<void>((_, rej) => {
475
+ timer = setTimeout(() => rej(new Error(`onToolBatchComplete timed out after ${timeoutMs}ms`)), timeoutMs);
476
+ }),
477
+ ])
478
+ .catch((err: unknown) => {
479
+ const error = err instanceof Error ? err.message : String(err);
480
+ failures.push({ module: module.name, error });
481
+ console.error(`[${module.name}] onToolBatchComplete:`, err);
482
+ })
483
+ .finally(() => clearTimeout(timer)),
484
+ );
485
+ }
486
+ await Promise.all(promises);
487
+ return failures;
488
+ }
489
+
400
490
  /**
401
491
  * Dispatch speech to registered handlers.
402
492
  */
@@ -711,3 +801,149 @@ class ProcessStateImpl implements ProcessState {
711
801
  this.ctx.pushEvent(event);
712
802
  }
713
803
  }
804
+
805
+ /** A result's flags, which consumers read for their truth: booleans here. */
806
+ const RESULT_FLAGS = ['success', 'isError', 'endTurn'] as const;
807
+
808
+ /**
809
+ * A module's tool result as the framework can store, send and trace it. A
810
+ * module runs in the host's process, so it can return what JSON can't hold,
811
+ * such as a cyclic object, a BigInt or a `toJSON` that throws, no result
812
+ * object at all, or one whose getters or Proxy traps throw, or answer
813
+ * differently each time they're read. Everything after this (the stored
814
+ * history, the request sent to the model, traces, the tool-result guard's
815
+ * audit record, a script's result) would throw on it, and the agent's turn
816
+ * would never settle.
817
+ *
818
+ * So the result is read once, here, and this never throws. Each field a
819
+ * consumer reads is read by name, so a result class's getters count, and any
820
+ * other own field is kept, except a `toJSON`: consumers read a result's
821
+ * fields, never a `toJSON` of the result. What's returned is the JSON form of
822
+ * those fields, parsed, so every consumer holds plain data and no module code
823
+ * runs again. It's taken where the framework stores or sends a result
824
+ * ({@link ModuleRegistry.storableToolCall}). A caller that uses the result
825
+ * in process (`ModuleContext.callTool`, `executeToolCall`) gets the module's
826
+ * own result, as the module returned it ({@link ModuleRegistry.handleToolCall}).
827
+ * The flags are booleans, read for their truth as consumers read them.
828
+ * `error` is its text form, which is all a consumer reads of it, so an error
829
+ * value JSON can't hold costs nothing. An error that says nothing (null,
830
+ * false or empty) is left out, so the result reads as having no error text.
831
+ *
832
+ * A result that can't be read or converted becomes an error result naming the
833
+ * tool, keeping the module's own error text and its `endTurn` when they could
834
+ * be read. So does data that is a function or a symbol, which JSON leaves out
835
+ * whole.
836
+ */
837
+ export function storableToolResult(call: ToolCall, result: unknown): ToolResult {
838
+ return checkedToolResult(call, result).result;
839
+ }
840
+
841
+ /**
842
+ * {@link storableToolResult}, saying whether the module's result was usable:
843
+ * read and converted (`usable`), or replaced by the error result naming the
844
+ * tool. A caller that stores the result and hands the module's own result
845
+ * back, as `puppetToolCall` does, hands back the error result instead when
846
+ * there was nothing usable to store.
847
+ */
848
+ export function checkedToolResult(call: ToolCall, result: unknown): { result: ToolResult; usable: boolean } {
849
+ // What's been read so far, kept by an error result if a later read throws.
850
+ let ownText: string | undefined;
851
+ let endTurn = false;
852
+ const unusable = (problem: string): { result: ToolResult; usable: boolean } => {
853
+ const own = ownText ? `, with this error: ${ownText}` : '.';
854
+ const error = `Tool '${call.name}' ${problem}. The call itself completed${own}`;
855
+ console.error(`[tools] ${call.callerAgentName ? `agent ${call.callerAgentName}: ` : ''}${error}`);
856
+ return { result: { success: false, isError: true, error, ...(endTurn ? { endTurn: true } : {}) }, usable: false };
857
+ };
858
+ const reasonOf = (error: unknown) => thrownText(error).split('\n')[0] || 'no reason given';
859
+ try {
860
+ if (!result || typeof result !== 'object' || Array.isArray(result) || result instanceof Error) {
861
+ const what = result === undefined || result === null ? String(result)
862
+ : Array.isArray(result) ? 'an array' : result instanceof Error ? 'an Error' : `a ${typeof result}`;
863
+ if (result instanceof Error) ownText = errorText(result);
864
+ return unusable(`returned ${what} instead of a result object, so there's nothing to store or send`);
865
+ }
866
+ const candidate = result as Record<string, unknown>;
867
+ // No prototype, so an own field named like an inherited one is a field.
868
+ const fields: Record<string, unknown> = Object.create(null);
869
+ const read = (key: string) => { if (key in candidate) fields[key] = candidate[key]; };
870
+ // endTurn first and the error's text next, so an error result keeps
871
+ // whichever of them could be read when a later read throws.
872
+ read('endTurn');
873
+ endTurn = Boolean(fields.endTurn);
874
+ read('error');
875
+ ownText = errorText(fields.error);
876
+ for (const key of ['success', 'isError', 'data']) read(key);
877
+ for (const key of Object.keys(candidate)) {
878
+ if (key !== 'toJSON' && !(key in fields)) fields[key] = candidate[key];
879
+ }
880
+ // The error is kept as its text, which consumers read; its value isn't.
881
+ delete fields.error;
882
+ // The flags are kept as their truth, which is all consumers read of them,
883
+ // before anything is serialized, so a flag's own value (a BigInt, a
884
+ // function, an object with a toJSON) neither breaks nor runs anything.
885
+ for (const flag of RESULT_FLAGS) {
886
+ if (fields[flag] !== undefined) fields[flag] = Boolean(fields[flag]);
887
+ }
888
+ // An error result reads as its error, so only a non-error result's data matters.
889
+ if (!fields.isError && (typeof fields.data === 'function' || typeof fields.data === 'symbol')) {
890
+ return unusable(`returned a ${typeof fields.data} as its data, which JSON leaves out, so there's nothing to store or send`);
891
+ }
892
+ let json: string;
893
+ try {
894
+ json = JSON.stringify(fields);
895
+ } catch (error) {
896
+ return unusable(`returned a result that can't be converted to JSON, so it can't be stored or sent (${reasonOf(error)})`);
897
+ }
898
+ const storable = JSON.parse(json) as Record<string, unknown>;
899
+ if (ownText !== undefined) storable.error = ownText;
900
+ return { result: storable as unknown as ToolResult, usable: true };
901
+ } catch (error) {
902
+ return unusable(`returned a result that can't be read, so it can't be stored or sent (${reasonOf(error)})`);
903
+ }
904
+ }
905
+
906
+ /** A result's `error` as text, always a string or none: a string as it is,
907
+ * an `Error`'s message read the same way (a module's value, so not always a
908
+ * string), an object's own `message`, any other value's JSON, and a
909
+ * primitive JSON can't hold (a BigInt, a symbol) as its String(). None for a
910
+ * value that says nothing: absent, null, false or empty, which consumers
911
+ * then report as "Unknown error". */
912
+ function errorText(error: unknown): string | undefined {
913
+ if (error === undefined || error === null || error === false || error === '') return undefined;
914
+ if (typeof error === 'string') return error;
915
+ if (error instanceof Error) return errorText(error.message);
916
+ const message = (error as { message?: unknown }).message;
917
+ if (typeof message === 'string' && message) return message;
918
+ try {
919
+ const json = JSON.stringify(error) as string | undefined;
920
+ if (json !== undefined) return json;
921
+ } catch {
922
+ // A cyclic or BigInt-holding value: nothing faithful to show as JSON.
923
+ }
924
+ if (typeof error !== 'object' && typeof error !== 'function') return thrownText(error);
925
+ return 'an error value with no text form';
926
+ }
927
+
928
+ /** A module's throw, answered as an error result: its text, or none when it
929
+ * has none, so consumers say "Unknown error". */
930
+ export function thrownResult(error: unknown): ToolResult {
931
+ const text = thrownText(error);
932
+ return { success: false, isError: true, ...(text ? { error: text } : {}) };
933
+ }
934
+
935
+ /** A thrown value's whole text, always a string, for a result's error, and
936
+ * for a value that has none too. An `Error`'s message is a module's value,
937
+ * so it may not be a string: one that isn't is given its String(), and an
938
+ * absent one is empty. String() throws on an object without a prototype,
939
+ * and such a value is named as having no text form. */
940
+ function thrownText(value: unknown): string {
941
+ try {
942
+ if (!(value instanceof Error)) return String(value);
943
+ const message: unknown = value.message;
944
+ if (typeof message === 'string') return message;
945
+ return message === undefined || message === null ? '' : String(message);
946
+ } catch {
947
+ return 'a value with no text form';
948
+ }
949
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Branch-local intent about workspace paths: what the workspace on THIS branch
3
+ * deliberately did or still owes, kept in a per-mount Chronicle tree state so
4
+ * it rewinds with the workspace tree it qualifies (a branch switch that brings
5
+ * back a deleted file brings back its missing tombstone too).
6
+ *
7
+ * - tombstone: the workspace deleted the path while disk kept a copy (no
8
+ * autoMaterialize, or a refused unlink). The hash is the deleted entry's,
9
+ * so even a legacy entry with no disk evidence keeps a durable intent.
10
+ * - origin 'store': the entry was created through workspace tools and disk
11
+ * has never agreed with it.
12
+ * - conflict: disk and workspace diverged in a way no rule may resolve
13
+ * silently; kept until a path sync, a forced materialize, or convergence.
14
+ *
15
+ * Each record is a small JSON blob addressed from the tree, so reading one is
16
+ * treeGet + getBlob and writing one is storeBlob + treeSet.
17
+ */
18
+
19
+ import type { JsStore } from '@animalabs/chronicle';
20
+
21
+ export type ConflictKind =
22
+ /** Disk, workspace and the last agreement all differ. */
23
+ | 'both-changed'
24
+ /** Disk deleted a file whose workspace copy differs from the last agreement. */
25
+ | 'deleted-on-disk'
26
+ /** Disk changed a file the workspace deleted. */
27
+ | 'changed-after-workspace-delete'
28
+ /** Disk holds a file at the path of a workspace-created entry, with other content. */
29
+ | 'store-origin-collision'
30
+ /** A legacy entry whose disk copy differs, with no evidence of which side changed. */
31
+ | 'unknown-provenance'
32
+ /** A module write or adoption was interrupted, and disk holds neither outcome. */
33
+ | 'interrupted';
34
+
35
+ /** The disk side of a conflict, as observed when it was recorded. */
36
+ export interface DiskCounterpart {
37
+ hash: string;
38
+ size: number;
39
+ mtimeMs: number;
40
+ /** Blob hash of the stored copy (text within limits); absent when only referenced. */
41
+ stored?: string;
42
+ }
43
+
44
+ export interface ConflictRecord {
45
+ kind: ConflictKind;
46
+ /** When the conflict was recorded (ms). */
47
+ at: number;
48
+ /** null when disk had no file. */
49
+ disk: DiskCounterpart | null;
50
+ }
51
+
52
+ export interface BranchIntent {
53
+ tombstone?: { hash: string };
54
+ origin?: 'store';
55
+ conflict?: ConflictRecord;
56
+ /**
57
+ * Why a recorded intent can't be read (its blob missing from the store, or
58
+ * not an intent): a tombstone or conflict it held is unknown, never absent.
59
+ * Never written; a put replaces the damaged record.
60
+ */
61
+ unreadable?: string;
62
+ }
63
+
64
+ function isEmpty(intent: BranchIntent): boolean {
65
+ return intent.tombstone === undefined && intent.origin === undefined && intent.conflict === undefined &&
66
+ intent.unreadable === undefined;
67
+ }
68
+
69
+ /** A recorded intent's blob, read; a damaged one says why it can't be. */
70
+ function readIntent(store: JsStore, blobHash: string): BranchIntent {
71
+ const blob = store.getBlob(blobHash);
72
+ if (!blob) return { unreadable: "its record's blob is missing from the store" };
73
+ try {
74
+ const parsed = JSON.parse(blob.toString('utf-8')) as unknown;
75
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as BranchIntent;
76
+ } catch {
77
+ // reported below
78
+ }
79
+ return { unreadable: 'its record is not a branch intent' };
80
+ }
81
+
82
+ export class BranchIntents {
83
+ constructor(private readonly store: JsStore, readonly treeStateId: string) {}
84
+
85
+ get(path: string): BranchIntent | null {
86
+ const entry = this.store.treeGet(this.treeStateId, path);
87
+ if (!entry) return null;
88
+ return readIntent(this.store, entry.blobHash);
89
+ }
90
+
91
+ /** Replace a path's intent; an empty intent removes the record. */
92
+ put(path: string, intent: BranchIntent | null): void {
93
+ if (intent?.unreadable !== undefined) {
94
+ const { unreadable: _damaged, ...rest } = intent;
95
+ intent = rest;
96
+ }
97
+ if (!intent || isEmpty(intent)) {
98
+ if (this.store.treeGet(this.treeStateId, path)) this.store.treeRemove(this.treeStateId, path);
99
+ return;
100
+ }
101
+ const bytes = Buffer.from(JSON.stringify(intent), 'utf-8');
102
+ const blobHash = this.store.storeBlob(bytes, 'application/json');
103
+ this.store.treeSet(this.treeStateId, path, { blobHash, size: bytes.byteLength, mode: 0 });
104
+ }
105
+
106
+ /** Update one path's intent in place. */
107
+ update(path: string, change: (current: BranchIntent) => BranchIntent): void {
108
+ this.put(path, change({ ...(this.get(path) ?? {}) }));
109
+ }
110
+
111
+ /** Every recorded intent under a prefix ('' for the whole mount). */
112
+ list(prefix = ''): Array<[string, BranchIntent]> {
113
+ const out: Array<[string, BranchIntent]> = [];
114
+ for (const entry of this.store.treeList(this.treeStateId, prefix ? prefix + '/' : undefined)) {
115
+ out.push([entry.path, readIntent(this.store, entry.blobHash)]);
116
+ }
117
+ if (prefix) {
118
+ const exact = this.get(prefix);
119
+ if (exact) out.push([prefix, exact]);
120
+ }
121
+ return out;
122
+ }
123
+ }