@animalabs/agent-framework 0.22.1 → 0.22.2

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 (359) hide show
  1. package/README.md +1 -1
  2. package/dist/src/agent.d.ts +192 -19
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +404 -65
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts +41 -0
  7. package/dist/src/api/mcp-server-core.d.ts.map +1 -0
  8. package/dist/src/api/mcp-server-core.js +593 -0
  9. package/dist/src/api/mcp-server-core.js.map +1 -0
  10. package/dist/src/api/mcp-server.d.ts +2 -36
  11. package/dist/src/api/mcp-server.d.ts.map +1 -1
  12. package/dist/src/api/mcp-server.js +15 -595
  13. package/dist/src/api/mcp-server.js.map +1 -1
  14. package/dist/src/api/server.d.ts +9 -1
  15. package/dist/src/api/server.d.ts.map +1 -1
  16. package/dist/src/api/server.js +50 -26
  17. package/dist/src/api/server.js.map +1 -1
  18. package/dist/src/api/types.d.ts +9 -1
  19. package/dist/src/api/types.d.ts.map +1 -1
  20. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  21. package/dist/src/code-execution/py-runner.js.map +1 -1
  22. package/dist/src/framework.d.ts +393 -113
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1748 -582
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/gate-script.d.ts.map +1 -1
  28. package/dist/src/gate/gate-script.js.map +1 -1
  29. package/dist/src/index.d.ts +2 -2
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +2 -2
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  34. package/dist/src/mcpl/channel-registry.d.ts +26 -7
  35. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  36. package/dist/src/mcpl/channel-registry.js +102 -22
  37. package/dist/src/mcpl/channel-registry.js.map +1 -1
  38. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  39. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  40. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  41. package/dist/src/mcpl/errors.js.map +1 -1
  42. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  43. package/dist/src/mcpl/feature-set-manager.js +4 -3
  44. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  45. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  46. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  47. package/dist/src/mcpl/log-value.d.ts +54 -0
  48. package/dist/src/mcpl/log-value.d.ts.map +1 -0
  49. package/dist/src/mcpl/log-value.js +109 -0
  50. package/dist/src/mcpl/log-value.js.map +1 -0
  51. package/dist/src/mcpl/prose-stream-router.d.ts +6 -6
  52. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  53. package/dist/src/mcpl/prose-stream-router.js +4 -4
  54. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  55. package/dist/src/mcpl/push-coalescer.d.ts +38 -3
  56. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  57. package/dist/src/mcpl/push-coalescer.js +81 -16
  58. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  59. package/dist/src/mcpl/push-handler.d.ts +26 -3
  60. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-handler.js +87 -23
  62. package/dist/src/mcpl/push-handler.js.map +1 -1
  63. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  64. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  65. package/dist/src/mcpl/references.d.ts +2 -2
  66. package/dist/src/mcpl/references.d.ts.map +1 -1
  67. package/dist/src/mcpl/references.js.map +1 -1
  68. package/dist/src/mcpl/refusal-lines.d.ts +37 -0
  69. package/dist/src/mcpl/refusal-lines.d.ts.map +1 -0
  70. package/dist/src/mcpl/refusal-lines.js +61 -0
  71. package/dist/src/mcpl/refusal-lines.js.map +1 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -1
  73. package/dist/src/mcpl/server-connection.d.ts +18 -2
  74. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  75. package/dist/src/mcpl/server-connection.js +66 -11
  76. package/dist/src/mcpl/server-connection.js.map +1 -1
  77. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  78. package/dist/src/mcpl/tool-classes.d.ts +1 -1
  79. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  80. package/dist/src/mcpl/tool-classes.js +3 -2
  81. package/dist/src/mcpl/tool-classes.js.map +1 -1
  82. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  83. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  84. package/dist/src/mcpl/transport.d.ts +23 -5
  85. package/dist/src/mcpl/transport.d.ts.map +1 -1
  86. package/dist/src/mcpl/transport.js +103 -10
  87. package/dist/src/mcpl/transport.js.map +1 -1
  88. package/dist/src/mcpl/types.d.ts +30 -26
  89. package/dist/src/mcpl/types.d.ts.map +1 -1
  90. package/dist/src/mcpl/types.js.map +1 -1
  91. package/dist/src/module-registry.d.ts.map +1 -1
  92. package/dist/src/module-registry.js.map +1 -1
  93. package/dist/src/modules/api/index.d.ts.map +1 -1
  94. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  95. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  96. package/dist/src/modules/discord/index.d.ts.map +1 -1
  97. package/dist/src/modules/discord/index.js +4 -2
  98. package/dist/src/modules/discord/index.js.map +1 -1
  99. package/dist/src/modules/health/index.d.ts.map +1 -1
  100. package/dist/src/modules/health/index.js.map +1 -1
  101. package/dist/src/modules/history/index.d.ts.map +1 -1
  102. package/dist/src/modules/history/index.js.map +1 -1
  103. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  104. package/dist/src/modules/history/semantic.js.map +1 -1
  105. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  107. package/dist/src/modules/workspace/disk-agreement.d.ts +22 -4
  108. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/disk-agreement.js +63 -25
  110. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/index.d.ts +8 -4
  113. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  114. package/dist/src/modules/workspace/index.js +39 -12
  115. package/dist/src/modules/workspace/index.js.map +1 -1
  116. package/dist/src/modules/workspace/observe.d.ts +43 -8
  117. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  118. package/dist/src/modules/workspace/observe.js +264 -39
  119. package/dist/src/modules/workspace/observe.js.map +1 -1
  120. package/dist/src/modules/workspace/reconcile.d.ts +17 -1
  121. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  122. package/dist/src/modules/workspace/reconcile.js +57 -14
  123. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  124. package/dist/src/modules/workspace/types.d.ts +27 -2
  125. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/types.js.map +1 -1
  127. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  128. package/dist/src/modules/workspace/watcher.js.map +1 -1
  129. package/dist/src/operator-log.d.ts.map +1 -1
  130. package/dist/src/operator-log.js.map +1 -1
  131. package/dist/src/provider-waits.d.ts.map +1 -1
  132. package/dist/src/provider-waits.js.map +1 -1
  133. package/dist/src/queue.d.ts.map +1 -1
  134. package/dist/src/record-journal.d.ts.map +1 -1
  135. package/dist/src/record-journal.js.map +1 -1
  136. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  137. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  138. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  139. package/dist/src/recovery/offline-branch.js +29 -24
  140. package/dist/src/recovery/offline-branch.js.map +1 -1
  141. package/dist/src/recovery/recover-cli.js.map +1 -1
  142. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -1
  143. package/dist/src/runtime/liveness-watchdog.js.map +1 -1
  144. package/dist/src/shutdown.d.ts.map +1 -1
  145. package/dist/src/shutdown.js.map +1 -1
  146. package/dist/src/stored-removal.d.ts +73 -0
  147. package/dist/src/stored-removal.d.ts.map +1 -0
  148. package/dist/src/stored-removal.js +100 -0
  149. package/dist/src/stored-removal.js.map +1 -0
  150. package/dist/src/thinking-token-stamp.js.map +1 -1
  151. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  152. package/dist/src/tool-image-ledger.js.map +1 -1
  153. package/dist/src/tool-presentation.d.ts.map +1 -1
  154. package/dist/src/tool-presentation.js.map +1 -1
  155. package/dist/src/tool-result-guard.d.ts.map +1 -1
  156. package/dist/src/tool-result-guard.js.map +1 -1
  157. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  158. package/dist/src/tune-out/coordinator.js.map +1 -1
  159. package/dist/src/tune-out/tools.d.ts +1 -1
  160. package/dist/src/tune-out/tools.d.ts.map +1 -1
  161. package/dist/src/types/agent.d.ts +42 -6
  162. package/dist/src/types/agent.d.ts.map +1 -1
  163. package/dist/src/types/framework.d.ts +10 -0
  164. package/dist/src/types/framework.d.ts.map +1 -1
  165. package/dist/src/types/module.d.ts +8 -4
  166. package/dist/src/types/module.d.ts.map +1 -1
  167. package/dist/src/types/trace.d.ts +61 -10
  168. package/dist/src/types/trace.d.ts.map +1 -1
  169. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  170. package/dist/test/agent-runtime-settings.test.js +1139 -3
  171. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  172. package/dist/test/batched-wake-locus.test.js.map +1 -1
  173. package/dist/test/channel-label-history.test.js.map +1 -1
  174. package/dist/test/code-execution.test.js.map +1 -1
  175. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  176. package/dist/test/conversation-router.test.js.map +1 -1
  177. package/dist/test/conversation-routing.test.js +240 -6
  178. package/dist/test/conversation-routing.test.js.map +1 -1
  179. package/dist/test/discord-awareness-outbox.test.js +104 -0
  180. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  181. package/dist/test/discord-typing-keepalive.test.d.ts +2 -0
  182. package/dist/test/discord-typing-keepalive.test.d.ts.map +1 -0
  183. package/dist/test/discord-typing-keepalive.test.js +77 -0
  184. package/dist/test/discord-typing-keepalive.test.js.map +1 -0
  185. package/dist/test/ephemeral-settle.test.js.map +1 -1
  186. package/dist/test/event-gate-passthrough.test.js.map +1 -1
  187. package/dist/test/event-gate-policy-mutation.test.js.map +1 -1
  188. package/dist/test/event-gate-rate-limit.test.js.map +1 -1
  189. package/dist/test/event-gate-script.test.js.map +1 -1
  190. package/dist/test/event-gate-selfwake.test.js.map +1 -1
  191. package/dist/test/event-gate-tags.test.js.map +1 -1
  192. package/dist/test/event-gate.test.js.map +1 -1
  193. package/dist/test/explicit-prose-routing.test.js +363 -42
  194. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  195. package/dist/test/framework.test.js.map +1 -1
  196. package/dist/test/helpers/coalescing-fixture.d.ts +2 -0
  197. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  198. package/dist/test/helpers/coalescing-fixture.js +8 -2
  199. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  200. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  201. package/dist/test/helpers/mock-membrane.js.map +1 -1
  202. package/dist/test/hide-then-turn.test.d.ts +2 -0
  203. package/dist/test/hide-then-turn.test.d.ts.map +1 -0
  204. package/dist/test/hide-then-turn.test.js +101 -0
  205. package/dist/test/hide-then-turn.test.js.map +1 -0
  206. package/dist/test/history-module-overview.test.js.map +1 -1
  207. package/dist/test/history-module-semantic.test.js.map +1 -1
  208. package/dist/test/history-module.test.js.map +1 -1
  209. package/dist/test/history-semantic-review-findings.test.js.map +1 -1
  210. package/dist/test/host-quiesce.test.js +125 -0
  211. package/dist/test/host-quiesce.test.js.map +1 -1
  212. package/dist/test/inference-exhaustion-rewind.test.js +81 -3
  213. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  214. package/dist/test/inference-failure-bounds.test.js +1 -0
  215. package/dist/test/inference-failure-bounds.test.js.map +1 -1
  216. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -1
  217. package/dist/test/liveness-watchdog.test.js.map +1 -1
  218. package/dist/test/maintenance-timer.test.js +1564 -2
  219. package/dist/test/maintenance-timer.test.js.map +1 -1
  220. package/dist/test/mcp-server-bin.test.d.ts +2 -0
  221. package/dist/test/mcp-server-bin.test.d.ts.map +1 -0
  222. package/dist/test/mcp-server-bin.test.js +142 -0
  223. package/dist/test/mcp-server-bin.test.js.map +1 -0
  224. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  225. package/dist/test/mcpl-child-env.test.js +1 -1
  226. package/dist/test/mcpl-child-env.test.js.map +1 -1
  227. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +2 -0
  228. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +1 -0
  229. package/dist/test/mcpl-coalesced-content-parity.test.js +532 -0
  230. package/dist/test/mcpl-coalesced-content-parity.test.js.map +1 -0
  231. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  232. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
  233. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  234. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  235. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  236. package/dist/test/mcpl-reconnect-reregistration.test.js +2 -0
  237. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  238. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  239. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  240. package/dist/test/mcpl-rejection-log-lines.test.d.ts +2 -0
  241. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +1 -0
  242. package/dist/test/mcpl-rejection-log-lines.test.js +349 -0
  243. package/dist/test/mcpl-rejection-log-lines.test.js.map +1 -0
  244. package/dist/test/mcpl-stderr-tail.test.d.ts +2 -0
  245. package/dist/test/mcpl-stderr-tail.test.d.ts.map +1 -0
  246. package/dist/test/mcpl-stderr-tail.test.js +210 -0
  247. package/dist/test/mcpl-stderr-tail.test.js.map +1 -0
  248. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -1
  249. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  250. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -1
  251. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -1
  252. package/dist/test/mcpl-transport-close.test.d.ts +2 -0
  253. package/dist/test/mcpl-transport-close.test.d.ts.map +1 -0
  254. package/dist/test/mcpl-transport-close.test.js +373 -0
  255. package/dist/test/mcpl-transport-close.test.js.map +1 -0
  256. package/dist/test/mcpl-transport-spawn.test.d.ts +2 -0
  257. package/dist/test/mcpl-transport-spawn.test.d.ts.map +1 -0
  258. package/dist/test/mcpl-transport-spawn.test.js +58 -0
  259. package/dist/test/mcpl-transport-spawn.test.js.map +1 -0
  260. package/dist/test/mcpl-ws-transport.test.js +1 -0
  261. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  262. package/dist/test/offline-recovery-branch.test.js +344 -0
  263. package/dist/test/offline-recovery-branch.test.js.map +1 -1
  264. package/dist/test/operator-surgery.test.js +197 -0
  265. package/dist/test/operator-surgery.test.js.map +1 -1
  266. package/dist/test/overbudget-drain-breaker.test.js +1 -0
  267. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  268. package/dist/test/pack-installs-alone.test.d.ts +2 -0
  269. package/dist/test/pack-installs-alone.test.d.ts.map +1 -0
  270. package/dist/test/pack-installs-alone.test.js +284 -0
  271. package/dist/test/pack-installs-alone.test.js.map +1 -0
  272. package/dist/test/physical-window-restart.test.d.ts +6 -4
  273. package/dist/test/physical-window-restart.test.d.ts.map +1 -1
  274. package/dist/test/physical-window-restart.test.js +275 -10
  275. package/dist/test/physical-window-restart.test.js.map +1 -1
  276. package/dist/test/placed-reaction-baseline.test.js.map +1 -1
  277. package/dist/test/present-while-acting.test.js +949 -6
  278. package/dist/test/present-while-acting.test.js.map +1 -1
  279. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  280. package/dist/test/provider-waits.test.js +67 -4
  281. package/dist/test/provider-waits.test.js.map +1 -1
  282. package/dist/test/puppet-tool-call.test.js +2 -0
  283. package/dist/test/puppet-tool-call.test.js.map +1 -1
  284. package/dist/test/reaction-baseline-platform.test.js.map +1 -1
  285. package/dist/test/record-journal.test.js.map +1 -1
  286. package/dist/test/refusal-rewind.test.js +9 -0
  287. package/dist/test/refusal-rewind.test.js.map +1 -1
  288. package/dist/test/refusal-single-call.test.js.map +1 -1
  289. package/dist/test/release-bump.test.d.ts +2 -0
  290. package/dist/test/release-bump.test.d.ts.map +1 -0
  291. package/dist/test/release-bump.test.js +248 -0
  292. package/dist/test/release-bump.test.js.map +1 -0
  293. package/dist/test/release-changelog.test.js.map +1 -1
  294. package/dist/test/release-holds.test.d.ts +2 -0
  295. package/dist/test/release-holds.test.d.ts.map +1 -0
  296. package/dist/test/release-holds.test.js +456 -0
  297. package/dist/test/release-holds.test.js.map +1 -0
  298. package/dist/test/same-round-think-policy.test.js.map +1 -1
  299. package/dist/test/shutdown-errors.test.js.map +1 -1
  300. package/dist/test/silent-heartbeat-rows.test.js.map +1 -1
  301. package/dist/test/stop-with-host-operations.test.d.ts +20 -0
  302. package/dist/test/stop-with-host-operations.test.d.ts.map +1 -0
  303. package/dist/test/stop-with-host-operations.test.js +1174 -0
  304. package/dist/test/stop-with-host-operations.test.js.map +1 -0
  305. package/dist/test/stop-with-work-in-flight.test.js +162 -50
  306. package/dist/test/stop-with-work-in-flight.test.js.map +1 -1
  307. package/dist/test/surgery-marks-receipt.test.js.map +1 -1
  308. package/dist/test/tool-batch-hook.test.js.map +1 -1
  309. package/dist/test/tool-result-guard.test.js +131 -9
  310. package/dist/test/tool-result-guard.test.js.map +1 -1
  311. package/dist/test/tool-result-spill.test.js.map +1 -1
  312. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -1
  313. package/dist/test/trunk-channel-routing.test.js +330 -7
  314. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  315. package/dist/test/tune-out-e2e.test.js.map +1 -1
  316. package/dist/test/unconvertible-tool-result.test.js.map +1 -1
  317. package/dist/test/unsent-compiles.test.d.ts +2 -0
  318. package/dist/test/unsent-compiles.test.d.ts.map +1 -0
  319. package/dist/test/unsent-compiles.test.js +139 -0
  320. package/dist/test/unsent-compiles.test.js.map +1 -0
  321. package/dist/test/workspace-branch-continuation.test.js.map +1 -1
  322. package/dist/test/workspace-materialize-freshness.test.js.map +1 -1
  323. package/dist/test/workspace-read-text.test.js.map +1 -1
  324. package/dist/test/workspace-three-way.test.js +907 -7
  325. package/dist/test/workspace-three-way.test.js.map +1 -1
  326. package/dist/test/workspace-watcher.test.js.map +1 -1
  327. package/dist/test/xml-round-persist.test.js.map +1 -1
  328. package/dist/tsconfig.tsbuildinfo +1 -1
  329. package/package.json +8 -6
  330. package/src/agent.ts +411 -66
  331. package/src/api/mcp-server-core.ts +681 -0
  332. package/src/api/mcp-server.ts +16 -685
  333. package/src/api/server.ts +51 -29
  334. package/src/api/types.ts +9 -1
  335. package/src/framework.ts +1899 -641
  336. package/src/index.ts +2 -2
  337. package/src/mcpl/channel-registry.ts +126 -31
  338. package/src/mcpl/feature-set-manager.ts +4 -3
  339. package/src/mcpl/log-value.ts +116 -0
  340. package/src/mcpl/prose-stream-router.ts +6 -6
  341. package/src/mcpl/push-coalescer.ts +76 -12
  342. package/src/mcpl/push-handler.ts +95 -23
  343. package/src/mcpl/refusal-lines.ts +59 -0
  344. package/src/mcpl/server-connection.ts +70 -11
  345. package/src/mcpl/tool-classes.ts +3 -2
  346. package/src/mcpl/transport.ts +105 -14
  347. package/src/mcpl/types.ts +5 -1
  348. package/src/modules/discord/index.ts +4 -2
  349. package/src/modules/workspace/disk-agreement.ts +74 -18
  350. package/src/modules/workspace/index.ts +41 -11
  351. package/src/modules/workspace/observe.ts +265 -39
  352. package/src/modules/workspace/reconcile.ts +71 -14
  353. package/src/modules/workspace/types.ts +26 -1
  354. package/src/recovery/offline-branch.ts +32 -28
  355. package/src/stored-removal.ts +126 -0
  356. package/src/types/agent.ts +42 -6
  357. package/src/types/framework.ts +10 -0
  358. package/src/types/module.ts +8 -4
  359. package/src/types/trace.ts +62 -11
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export * from './types/index.js';
3
3
 
4
4
  // Core classes
5
- export { AgentFramework, BudgetPreflightError, ResumeBlockedError } from './framework.js';
5
+ export { AgentFramework, BudgetPreflightError, ProviderWaitNotSleptError, ResumeBlockedError } from './framework.js';
6
6
  export type { RuntimeSettingsPreview, HostModeStatus } from './framework.js';
7
7
  export { Agent } from './agent.js';
8
8
  export type { StartStreamResult } from './agent.js';
@@ -17,7 +17,7 @@ export * from './modules/index.js';
17
17
 
18
18
  // API
19
19
  export { ApiServer } from './api/server.js';
20
- export { McpServer } from './api/mcp-server.js';
20
+ export { McpServer } from './api/mcp-server-core.js';
21
21
  export type {
22
22
  ApiServerConfig,
23
23
  ApiMessage,
@@ -37,8 +37,10 @@ import type { McplServerRegistry } from './server-registry.js';
37
37
  import type { FeatureSetManager } from './feature-set-manager.js';
38
38
  import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
39
39
  import { expandCoreTags } from './tags.js';
40
- import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
40
+ import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
41
41
  import { isVisiblyEmptyContent } from './visible-content.js';
42
+ import { logDetail, logValue } from './log-value.js';
43
+ import { RefusalLines } from './refusal-lines.js';
42
44
  import { CapabilityGrant } from './capability-grant.js';
43
45
 
44
46
  // ============================================================================
@@ -249,7 +251,9 @@ interface McplChannelIncomingEvent {
249
251
  function convertBlock(block: McplContentBlock): ContentBlock {
250
252
  switch (block.type) {
251
253
  case 'text':
252
- return { type: 'text', text: block.text };
254
+ // Text that isn't a string is no text, as the request sanitizer and
255
+ // the visible-content check already read it.
256
+ return { type: 'text', text: typeof block.text === 'string' ? block.text : '' };
253
257
 
254
258
  case 'image':
255
259
  if (isInlineContradiction(block)) {
@@ -257,24 +261,24 @@ function convertBlock(block: McplContentBlock): ContentBlock {
257
261
  // closed, withhold the data (checked BEFORE the data branch).
258
262
  return { type: 'text', text: INLINE_WITHHELD_TEXT };
259
263
  }
260
- if (block.uri && block.disposition) {
264
+ if (isFilledString(block.uri) && block.disposition) {
261
265
  // RFC-005 uri-form media with a disposition claim: stub, do not
262
266
  // hand the URI to the provider or inline it.
263
267
  return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
264
268
  }
265
- if (block.data && block.mimeType) {
269
+ if (isFilledString(block.data) && isFilledString(block.mimeType)) {
266
270
  return {
267
271
  type: 'image',
268
272
  source: { type: 'base64', data: block.data, mediaType: block.mimeType },
269
273
  } as ContentBlock;
270
274
  }
271
- if (block.uri) {
275
+ if (isFilledString(block.uri)) {
272
276
  return {
273
277
  type: 'image',
274
278
  source: { type: 'url', url: block.uri },
275
279
  } as ContentBlock;
276
280
  }
277
- return { type: 'text', text: '[Image: no data]' };
281
+ return { type: 'text', text: isFilledString(block.data) ? '[Image: no media type]' : '[Image: no data]' };
278
282
 
279
283
  case 'audio':
280
284
  if (isInlineContradiction(block)) {
@@ -282,31 +286,43 @@ function convertBlock(block: McplContentBlock): ContentBlock {
282
286
  // closed, withhold the data (checked BEFORE the data branch).
283
287
  return { type: 'text', text: INLINE_WITHHELD_TEXT };
284
288
  }
285
- if (block.uri && block.disposition) {
289
+ if (isFilledString(block.uri) && block.disposition) {
286
290
  // RFC-005 uri-form media with a disposition claim: stub, do not
287
291
  // hand the URI to the provider or inline it.
288
292
  return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
289
293
  }
290
- if (block.data && block.mimeType) {
294
+ if (isFilledString(block.data) && isFilledString(block.mimeType)) {
291
295
  return {
292
296
  type: 'audio',
293
297
  source: { type: 'base64', data: block.data, mediaType: block.mimeType },
294
298
  } as ContentBlock;
295
299
  }
296
- return { type: 'text', text: '[Audio: no data]' };
300
+ return { type: 'text', text: isFilledString(block.data) ? '[Audio: no media type]' : '[Audio: no data]' };
297
301
 
298
302
  case 'resource':
299
303
  // RFC-005: reference blocks become bounded stubs — never raw URIs
300
304
  // (a signed URL is a bearer credential that looks like a location).
301
305
  return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
302
306
 
303
- default:
307
+ default: {
304
308
  // Unknown wire block types previously fell off the exhaustive switch
305
- // and propagated `undefined` into ContentBlock[]. Fail visibly.
306
- return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` };
309
+ // and propagated `undefined` into ContentBlock[]. Fail visibly. A type
310
+ // that isn't a string is no type, and is never converted to one: an
311
+ // object can throw when it is.
312
+ const type: unknown = (block as { type?: unknown }).type;
313
+ return { type: 'text', text: `[unrecognized content block: ${typeof type === 'string' ? type : 'untyped'}]` };
314
+ }
307
315
  }
308
316
  }
309
317
 
318
+ /** A string with something in it: the only data, mimeType or uri a block's
319
+ * source is built from, and the only uri that makes it a reference. Anything
320
+ * else a sender puts there leaves the block without it, so conversion never
321
+ * throws and never copies a non-string. */
322
+ function isFilledString(value: unknown): value is string {
323
+ return typeof value === 'string' && value !== '';
324
+ }
325
+
310
326
  // ============================================================================
311
327
  // Channel Tool Definitions
312
328
  // ============================================================================
@@ -512,17 +528,19 @@ interface ChannelRegistryOptions {
512
528
  */
513
529
  homeChannelResolver?: (agentName: string) => string | undefined;
514
530
  /**
515
- * Resolve the channel that triggered an agent's CURRENT inference turn, by
516
- * agent name. This is the fix for single-TRUNK agents (the only mode
531
+ * Resolve an agent's channel for its CURRENT inference turn, by agent name:
532
+ * the turn's locus once it has one, else the channel that triggered the
533
+ * turn. This is the fix for single-TRUNK agents (the only mode
517
534
  * connectome-host runs — it never exposes conversation forks). A trunk agent
518
535
  * has no fork home, so without this its plain-text speech falls back to the
519
536
  * process-global `defaultPublishChannel`, which tracks the most-recent inbound
520
537
  * across ALL channels and misroutes a reply to whichever channel last spoke
521
- * under concurrency (item-3 redux). The framework tracks the triggering
522
- * channel of the live turn per-agent and exposes it here; resolves to
523
- * undefined for a heartbeat / no-trigger turn, which correctly keeps the
524
- * global fallback. Consulted AFTER `homeChannelResolver` (a fork's home always
525
- * wins), BEFORE `defaultPublishChannel`.
538
+ * under concurrency (item-3 redux). The framework keeps both on the live
539
+ * turn's routing state and exposes them here. For a heartbeat / no-trigger
540
+ * turn it resolves to undefined until the turn's locus is resolved, so that
541
+ * resolution takes the global fallback, which the turn then keeps.
542
+ * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE
543
+ * `defaultPublishChannel`.
526
544
  */
527
545
  activeChannelResolver?: (agentName: string) => string | undefined;
528
546
  }
@@ -577,6 +595,10 @@ export class ChannelRegistry {
577
595
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
578
596
  private channels = new Map<string, ChannelEntry>();
579
597
 
598
+ /** Holds back repeats of the line for a message refused because its
599
+ * channel isn't registered (RefusalLines). */
600
+ private unknownChannelLines = new RefusalLines();
601
+
580
602
  /**
581
603
  * Most-recently-known label per channelId, replayed from
582
604
  * `CHANNEL_LABEL_HISTORY_LOG_ID` (last sighting wins) and kept current by
@@ -848,6 +870,45 @@ export class ChannelRegistry {
848
870
  });
849
871
  }
850
872
 
873
+ /**
874
+ * A coalesced message the host refused. Until now the host said nothing, so
875
+ * a dropped message (agent-framework#266: every Discord message with a
876
+ * screenshot) left no trace where operators look. The sender gets the
877
+ * per-message result this lane gives: `coalesce_invalid` for a malformed
878
+ * item, or the thrown error's message for anything else. `answered` is the
879
+ * code that result stands for (-32602 for `coalesce_invalid`), and the
880
+ * reason follows it (coalesceRejectionReason): `coalesce-failed` when the
881
+ * host itself failed, `coalesce-invalid` when the sender's item was
882
+ * refused, with the failing `field` and the error's message as `detail`.
883
+ * The line prints the ids the sender sent, whatever their type; the trace
884
+ * carries them when they are strings.
885
+ */
886
+ private traceCoalesceRejection(
887
+ serverId: string,
888
+ message: { channelId?: unknown; messageId?: unknown } | null | undefined,
889
+ error: { field?: unknown; message: string },
890
+ answered: number,
891
+ ): void {
892
+ const reason = coalesceRejectionReason(answered);
893
+ const field = typeof error.field === 'string' && error.field ? error.field : undefined;
894
+ const channelId = typeof message?.channelId === 'string' ? message.channelId : '';
895
+ const messageId = typeof message?.messageId === 'string' && message.messageId ? message.messageId : undefined;
896
+ console.error(
897
+ `[channel-incoming-rejected] server=${serverId} channel=${logValue(message?.channelId ?? '')} ` +
898
+ `messageId=${logValue(message?.messageId ?? '')} ` +
899
+ `reason=${reason}${field ? ` field=${logValue(field)}` : ''}: ${logDetail(error.message)}`,
900
+ );
901
+ this.emitTraceFn({
902
+ type: 'mcpl:channel-incoming-rejected',
903
+ serverId,
904
+ channelId,
905
+ ...(messageId ? { messageId } : {}),
906
+ reason,
907
+ ...(field ? { field } : {}),
908
+ detail: error.message,
909
+ });
910
+ }
911
+
851
912
  /**
852
913
  * Handle `channels/incoming` from a server.
853
914
  *
@@ -864,10 +925,20 @@ export class ChannelRegistry {
864
925
  for (const message of params.messages) {
865
926
  if (!message || typeof message.channelId !== 'string' || !message.channelId
866
927
  || typeof message.messageId !== 'string' || !message.messageId) {
928
+ // The same gate as admission below: with no coalescer wired, a
929
+ // message carrying `coalesce` is handled uncoalesced, and refused as
930
+ // any uncoalesced message without an identity is.
931
+ const coalescedItem = message?.coalesce !== undefined && !!this.handleCoalescedIncoming;
932
+ if (coalescedItem) {
933
+ this.traceCoalesceRejection(serverId, message, {
934
+ field: typeof message.channelId !== 'string' || !message.channelId ? 'channelId' : 'messageId',
935
+ message: 'a coalesced message needs a channelId and a messageId',
936
+ }, -32602);
937
+ }
867
938
  results.push({
868
939
  messageId: typeof message?.messageId === 'string' ? message.messageId : '',
869
940
  accepted: false,
870
- reason: message?.coalesce !== undefined ? 'coalesce_invalid' : 'invalid channel/message identity',
941
+ reason: coalescedItem ? 'coalesce_invalid' : 'invalid channel/message identity',
871
942
  });
872
943
  continue;
873
944
  }
@@ -899,11 +970,24 @@ export class ChannelRegistry {
899
970
  // routable entry rather than an open self-attested one.
900
971
  const incomingKey = `${serverId}:${message.channelId}`;
901
972
  if (!this.channels.has(incomingKey)) {
973
+ // Loud, coalesced or not, like every other refusal of a message, but
974
+ // at most once a minute per channel (RefusalLines): a server that
975
+ // never registered a busy channel would otherwise print a line for
976
+ // every message in it. Every refusal is traced.
977
+ const held = this.unknownChannelLines.admit(serverId, message.channelId);
978
+ if (held !== null) {
979
+ console.error(
980
+ `[channel-incoming-rejected] server=${serverId} channel=${logValue(message.channelId)} ` +
981
+ `messageId=${logValue(message.messageId)} reason=unknown-channel ` +
982
+ `(not registered, §14.5${held ? `; ${held} more since the last line` : ''})`,
983
+ );
984
+ }
902
985
  this.emitTraceFn({
903
986
  type: 'mcpl:channel-incoming-rejected',
904
987
  serverId,
905
988
  channelId: message.channelId,
906
- reason: 'unknown channel — not registered (§14.5)',
989
+ messageId: message.messageId,
990
+ reason: 'unknown-channel',
907
991
  });
908
992
  results.push({
909
993
  messageId: message.messageId,
@@ -921,7 +1005,7 @@ export class ChannelRegistry {
921
1005
  // shows the model nothing would wake it with no visible cause.
922
1006
  const emptyAllowed = coalesced && (message.coalesce as { retract?: unknown } | null)?.retract === true;
923
1007
  const rejectEmpty = () => {
924
- console.error(`[channel-incoming-rejected] server=${serverId} channel=${message.channelId} messageId=${message.messageId} reason=empty-content`);
1008
+ console.error(`[channel-incoming-rejected] server=${serverId} channel=${logValue(message.channelId)} messageId=${logValue(message.messageId)} reason=empty-content`);
925
1009
  this.emitTraceFn({
926
1010
  type: 'mcpl:channel-incoming-rejected',
927
1011
  serverId,
@@ -935,10 +1019,14 @@ export class ChannelRegistry {
935
1019
  // RFC-006 §13: malformed content on a coalesced item is that item's
936
1020
  // failure, not the batch's — check the shape before converting.
937
1021
  try {
938
- validateCoalescedContent(message.content, undefined, { allowEmpty: emptyAllowed });
1022
+ validateCoalescedContent(message.content, { allowEmpty: emptyAllowed });
939
1023
  } catch (error) {
940
1024
  if (error instanceof EmptyContentError) rejectEmpty();
941
- else results.push({ messageId: message.messageId, accepted: false, reason: 'coalesce_invalid' });
1025
+ else {
1026
+ // Answered coalesce_invalid whatever the error's code: -32602.
1027
+ this.traceCoalesceRejection(serverId, message, error as Error & { field?: string }, -32602);
1028
+ results.push({ messageId: message.messageId, accepted: false, reason: 'coalesce_invalid' });
1029
+ }
942
1030
  continue;
943
1031
  }
944
1032
  }
@@ -970,8 +1058,11 @@ export class ChannelRegistry {
970
1058
  // Determine whether to trigger inference
971
1059
  let triggerInference = true;
972
1060
  if (this.shouldTriggerInference) {
973
- const textContent = message.content
974
- .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
1061
+ // The text the agent's context gets, as the push lane's filter reads
1062
+ // it: the converted text blocks, a placeholder or reference stub
1063
+ // among them, and a text block's non-string text as empty text.
1064
+ const textContent = convertedContent
1065
+ .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
975
1066
  .map((b) => b.text)
976
1067
  .join('\n');
977
1068
  triggerInference = this.shouldTriggerInference(
@@ -1014,7 +1105,10 @@ export class ChannelRegistry {
1014
1105
  if (result.accepted) markAccepted();
1015
1106
  results.push(result);
1016
1107
  } catch (error) {
1017
- const err = error as Error & { code?: number };
1108
+ const err = error as Error & { code?: number; field?: string };
1109
+ // -32602 is answered coalesce_invalid; anything else as its message,
1110
+ // a plain Error standing for an internal error (-32603).
1111
+ this.traceCoalesceRejection(serverId, message, err, typeof err.code === 'number' ? err.code : -32603);
1018
1112
  results.push({
1019
1113
  messageId: message.messageId,
1020
1114
  accepted: false,
@@ -1361,8 +1455,9 @@ export class ChannelRegistry {
1361
1455
 
1362
1456
  // Resolve the outbound locus the SAME way routeSpeech does, so the agent is
1363
1457
  // told the channel its speech will actually land in: a conversation fork's
1364
- // home channel, else this turn's triggering channel (single trunk agent —
1365
- // item-3 redux), else the global default (heartbeats). Without this, a fork
1458
+ // home channel, else this turn's channel (single trunk agent — item-3
1459
+ // redux: its locus, else its triggering channel), else the global default
1460
+ // (heartbeats). Without this, a fork
1366
1461
  // was advertised the global locus but published somewhere else; and a trunk
1367
1462
  // agent was told the wrong channel under concurrency.
1368
1463
  const home = agentName ? this.homeChannelResolver?.(agentName) : undefined;
@@ -3018,7 +3113,7 @@ export class ChannelRegistry {
3018
3113
  try {
3019
3114
  await this.openChannelNow(entry, 'opened-by-delivery');
3020
3115
  console.error(
3021
- `[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`,
3116
+ `[routeSpeech] ${conversationId}: locus ${logValue(channelId)} was closed — opened by delivery (speech implies engagement)`,
3022
3117
  );
3023
3118
  this.emitTraceFn({
3024
3119
  type: 'mcpl:channel-opened-by-send',
@@ -3071,7 +3166,7 @@ export class ChannelRegistry {
3071
3166
  return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`);
3072
3167
  }
3073
3168
 
3074
- console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
3169
+ console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${entry.serverId}, delivered=${delivered})`);
3075
3170
  this.emitTraceFn({
3076
3171
  type: 'mcpl:speech-routed',
3077
3172
  conversationId,
@@ -19,6 +19,7 @@ import {
19
19
  UNKNOWN_FEATURE_SET,
20
20
  } from './errors.js';
21
21
  import { isKnownCapabilityPath } from './capability-grant.js';
22
+ import { logValue } from './log-value.js';
22
23
 
23
24
  // ============================================================================
24
25
  // Error Class
@@ -222,7 +223,7 @@ export class FeatureSetManager {
222
223
  ): void => {
223
224
  const selectedByConfig = state.enabled.delete(name);
224
225
  const note = explicitSelection && selectedByConfig ? ' — overrides enabledFeatureSets' : '';
225
- console.error(`[mcpl] ${serverId}/${name} disabled: ${detail}${note}`);
226
+ console.error(`[mcpl] ${serverId}/${logValue(name)} disabled: ${detail}${note}`);
226
227
  this.emitTrace?.({
227
228
  type: 'mcpl:feature-set-disabled',
228
229
  serverId,
@@ -240,14 +241,14 @@ export class FeatureSetManager {
240
241
  }
241
242
  const unknown = uses.filter((u) => typeof u !== 'string' || !isKnownCapabilityPath(u));
242
243
  if (unknown.length > 0) {
243
- disable(name, 'invalid_uses', `invalid_uses (§6.4 — unrecognized: ${unknown.join(', ')})`, {
244
+ disable(name, 'invalid_uses', `invalid_uses (§6.4 — unrecognized: ${unknown.map(logValue).join(', ')})`, {
244
245
  unrecognized: unknown.map((u) => (typeof u === 'string' ? u : JSON.stringify(u))),
245
246
  });
246
247
  continue;
247
248
  }
248
249
  const missing = (uses as string[]).filter((u) => !grant.has(u));
249
250
  if (missing.length > 0) {
250
- disable(name, 'missing_capabilities', `missing capabilities ${missing.join(', ')} (§6.4)`, { missing });
251
+ disable(name, 'missing_capabilities', `missing capabilities ${missing.map(logValue).join(', ')} (§6.4)`, { missing });
251
252
  }
252
253
  }
253
254
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * A value a connected server chose, as one field of a host log line.
3
+ *
4
+ * The rejection lines (`[channel-incoming-rejected]`, `[push-event-rejected]`)
5
+ * and the other host lines that name a server's own identifiers are read a
6
+ * line at a time, by eye and with grep. Printed raw, an identifier carrying a
7
+ * line break prints a second line that reads as another host record, about
8
+ * another server; one carrying a space can pose as another field of the same
9
+ * line; and one carrying `=` can hold a lookalike field (`reason=…`) that a
10
+ * search for that field finds before the real one.
11
+ *
12
+ * A plain token (visible characters only: no space, quote, backslash or `=`)
13
+ * prints as it is, so ordinary ids read and grep as they always have.
14
+ * Anything else prints as a JSON string with every `=` and every invisible
15
+ * character escaped: controls, format and bidi characters, line and paragraph
16
+ * separators, every space (the plain one included), default-ignorable
17
+ * characters (Hangul fillers, the combining grapheme joiner, variation
18
+ * selectors) and the blank Braille pattern. So it stays one field on one line,
19
+ * a reader that splits on whitespace keeps it whole, no field holds a raw
20
+ * `=` (so every `=` before a line's free-text tail, `logDetail`, belongs to
21
+ * a real field), and `JSON.parse` of a quoted value that isn't cut gives
22
+ * back exactly what was sent. Escaping rather than stripping keeps two
23
+ * identifiers that differ in invisible characters within the units a line
24
+ * prints from printing alike; canonically equivalent or lookalike
25
+ * identifiers still can.
26
+ *
27
+ * A value longer than 256 UTF-16 units is cut, so one value can't make a line
28
+ * long: it prints quoted, as its first 256 units (255 when the 256th is a
29
+ * high surrogate, so a pair is never split), each at most six characters once
30
+ * escaped, followed by `…(N)`, N being its full length. `JSON.parse` of the
31
+ * quoted part gives back those first units. A cut value always opens with a
32
+ * quote, and its marker follows the closing quote. A plain token never holds
33
+ * a quote, and a quoted value that isn't cut ends at its closing quote, so
34
+ * only a value that was cut prints as one.
35
+ *
36
+ * A value that isn't a string prints as its JSON text, quoted as any text is
37
+ * that isn't a plain token: the number 5 prints as `5`, and `{"toString":1}`
38
+ * as `"{\"toString\":1}"`. So the number 5 and the string "5" print alike, as
39
+ * they name the same message. A number JSON writes as `null` (NaN, Infinity)
40
+ * prints as `String()` of it. Converting a value never throws: one JSON can't
41
+ * write prints as `String()` of it, or else as its object tag, and one none
42
+ * of those can read (a revoked Proxy) as `[unprintable]`.
43
+ */
44
+ export function logValue(value: unknown): string {
45
+ return printed(textOf(value), FIELD);
46
+ }
47
+
48
+ /**
49
+ * The host's own message, as the free text that ends a log line after its
50
+ * fields: `reason=coalesce-invalid field=payload.content: "content must be an
51
+ * array"`. It prints as `logValue` prints a field, cut the same way, except
52
+ * that a plain space and `=` stay raw, for reading. That is safe because the
53
+ * text is the host's (an error the host raised), not an id a sender chose,
54
+ * and every real field of the line comes before it.
55
+ */
56
+ export function logDetail(value: unknown): string {
57
+ return printed(textOf(value), DETAIL);
58
+ }
59
+
60
+ /** How a form prints: the text that stands as it is, and what it escapes. */
61
+ interface Form {
62
+ plain: RegExp;
63
+ escaped: RegExp;
64
+ }
65
+
66
+ const FIELD: Form = {
67
+ plain: /^[^"\\=\p{C}\p{Z}\p{Default_Ignorable_Code_Point}\u2800]+$/u,
68
+ escaped: /[=\p{C}\p{Z}\p{Default_Ignorable_Code_Point}\u2800]/gu,
69
+ };
70
+
71
+ const DETAIL: Form = {
72
+ plain: /^[^"\\\p{C}\p{Z}\p{Default_Ignorable_Code_Point}\u2800]+$/u,
73
+ escaped: /(?! )[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}\u2800]/gu,
74
+ };
75
+
76
+ /** The most of a value's text a line prints, in UTF-16 units. */
77
+ const MAX_PRINTED = 256;
78
+
79
+ function printed(text: string, form: Form): string {
80
+ if (text.length <= MAX_PRINTED) return form.plain.test(text) ? text : quoted(text, form);
81
+ let end = MAX_PRINTED;
82
+ const last = text.charCodeAt(end - 1);
83
+ if (last >= 0xd800 && last <= 0xdbff) end--;
84
+ return `${quoted(text.slice(0, end), form)}…(${text.length})`;
85
+ }
86
+
87
+ function quoted(text: string, form: Form): string {
88
+ // JSON escapes C0 controls, quotes and backslashes and leaves the rest of
89
+ // the escaped set raw: escape those here, per UTF-16 unit, as JSON does.
90
+ return JSON.stringify(text).replace(form.escaped, escapeUnits);
91
+ }
92
+
93
+ function textOf(value: unknown): string {
94
+ if (typeof value === 'string') return value;
95
+ // JSON writes a non-finite number as null, which reads as a missing value.
96
+ if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
97
+ const readers = [
98
+ () => JSON.stringify(value) ?? String(value),
99
+ () => String(value),
100
+ () => Object.prototype.toString.call(value),
101
+ ];
102
+ for (const read of readers) {
103
+ try {
104
+ return read();
105
+ } catch {
106
+ // the next reader
107
+ }
108
+ }
109
+ return '[unprintable]';
110
+ }
111
+
112
+ function escapeUnits(chars: string): string {
113
+ let out = '';
114
+ for (let i = 0; i < chars.length; i++) out += `\\u${chars.charCodeAt(i).toString(16).padStart(4, '0')}`;
115
+ return out;
116
+ }
@@ -21,13 +21,13 @@
21
21
  *
22
22
  * Three modes, mirroring proseRouting:
23
23
  * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed;
24
- * one that resumes a turn starts at the turn's destination, and
25
- * each round's end returns the stream to it (retarget).
26
- * - hybrid: parse `>>>` envelopes, with the frozen turn locus as the
24
+ * a later stream of the turn starts at the turn's destination,
25
+ * and each round's end returns the stream to it (retarget).
26
+ * - hybrid: parse `>>>` envelopes, with the turn's locus as the
27
27
  * fallback. An envelope with no target, or `>>>skip_reply`,
28
28
  * holds back what follows until a later `>>>` names a target.
29
29
  * - locus: NO prefix parsing (locus mode's `>>` lines are plain text);
30
- * everything streams to the frozen turn locus.
30
+ * everything streams to the turn's locus.
31
31
  */
32
32
 
33
33
  export interface RoutedDelta {
@@ -52,9 +52,9 @@ export class ProseStreamRouter {
52
52
  private opts: {
53
53
  /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */
54
54
  mode: 'explicit' | 'hybrid' | 'locus';
55
- /** Initial destination: the frozen turn locus (locus and hybrid mode); in
55
+ /** Initial destination: the turn's locus (locus and hybrid mode); in
56
56
  * explicit mode, null on a fresh turn, or the turn's destination on a
57
- * stream that resumes one. */
57
+ * later stream of the turn. */
58
58
  initialTarget: string | null;
59
59
  /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */
60
60
  resolve: (spec: string) => string | null;
@@ -18,6 +18,7 @@
18
18
  * bookkeeping and is unit-tested without a framework.
19
19
  */
20
20
  import type { McplContentBlock, PushEventResult } from './types.js';
21
+ import { classifyBlock, isInlineContradiction } from './references.js';
21
22
  import { isVisiblyEmptyContent } from './visible-content.js';
22
23
 
23
24
  export const PUSH_COALESCING_SUPPORT = {
@@ -37,6 +38,18 @@ export class CoalesceError extends Error {
37
38
  }
38
39
  }
39
40
 
41
+ /**
42
+ * How the host names a coalesce rejection, from the code the wire answered
43
+ * with. `coalesce-failed` is the host's own failure: -32000 (coalescing
44
+ * unavailable, the host stopping, or an acceptance it couldn't make durable)
45
+ * and -32603 (an internal error). `coalesce-invalid` is every other answer,
46
+ * the sender's: -32602 for a malformed item, -32017 or -32023 for a channel
47
+ * that isn't permitted or isn't registered.
48
+ */
49
+ export function coalesceRejectionReason(answered: number): 'coalesce-failed' | 'coalesce-invalid' {
50
+ return answered === -32000 || answered === -32603 ? 'coalesce-failed' : 'coalesce-invalid';
51
+ }
52
+
40
53
  /** Content with nothing model-visible (see visible-content.ts): -32602. */
41
54
  export class EmptyContentError extends CoalesceError {
42
55
  readonly reason = 'empty-content';
@@ -212,7 +225,6 @@ export interface CoalescerOptions {
212
225
  recoveryBackoffMs?: number;
213
226
  maxSubjects?: number;
214
227
  maxNotices?: number;
215
- maxContentBytes?: number;
216
228
  maxDataBytes?: number;
217
229
  }
218
230
 
@@ -232,6 +244,32 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
232
244
  /**
233
245
  * Validate wire content before it is stored, rendered or converted.
234
246
  *
247
+ * Its shape and its visible emptiness, nothing else. An incoming coalesced
248
+ * occurrence reaches context through the converters an uncoalesced message
249
+ * goes through: a plain occurrence the moment it is admitted (the coalescer
250
+ * only remembers where it landed), a deferred one when its render settles or
251
+ * falls back to it. Its content must be an array of objects. A block the
252
+ * converters can't render (a video, an unknown type, an image with neither
253
+ * uri nor data) becomes a placeholder, as it does uncoalesced, and so does a
254
+ * malformed one, since the converters take any object without throwing: a
255
+ * type, text, data, mimeType or uri that isn't a string counts as absent, a
256
+ * text block's as no text. The uncoalesced lane doesn't check the array:
257
+ * there a member that isn't an object becomes the untyped placeholder, and a
258
+ * null member or content that isn't an array throws in conversion
259
+ * (agent-framework#297 brings that lane to this rule).
260
+ * Nor does the uncoalesced path set a byte budget on content:
261
+ * an inline image is admitted here as it is there (agent-framework#266: a
262
+ * 1 MiB serialized-content budget here dropped every coalesced message
263
+ * carrying a screenshot). A deferred occurrence's content is held, until then,
264
+ * in the coalescer's state and its persisted snapshot and receipts.
265
+ *
266
+ * A render result, which the host delivers in the occurrence's place, gets
267
+ * these checks, as RFC-006 §5.2 gives it every check a pushed payload gets,
268
+ * and one more (`checkBlockShapes`): each block must be one the converters
269
+ * show as itself or as a reference stub, not as a placeholder. A malformed
270
+ * render falls back to the occurrence's own content; a pushed payload has
271
+ * nothing to fall back to, and gets its placeholders.
272
+ *
235
273
  * Visibly-empty content is refused unless `allowEmpty`: an admitted
236
274
  * occurrence with nothing to show would wake the model with no visible cause.
237
275
  * Empty is legitimate only where the RFC gives it a meaning — a retraction
@@ -240,22 +278,38 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
240
278
  */
241
279
  export function validateCoalescedContent(
242
280
  content: unknown,
243
- maxBytes = 1024 * 1024,
244
- options: { allowEmpty?: boolean } = {},
281
+ options: { allowEmpty?: boolean; checkBlockShapes?: boolean } = {},
245
282
  ): asserts content is McplContentBlock[] {
246
283
  if (!Array.isArray(content)) throw new CoalesceError('payload.content', 'content must be an array');
247
284
  for (const b of content as Array<Record<string, unknown>>) {
248
285
  if (!b || typeof b !== 'object' || Array.isArray(b)) throw new CoalesceError('payload.content', 'invalid content block');
249
- if (b.type === 'text' && typeof b.text === 'string') continue;
250
- if (b.type === 'resource' && typeof b.uri === 'string') continue;
251
- if ((b.type === 'image' || b.type === 'audio')
252
- && (typeof b.uri === 'string' || (typeof b.data === 'string' && typeof b.mimeType === 'string'))) continue;
253
- throw new CoalesceError('payload.content', 'invalid content block');
286
+ if (options.checkBlockShapes && !showsAsItself(b)) throw new CoalesceError('payload.content', 'invalid content block');
254
287
  }
255
- if (Buffer.byteLength(JSON.stringify(content)) > maxBytes) throw new CoalesceError('payload.content', 'content exceeds host byte limit');
256
288
  if (!options.allowEmpty && isVisiblyEmptyContent(content)) throw new EmptyContentError();
257
289
  }
258
290
 
291
+ /**
292
+ * Whether the converters (`convertBlock` in push-handler.ts and
293
+ * channel-registry.ts) show a block as itself, its text or its image or
294
+ * audio source, or as a reference stub, and not as a placeholder: an
295
+ * unrecognized block, a no-data or no-media-type one, or withheld inline
296
+ * data. A text block whose text isn't a string, which they show as no text,
297
+ * is refused too. This follows their branches in their order.
298
+ */
299
+ function showsAsItself(b: Record<string, unknown>): boolean {
300
+ if (b.type === 'text') return typeof b.text === 'string';
301
+ if (b.type === 'resource') return classifyBlock(b).kind === 'reference';
302
+ if (b.type !== 'image' && b.type !== 'audio') return false;
303
+ if (isInlineContradiction(b)) return false;
304
+ if (isFilledString(b.uri) && b.disposition) return classifyBlock(b).kind === 'reference';
305
+ if (isFilledString(b.data) && isFilledString(b.mimeType)) return true;
306
+ return b.type === 'image' && isFilledString(b.uri);
307
+ }
308
+
309
+ function isFilledString(value: unknown): value is string {
310
+ return typeof value === 'string' && value !== '';
311
+ }
312
+
259
313
  /** Validate the `coalesce` member of either lane (§13). */
260
314
  export function validateCoalesceMember(
261
315
  c: unknown,
@@ -700,14 +754,24 @@ export class PushCoalescer<E = unknown> {
700
754
  try {
701
755
  const result = await this.host.render(occurrence, params);
702
756
  if (rendering.cancelled) { this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true }); }
703
- // §5.2: an empty result is legitimate ("nothing happened").
704
- else validateCoalescedContent(result?.content, this.options.maxContentBytes, { allowEmpty: true });
757
+ // §5.2: an empty result is legitimate ("nothing happened"); each
758
+ // block must be well formed.
759
+ else validateCoalescedContent(result?.content, { allowEmpty: true, checkBlockShapes: true });
705
760
  if (rendering.cancelled) throw new CancelledRender();
706
761
  content = result.content;
707
762
  timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
708
763
  source = 'render';
709
764
  } catch (error) {
710
- if (!(error instanceof CancelledRender)) this.host.audit({ kind: 'render-failed', subject, eventId: occurrence.eventId, error: String(error) });
765
+ if (!(error instanceof CancelledRender)) {
766
+ // The occurrence's own content is delivered instead; the host names
767
+ // the failure where operators look (a console line, not only this
768
+ // audit record).
769
+ this.host.audit({
770
+ kind: 'render-failed', subject, serverId: occurrence.serverId, eventId: occurrence.eventId,
771
+ ...(error instanceof CoalesceError ? { field: error.field } : {}),
772
+ error: String(error),
773
+ });
774
+ }
711
775
  } finally {
712
776
  const left = (this.rendersInFlight.get(occurrence.serverId) ?? 1) - 1;
713
777
  if (left > 0) this.rendersInFlight.set(occurrence.serverId, left); else this.rendersInFlight.delete(occurrence.serverId);