@animalabs/agent-framework 0.27.0 → 0.29.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 (344) hide show
  1. package/README.md +145 -41
  2. package/dist/src/agent.d.ts +29 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +73 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +3 -12
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/api/server.d.ts.map +1 -1
  10. package/dist/src/api/server.js +11 -5
  11. package/dist/src/api/server.js.map +1 -1
  12. package/dist/src/auto-continue.d.ts +5 -2
  13. package/dist/src/auto-continue.d.ts.map +1 -1
  14. package/dist/src/auto-continue.js +9 -5
  15. package/dist/src/auto-continue.js.map +1 -1
  16. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  17. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/runner.js +7 -5
  19. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +3 -2
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/cut-off-round.d.ts +24 -0
  24. package/dist/src/cut-off-round.d.ts.map +1 -0
  25. package/dist/src/cut-off-round.js +87 -0
  26. package/dist/src/cut-off-round.js.map +1 -0
  27. package/dist/src/framework.d.ts +468 -125
  28. package/dist/src/framework.d.ts.map +1 -1
  29. package/dist/src/framework.js +3651 -1111
  30. package/dist/src/framework.js.map +1 -1
  31. package/dist/src/gate/event-gate.d.ts +133 -13
  32. package/dist/src/gate/event-gate.d.ts.map +1 -1
  33. package/dist/src/gate/event-gate.js +654 -59
  34. package/dist/src/gate/event-gate.js.map +1 -1
  35. package/dist/src/gate/index.d.ts +3 -2
  36. package/dist/src/gate/index.d.ts.map +1 -1
  37. package/dist/src/gate/index.js +2 -1
  38. package/dist/src/gate/index.js.map +1 -1
  39. package/dist/src/gate/reply-judge.d.ts +116 -0
  40. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  41. package/dist/src/gate/reply-judge.js +467 -0
  42. package/dist/src/gate/reply-judge.js.map +1 -0
  43. package/dist/src/gate/types.d.ts +173 -2
  44. package/dist/src/gate/types.d.ts.map +1 -1
  45. package/dist/src/heartbeat/heartbeat.d.ts +202 -0
  46. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  47. package/dist/src/heartbeat/heartbeat.js +778 -0
  48. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  50. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  51. package/dist/src/heartbeat/reminder-time.js +107 -0
  52. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  53. package/dist/src/held-writes.d.ts +3 -3
  54. package/dist/src/held-writes.js +3 -3
  55. package/dist/src/host-presentation.d.ts +170 -0
  56. package/dist/src/host-presentation.d.ts.map +1 -0
  57. package/dist/src/host-presentation.js +561 -0
  58. package/dist/src/host-presentation.js.map +1 -0
  59. package/dist/src/index.d.ts +5 -1
  60. package/dist/src/index.d.ts.map +1 -1
  61. package/dist/src/index.js +3 -1
  62. package/dist/src/index.js.map +1 -1
  63. package/dist/src/line-reader.d.ts +4 -0
  64. package/dist/src/line-reader.d.ts.map +1 -0
  65. package/dist/src/line-reader.js +59 -0
  66. package/dist/src/line-reader.js.map +1 -0
  67. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  68. package/dist/src/mcpl/capability-mask.js +3 -2
  69. package/dist/src/mcpl/capability-mask.js.map +1 -1
  70. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  71. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  72. package/dist/src/mcpl/channel-registry.js +133 -54
  73. package/dist/src/mcpl/channel-registry.js.map +1 -1
  74. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  75. package/dist/src/mcpl/conversation-router.js +2 -1
  76. package/dist/src/mcpl/conversation-router.js.map +1 -1
  77. package/dist/src/mcpl/convert-block.d.ts +1 -0
  78. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  79. package/dist/src/mcpl/convert-block.js +16 -4
  80. package/dist/src/mcpl/convert-block.js.map +1 -1
  81. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  82. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  83. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  84. package/dist/src/mcpl/inbound-source.d.ts +29 -11
  85. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  86. package/dist/src/mcpl/inbound-source.js +43 -70
  87. package/dist/src/mcpl/inbound-source.js.map +1 -1
  88. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  89. package/dist/src/mcpl/modern-connection.js +11 -7
  90. package/dist/src/mcpl/modern-connection.js.map +1 -1
  91. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  92. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  93. package/dist/src/mcpl/protocol-family.js +25 -3
  94. package/dist/src/mcpl/protocol-family.js.map +1 -1
  95. package/dist/src/mcpl/push-handler.js +2 -2
  96. package/dist/src/mcpl/push-handler.js.map +1 -1
  97. package/dist/src/mcpl/references.d.ts +12 -40
  98. package/dist/src/mcpl/references.d.ts.map +1 -1
  99. package/dist/src/mcpl/references.js +29 -23
  100. package/dist/src/mcpl/references.js.map +1 -1
  101. package/dist/src/mcpl/server-connection.d.ts +18 -8
  102. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  103. package/dist/src/mcpl/server-connection.js +70 -30
  104. package/dist/src/mcpl/server-connection.js.map +1 -1
  105. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  106. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  107. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  108. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  109. package/dist/src/mcpl/tags.d.ts +9 -2
  110. package/dist/src/mcpl/tags.d.ts.map +1 -1
  111. package/dist/src/mcpl/tags.js +12 -2
  112. package/dist/src/mcpl/tags.js.map +1 -1
  113. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  114. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  115. package/dist/src/mcpl/tool-presence.js +150 -80
  116. package/dist/src/mcpl/tool-presence.js.map +1 -1
  117. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  118. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  119. package/dist/src/mcpl/tool-signature.js +60 -30
  120. package/dist/src/mcpl/tool-signature.js.map +1 -1
  121. package/dist/src/mcpl/toolset-host.d.ts +88 -55
  122. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  123. package/dist/src/mcpl/toolset-host.js +220 -111
  124. package/dist/src/mcpl/toolset-host.js.map +1 -1
  125. package/dist/src/mcpl/toolsets-module.d.ts +27 -35
  126. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  127. package/dist/src/mcpl/toolsets-module.js +313 -91
  128. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  129. package/dist/src/mcpl/transport.d.ts +1 -1
  130. package/dist/src/mcpl/transport.d.ts.map +1 -1
  131. package/dist/src/mcpl/transport.js +16 -16
  132. package/dist/src/mcpl/transport.js.map +1 -1
  133. package/dist/src/mcpl/types.d.ts +31 -13
  134. package/dist/src/mcpl/types.d.ts.map +1 -1
  135. package/dist/src/mcpl/types.js.map +1 -1
  136. package/dist/src/module-registry.d.ts +10 -8
  137. package/dist/src/module-registry.d.ts.map +1 -1
  138. package/dist/src/module-registry.js +15 -9
  139. package/dist/src/module-registry.js.map +1 -1
  140. package/dist/src/modules/api/index.d.ts.map +1 -1
  141. package/dist/src/modules/api/index.js +4 -2
  142. package/dist/src/modules/api/index.js.map +1 -1
  143. package/dist/src/modules/discord/index.d.ts.map +1 -1
  144. package/dist/src/modules/discord/index.js +33 -25
  145. package/dist/src/modules/discord/index.js.map +1 -1
  146. package/dist/src/modules/history/index.d.ts.map +1 -1
  147. package/dist/src/modules/history/index.js +30 -17
  148. package/dist/src/modules/history/index.js.map +1 -1
  149. package/dist/src/modules/sender-name.d.ts +7 -0
  150. package/dist/src/modules/sender-name.d.ts.map +1 -0
  151. package/dist/src/modules/sender-name.js +10 -0
  152. package/dist/src/modules/sender-name.js.map +1 -0
  153. package/dist/src/modules/workspace/answers.d.ts +26 -0
  154. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  155. package/dist/src/modules/workspace/answers.js +138 -0
  156. package/dist/src/modules/workspace/answers.js.map +1 -0
  157. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  158. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  159. package/dist/src/modules/workspace/branch-intent.js +6 -2
  160. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  161. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +59 -24
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  166. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  168. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  169. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  170. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/edit-text.js +4 -2
  172. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  173. package/dist/src/modules/workspace/effects.d.ts +4 -2
  174. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/effects.js +67 -50
  176. package/dist/src/modules/workspace/effects.js.map +1 -1
  177. package/dist/src/modules/workspace/index.d.ts +63 -51
  178. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/index.js +980 -547
  180. package/dist/src/modules/workspace/index.js.map +1 -1
  181. package/dist/src/modules/workspace/observe.d.ts +125 -82
  182. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/observe.js +465 -241
  184. package/dist/src/modules/workspace/observe.js.map +1 -1
  185. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/read-page.js +7 -1
  187. package/dist/src/modules/workspace/read-page.js.map +1 -1
  188. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  189. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/reconcile.js +156 -50
  191. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  192. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  193. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/result-text.js.map +1 -1
  195. package/dist/src/modules/workspace/sync.d.ts +9 -0
  196. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/sync.js +14 -0
  198. package/dist/src/modules/workspace/sync.js.map +1 -1
  199. package/dist/src/modules/workspace/types.d.ts +96 -43
  200. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  201. package/dist/src/modules/workspace/types.js.map +1 -1
  202. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  203. package/dist/src/modules/workspace/watcher.js +8 -3
  204. package/dist/src/modules/workspace/watcher.js.map +1 -1
  205. package/dist/src/notice-tags.d.ts +79 -0
  206. package/dist/src/notice-tags.d.ts.map +1 -0
  207. package/dist/src/notice-tags.js +83 -0
  208. package/dist/src/notice-tags.js.map +1 -0
  209. package/dist/src/prose-drafts.d.ts +4 -3
  210. package/dist/src/prose-drafts.d.ts.map +1 -1
  211. package/dist/src/prose-drafts.js +3 -2
  212. package/dist/src/prose-drafts.js.map +1 -1
  213. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  214. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  215. package/dist/src/reasoning-loss-notice.js +52 -0
  216. package/dist/src/reasoning-loss-notice.js.map +1 -0
  217. package/dist/src/reasoning-loss.d.ts +24 -0
  218. package/dist/src/reasoning-loss.d.ts.map +1 -0
  219. package/dist/src/reasoning-loss.js +102 -0
  220. package/dist/src/reasoning-loss.js.map +1 -0
  221. package/dist/src/send-tools.d.ts +3 -2
  222. package/dist/src/send-tools.d.ts.map +1 -1
  223. package/dist/src/send-tools.js +3 -2
  224. package/dist/src/send-tools.js.map +1 -1
  225. package/dist/src/sent-rows.js +1 -1
  226. package/dist/src/silent-heartbeat.d.ts +1 -53
  227. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  228. package/dist/src/silent-heartbeat.js +56 -3
  229. package/dist/src/silent-heartbeat.js.map +1 -1
  230. package/dist/src/supplied-text.d.ts +35 -0
  231. package/dist/src/supplied-text.d.ts.map +1 -0
  232. package/dist/src/supplied-text.js +141 -0
  233. package/dist/src/supplied-text.js.map +1 -0
  234. package/dist/src/thinking-drops.d.ts +4 -11
  235. package/dist/src/thinking-drops.d.ts.map +1 -1
  236. package/dist/src/thinking-drops.js +7 -14
  237. package/dist/src/thinking-drops.js.map +1 -1
  238. package/dist/src/tool-args.d.ts +12 -0
  239. package/dist/src/tool-args.d.ts.map +1 -1
  240. package/dist/src/tool-args.js +39 -4
  241. package/dist/src/tool-args.js.map +1 -1
  242. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  243. package/dist/src/tool-image-ledger.js +2 -1
  244. package/dist/src/tool-image-ledger.js.map +1 -1
  245. package/dist/src/tool-presentation.d.ts.map +1 -1
  246. package/dist/src/tool-presentation.js +5 -4
  247. package/dist/src/tool-presentation.js.map +1 -1
  248. package/dist/src/tool-result-guard.d.ts +8 -4
  249. package/dist/src/tool-result-guard.d.ts.map +1 -1
  250. package/dist/src/tool-result-guard.js +23 -11
  251. package/dist/src/tool-result-guard.js.map +1 -1
  252. package/dist/src/tune-out/coordinator.d.ts +3 -2
  253. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  254. package/dist/src/tune-out/coordinator.js +24 -26
  255. package/dist/src/tune-out/coordinator.js.map +1 -1
  256. package/dist/src/tune-out/tools.d.ts +4 -1
  257. package/dist/src/tune-out/tools.d.ts.map +1 -1
  258. package/dist/src/types/agent.d.ts +63 -18
  259. package/dist/src/types/agent.d.ts.map +1 -1
  260. package/dist/src/types/events.d.ts +18 -8
  261. package/dist/src/types/events.d.ts.map +1 -1
  262. package/dist/src/types/framework.d.ts +58 -0
  263. package/dist/src/types/framework.d.ts.map +1 -1
  264. package/dist/src/types/module.d.ts +27 -13
  265. package/dist/src/types/module.d.ts.map +1 -1
  266. package/dist/src/types/trace.d.ts +34 -1
  267. package/dist/src/types/trace.d.ts.map +1 -1
  268. package/package.json +3 -3
  269. package/src/agent.ts +76 -12
  270. package/src/api/mcp-server-core.ts +3 -14
  271. package/src/api/server.ts +11 -5
  272. package/src/auto-continue.ts +12 -5
  273. package/src/code-execution/isolate/runner.ts +7 -5
  274. package/src/code-execution/isolate/scripts.ts +3 -2
  275. package/src/cut-off-round.ts +98 -0
  276. package/src/framework.ts +3772 -1138
  277. package/src/gate/event-gate.ts +705 -60
  278. package/src/gate/index.ts +9 -1
  279. package/src/gate/reply-judge.ts +512 -0
  280. package/src/gate/types.ts +176 -3
  281. package/src/heartbeat/heartbeat.ts +843 -0
  282. package/src/heartbeat/reminder-time.ts +107 -0
  283. package/src/held-writes.ts +3 -3
  284. package/src/host-presentation.ts +605 -0
  285. package/src/index.ts +5 -1
  286. package/src/line-reader.ts +58 -0
  287. package/src/mcpl/capability-mask.ts +3 -2
  288. package/src/mcpl/channel-registry.ts +139 -53
  289. package/src/mcpl/conversation-router.ts +2 -1
  290. package/src/mcpl/convert-block.ts +17 -4
  291. package/src/mcpl/hook-orchestrator.ts +4 -3
  292. package/src/mcpl/inbound-source.ts +45 -70
  293. package/src/mcpl/modern-connection.ts +11 -7
  294. package/src/mcpl/protocol-family.ts +24 -3
  295. package/src/mcpl/push-handler.ts +2 -2
  296. package/src/mcpl/references.ts +31 -23
  297. package/src/mcpl/server-connection.ts +76 -34
  298. package/src/mcpl/skip-reply-wake.ts +35 -0
  299. package/src/mcpl/tags.ts +13 -2
  300. package/src/mcpl/tool-presence.ts +149 -82
  301. package/src/mcpl/tool-signature.ts +64 -32
  302. package/src/mcpl/toolset-host.ts +212 -110
  303. package/src/mcpl/toolsets-module.ts +277 -91
  304. package/src/mcpl/transport.ts +18 -14
  305. package/src/mcpl/types.ts +32 -13
  306. package/src/module-registry.ts +14 -9
  307. package/src/modules/api/index.ts +4 -2
  308. package/src/modules/discord/index.ts +37 -25
  309. package/src/modules/history/index.ts +30 -18
  310. package/src/modules/sender-name.ts +9 -0
  311. package/src/modules/workspace/answers.ts +151 -0
  312. package/src/modules/workspace/branch-intent.ts +10 -5
  313. package/src/modules/workspace/direct-write.ts +59 -23
  314. package/src/modules/workspace/disk-agreement.ts +52 -21
  315. package/src/modules/workspace/edit-text.ts +7 -5
  316. package/src/modules/workspace/effects.ts +69 -50
  317. package/src/modules/workspace/index.ts +966 -556
  318. package/src/modules/workspace/observe.ts +497 -242
  319. package/src/modules/workspace/read-page.ts +7 -1
  320. package/src/modules/workspace/reconcile.ts +184 -49
  321. package/src/modules/workspace/result-text.ts +2 -0
  322. package/src/modules/workspace/sync.ts +16 -0
  323. package/src/modules/workspace/types.ts +96 -43
  324. package/src/modules/workspace/watcher.ts +8 -3
  325. package/src/notice-tags.ts +84 -0
  326. package/src/prose-drafts.ts +4 -3
  327. package/src/reasoning-loss-notice.ts +64 -0
  328. package/src/reasoning-loss.ts +120 -0
  329. package/src/send-tools.ts +3 -2
  330. package/src/sent-rows.ts +1 -1
  331. package/src/silent-heartbeat.ts +4 -3
  332. package/src/supplied-text.ts +154 -0
  333. package/src/thinking-drops.ts +10 -16
  334. package/src/tool-args.ts +38 -4
  335. package/src/tool-image-ledger.ts +2 -1
  336. package/src/tool-presentation.ts +5 -4
  337. package/src/tool-result-guard.ts +26 -11
  338. package/src/tune-out/coordinator.ts +25 -27
  339. package/src/tune-out/tools.ts +4 -1
  340. package/src/types/agent.ts +63 -18
  341. package/src/types/events.ts +18 -8
  342. package/src/types/framework.ts +59 -0
  343. package/src/types/module.ts +28 -13
  344. package/src/types/trace.ts +36 -1
@@ -11,6 +11,8 @@
11
11
  * inference tokens, not whether the agent should know about the event.
12
12
  */
13
13
 
14
+ import { NOTICE_TAG } from '../notice-tags.js';
15
+ import { cutToFit, suppliedValue } from '../supplied-text.js';
14
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
15
17
  import { basename, dirname, join } from 'node:path';
16
18
  import { GateScript } from './gate-script.js';
@@ -33,10 +35,18 @@ import type {
33
35
  GateStatus,
34
36
  ShadowWarning,
35
37
  GateProbeRow,
38
+ JudgeConfig,
39
+ JudgeEventSummary,
40
+ JudgeFn,
41
+ JudgeVerdict,
42
+ UpcomingWake,
36
43
  } from './types.js';
37
44
 
38
45
  // Re-export types consumers need
39
- export type { GateConfig, GateOptions, GateDecision, GateEntryRef, GateEventInfo, WakeLine } from './types.js';
46
+ export type {
47
+ GateConfig, GateOptions, GateDecision, GateEntryRef, GateEventInfo, WakeLine,
48
+ JudgeConfig, JudgeEventSummary, JudgeFn, JudgeRequest, JudgeVerdict, ReplyJudgeOptions, UpcomingWake,
49
+ } from './types.js';
40
50
 
41
51
  // ---------------------------------------------------------------------------
42
52
  // Constants
@@ -63,6 +73,15 @@ function parseGateJson(text: string): unknown {
63
73
  const MIN_DEBOUNCE_MS = 100;
64
74
  /** Maximum debounce value (ms). */
65
75
  const MAX_DEBOUNCE_MS = 300_000;
76
+ /** Maximum `judge.maxWaitMs` (ms): an hour. */
77
+ const MAX_JUDGE_WAIT_MS = 3_600_000;
78
+ /** Longest `judge.instructions` (characters) a rule may hold: they go to the
79
+ * judge with every batch, so they're kept short. */
80
+ const MAX_JUDGE_INSTRUCTIONS = 4_000;
81
+ /** How long a judge request may take by default (ms) before onError decides. */
82
+ const DEFAULT_JUDGE_TIMEOUT_MS = 20_000;
83
+ /** Judge failures in a row after which gate_status names the judge as failing. */
84
+ const JUDGE_FAILING_STREAK = 3;
66
85
 
67
86
  /** Whether a check made at `last` is still fresh at `now`, less than the
68
87
  * reload throttle ago. A clock stepped backwards since makes it stale, so
@@ -131,10 +150,11 @@ interface PendingEvent {
131
150
 
132
151
  /** Where the host put a message the gate wrote: the agent whose window it
133
152
  * went to and, when it was held for that agent's live turn, the id it waits
134
- * under in the host's queue. */
153
+ * under in the host's queue, or else the id it was stored under. */
135
154
  export interface WritePlacement {
136
155
  agent?: string;
137
156
  deferredId?: string;
157
+ messageId?: string;
138
158
  }
139
159
 
140
160
  /**
@@ -182,6 +202,33 @@ export interface WakeProvenance {
182
202
  interface DebounceState {
183
203
  timer: ReturnType<typeof setTimeout>;
184
204
  events: PendingEvent[];
205
+ /** When the batch's first event arrived (ms), for a judge's maxWaitMs. */
206
+ firstAt: number;
207
+ /** When its timer fires (ms), as of its last re-arm (upcomingWakes). */
208
+ dueAt: number;
209
+ /** The judge the batch goes to when it fires, as its rule held it when the
210
+ * batch began: a rule removed or changed meanwhile has its batch judged
211
+ * as the rule it was asked. Absent for a debounce. */
212
+ judge?: JudgeConfig;
213
+ }
214
+
215
+ /** A batch the reply judge is deciding now. */
216
+ interface JudgingState {
217
+ events: PendingEvent[];
218
+ judge: JudgeConfig;
219
+ started: number;
220
+ abort: AbortController;
221
+ /** The judge's time limit, cleared once it's decided or the gate disposed. */
222
+ timer?: ReturnType<typeof setTimeout>;
223
+ /** Batches of the same rule that fired meanwhile, judged once this one is
224
+ * decided: delivered with it on reply, judged afresh on wait. */
225
+ queued: PendingEvent[];
226
+ }
227
+
228
+ /** A rule's verdict counts and newest verdict (gate_status judgeState). */
229
+ interface JudgeRecord {
230
+ verdicts: { reply: number; wait: number; error: number };
231
+ last?: NonNullable<GatePolicyStats['judgeState']>['last'];
185
232
  }
186
233
 
187
234
  /** Token bucket for one (policy, key) pair under a rate_limit behavior. */
@@ -298,7 +345,7 @@ function referenceLine(count: number, channel: string, oldest: number, now: numb
298
345
  /** A handed batched-wake line's text (WakeLine), as it reads when written at
299
346
  * `now`: the gate's words, its message's age counted to then. */
300
347
  export function wakeLineText(line: WakeLine, now: number): string {
301
- return `[Gate: 1 event matched]\n\n${referenceLine(1, line.shown, line.at, now)}`;
348
+ return `[${NOTICE_TAG.gate}: 1 event matched]\n\n${referenceLine(1, line.shown, line.at, now)}`;
302
349
  }
303
350
 
304
351
  /** The event's own stored entry, as the framework is asked about it. */
@@ -333,9 +380,21 @@ function formatBehavior(b: import('./types.js').GateBehavior): string {
333
380
  const k = b.passive_sample.keyBy ? `,keyBy:${b.passive_sample.keyBy}` : '';
334
381
  return `passive_sample:${b.passive_sample.every}${k}`;
335
382
  }
383
+ if ('judge' in b) {
384
+ const w = b.judge.maxWaitMs !== undefined ? `,maxWait:${b.judge.maxWaitMs}` : '';
385
+ return `judge:${b.judge.debounceMs}${w}`;
386
+ }
336
387
  return 'unknown';
337
388
  }
338
389
 
390
+ /** The quiet period (ms) of a batching behavior: a debounce's, or a judge's. */
391
+ function batchQuietMs(b: GateBehavior): number | undefined {
392
+ if (typeof b !== 'object') return undefined;
393
+ if ('debounce' in b) return b.debounce;
394
+ if ('judge' in b) return b.judge.debounceMs;
395
+ return undefined;
396
+ }
397
+
339
398
  /**
340
399
  * Stable fingerprint for a GateBehavior, used during reload to detect
341
400
  * config changes that invalidate per-policy state. JSON.stringify is
@@ -393,9 +452,10 @@ const MATCH_FIELDS = [
393
452
  'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
394
453
  ];
395
454
  const FILTER_FIELDS = ['type', 'pattern'];
396
- const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample'] as const;
455
+ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'] as const;
397
456
  const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
398
457
  const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
458
+ const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
399
459
 
400
460
  function isPlainObject(value: unknown): value is Record<string, unknown> {
401
461
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -600,6 +660,7 @@ function fieldLevels(file: string): ReadonlyArray<{ name: string; fields: readon
600
660
  { name: '"behavior"', fields: BEHAVIOR_KINDS },
601
661
  { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
602
662
  { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
663
+ { name: '"behavior.judge"', fields: JUDGE_FIELDS },
603
664
  ];
604
665
  }
605
666
 
@@ -840,7 +901,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
840
901
  }, (m) => {
841
902
  const of = (fields: readonly string[]) => m.keys.some((key) => fields.includes(key));
842
903
  if (obj.match === undefined && (of(MATCH_FIELDS) || of(FILTER_FIELDS))) return 'so the rule matches every event';
843
- if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS))) {
904
+ if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS) || of(JUDGE_FIELDS))) {
844
905
  return 'so the rule\'s behavior is "always"';
845
906
  }
846
907
  return undefined;
@@ -869,7 +930,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
869
930
 
870
931
  type BehaviorKind = 'always' | 'defer' | 'skip' | (typeof BEHAVIOR_KINDS)[number];
871
932
 
872
- const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit" and "passive_sample"';
933
+ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit", "passive_sample" and "judge"';
873
934
 
874
935
  /**
875
936
  * A rule's behavior: "always" when left out; one of the three names; or an
@@ -878,7 +939,8 @@ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one
878
939
  * and is read so; a list, or an object holding none of the kinds, was
879
940
  * refused, and the rule is left out. Of
880
941
  * two kinds or more, "debounce", then "rate_limit", then "passive_sample" was
881
- * the one read, whatever their order, and is read so. Each other kind is read
942
+ * the one read, whatever their order, and is read so; "judge", newer than
943
+ * those, is read only where none of them is given. Each other kind is read
882
944
  * on its own as well, so that a refusal names its problems too, and a load
883
945
  * reads past them, since it isn't read either way. `kind` is the behavior's
884
946
  * kind even where it can't be read, for passthrough's check.
@@ -899,7 +961,7 @@ function readBehavior(r: Reader, value: unknown): { behavior?: GateBehavior | un
899
961
  const kinds = BEHAVIOR_KINDS.filter((k) => b[k] !== undefined);
900
962
  const kind = kinds[0];
901
963
  if (kind === undefined) {
902
- r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit" and "passive_sample"' });
964
+ r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit", "passive_sample" and "judge"' });
903
965
  return {};
904
966
  }
905
967
  if (kinds.length > 1) {
@@ -1022,11 +1084,99 @@ function readPassiveSample(r: Reader, value: unknown): GateBehavior | undefined
1022
1084
  return { passive_sample: out };
1023
1085
  }
1024
1086
 
1087
+ /**
1088
+ * A judge: a debounce's quiet period, read as `debounce` is, with at its
1089
+ * nearest bound one out of range; an optional longest wait, from that
1090
+ * quiet period to an hour, likewise; and the agent's optional instructions to
1091
+ * the judge, text of at most MAX_JUDGE_INSTRUCTIONS characters. Earlier
1092
+ * releases knew no judge, so nothing deployed has one: a rule whose judge
1093
+ * can't be read is left out, and its instructions, which go to a model with
1094
+ * every batch, are never cut to fit.
1095
+ */
1096
+ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
1097
+ if (!isPlainObject(value)) {
1098
+ r.problems.push({
1099
+ text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
1100
+ });
1101
+ return undefined;
1102
+ }
1103
+ const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
1104
+ maxWaitMs: 'a batch is judged only after its quiet period',
1105
+ instructions: 'the judge decides without the agent\'s word',
1106
+ });
1107
+ readFieldNames(r, 'behavior.judge', conf, JUDGE_FIELDS);
1108
+ let ok = true;
1109
+ const ms = conf.debounceMs;
1110
+ let debounceMs: number | undefined;
1111
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
1112
+ r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
1113
+ ok = false;
1114
+ } else if (ms < MIN_DEBOUNCE_MS) {
1115
+ r.problems.push({
1116
+ text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
1117
+ });
1118
+ debounceMs = MIN_DEBOUNCE_MS;
1119
+ } else if (ms > MAX_DEBOUNCE_MS) {
1120
+ r.problems.push({
1121
+ text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
1122
+ });
1123
+ debounceMs = MAX_DEBOUNCE_MS;
1124
+ } else {
1125
+ debounceMs = ms;
1126
+ }
1127
+ let maxWaitMs: number | undefined;
1128
+ const wait = conf.maxWaitMs;
1129
+ if (wait !== undefined) {
1130
+ if (typeof wait !== 'number' || !Number.isFinite(wait)) {
1131
+ r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
1132
+ ok = false;
1133
+ } else {
1134
+ const floor = debounceMs ?? MIN_DEBOUNCE_MS;
1135
+ if (wait < floor) {
1136
+ r.problems.push({
1137
+ text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
1138
+ read: `so it's read as ${floor} ms`,
1139
+ });
1140
+ maxWaitMs = floor;
1141
+ } else if (wait > MAX_JUDGE_WAIT_MS) {
1142
+ r.problems.push({
1143
+ text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
1144
+ read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
1145
+ });
1146
+ maxWaitMs = MAX_JUDGE_WAIT_MS;
1147
+ } else {
1148
+ maxWaitMs = wait;
1149
+ }
1150
+ }
1151
+ }
1152
+ const said = conf.instructions;
1153
+ let instructions: string | undefined;
1154
+ if (said !== undefined) {
1155
+ if (typeof said !== 'string') {
1156
+ r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
1157
+ ok = false;
1158
+ } else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
1159
+ r.problems.push({
1160
+ text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
1161
+ });
1162
+ ok = false;
1163
+ } else {
1164
+ instructions = said;
1165
+ }
1166
+ }
1167
+ if (!ok || debounceMs === undefined) return undefined;
1168
+ const out: JudgeConfig = { debounceMs };
1169
+ if (maxWaitMs !== undefined) out.maxWaitMs = maxWaitMs;
1170
+ if (instructions !== undefined) out.instructions = instructions;
1171
+ return { judge: out };
1172
+ }
1173
+
1025
1174
  /** How each kind of behavior object is read. */
1026
1175
  const READ_KIND: Readonly<Record<(typeof BEHAVIOR_KINDS)[number], (r: Reader, value: unknown) => GateBehavior | undefined>> = {
1027
1176
  debounce: readDebounce,
1028
1177
  rate_limit: readRateLimit,
1029
1178
  passive_sample: readPassiveSample,
1179
+ judge: readJudge,
1030
1180
  };
1031
1181
 
1032
1182
  /** A timing kind's keyBy: a field name. One of another type was read as none. */
@@ -1677,30 +1827,72 @@ function globAtLeastAsBroad(earlier: string | undefined, later: string | undefin
1677
1827
  return earlier === later;
1678
1828
  }
1679
1829
 
1830
+ /** The event types the gate can see from an MCPL server: its pushes and its
1831
+ * channels' messages. */
1832
+ export const MCPL_GATE_EVENT_TYPES: readonly string[] = ['mcpl:push-event', 'mcpl:channel-incoming'];
1833
+
1834
+ /** The event types a host knows a source sends (EventGate's
1835
+ * `eventTypesFrom`), or undefined when it could send any. */
1836
+ export type EventTypesFrom = (source: string) => readonly string[] | undefined;
1837
+
1838
+ /** The event types `rule` can match: its scope's, narrowed to those its
1839
+ * source is known to send; 'all' when neither says. */
1840
+ function typesSeenBy(rule: GatePolicy, eventTypesFrom?: EventTypesFrom): { types: string[] | 'all'; known: boolean } {
1841
+ const scope = rule.match.scope && rule.match.scope.length > 0 ? rule.match.scope : undefined;
1842
+ const source = rule.match.source;
1843
+ let sent: readonly string[] | undefined;
1844
+ if (eventTypesFrom && source !== undefined && !source.includes('*')) {
1845
+ try {
1846
+ sent = eventTypesFrom(source);
1847
+ } catch {
1848
+ sent = undefined;
1849
+ }
1850
+ }
1851
+ if (scope && sent) return { types: scope.filter((t) => sent!.includes(t)), known: true };
1852
+ if (scope) return { types: scope, known: true };
1853
+ if (sent) return { types: [...sent], known: true };
1854
+ return { types: 'all', known: false };
1855
+ }
1856
+
1680
1857
  /**
1681
- * Event types for which `earlier` provably matches EVERY event that `later`
1682
- * would match — i.e. the types for which `later` is unreachable when
1683
- * `earlier` sits before it and consumes its matches. Returns null when
1684
- * subsumption can't be proven (which is not proof of safety — the lint is
1685
- * deliberately one-sided so every warning it emits is true).
1858
+ * Whether `earlier` provably matches events `later` would match, so that
1859
+ * `later` doesn't see them when `earlier` sits before it and consumes its
1860
+ * matches: the event types concerned, whether that's every event `later`
1861
+ * would match, and whether `later` is known to see those types. Returns null
1862
+ * when it can't be proven (which is not proof of safety — the lint is
1863
+ * deliberately one-sided so every note it emits is true).
1686
1864
  */
1687
- function subsumedEventTypes(earlier: GatePolicy, later: GatePolicy): string[] | 'all' | null {
1865
+ function subsumedEventTypes(
1866
+ earlier: GatePolicy,
1867
+ later: GatePolicy,
1868
+ eventTypesFrom?: EventTypesFrom,
1869
+ ): Omit<ShadowWarning, 'earlier' | 'later'> | null {
1688
1870
  const e = earlier.match;
1689
1871
  const l = later.match;
1690
1872
 
1691
- // Scope: which of `later`'s event types does `earlier` cover?
1692
- let covered: string[] | 'all';
1693
- if (!e.scope || e.scope.length === 0) {
1694
- covered = l.scope && l.scope.length > 0 ? l.scope : 'all';
1695
- } else if (!l.scope || l.scope.length === 0) {
1696
- // `later` matches all types; `earlier` only consumes its own scope, so
1697
- // the shadow is partial — exactly those types.
1698
- covered = e.scope;
1873
+ // Event types: which of those `later` can match does `earlier` cover?
1874
+ const seen = typesSeenBy(later, eventTypesFrom);
1875
+ const earlierTypes = e.scope && e.scope.length > 0 ? e.scope : 'all';
1876
+ let eventTypes: string[] | 'all';
1877
+ let whole: boolean;
1878
+ let known = seen.known;
1879
+ if (earlierTypes === 'all') {
1880
+ eventTypes = seen.types;
1881
+ whole = true;
1882
+ } else if (seen.types === 'all') {
1883
+ // `later` matches every type; `earlier` only consumes its own scope, so
1884
+ // the overlap is partial — exactly those types, which may never reach
1885
+ // `later`.
1886
+ eventTypes = earlierTypes;
1887
+ whole = false;
1699
1888
  } else {
1700
- const overlap = l.scope.filter((t) => e.scope!.includes(t));
1889
+ const overlap = seen.types.filter((t) => earlierTypes.includes(t));
1701
1890
  if (overlap.length === 0) return null;
1702
- covered = overlap;
1891
+ eventTypes = overlap;
1892
+ whole = overlap.length === seen.types.length;
1893
+ known = true;
1703
1894
  }
1895
+ if (Array.isArray(eventTypes) && eventTypes.length === 0) return null;
1704
1896
 
1705
1897
  // Every remaining discriminator on `earlier` must be at least as broad as
1706
1898
  // `later`'s, otherwise some `later` events slip past `earlier`.
@@ -1728,38 +1920,55 @@ function subsumedEventTypes(earlier: GatePolicy, later: GatePolicy): string[] |
1728
1920
  if (e.tagsAll && !subsetOf(e.tagsAll, l.tagsAll ?? [])) return null;
1729
1921
  if (e.tagsNone && !subsetOf(e.tagsNone, l.tagsNone ?? [])) return null;
1730
1922
 
1731
- return covered;
1923
+ return { eventTypes, whole, known };
1732
1924
  }
1733
1925
 
1734
1926
  /**
1735
- * Find rules made unreachable by earlier, broader rules (first match wins).
1927
+ * Find rules an earlier, broader rule keeps from applying (first match wins),
1928
+ * in whole or for some event types.
1736
1929
  * The 2026-08-08 Mythos incident is the canonical catch: a prepended
1737
1930
  * `{scope:[mcpl:channel-incoming]}` sampler silently made the
1738
1931
  * direct-address and mention rules dead for the whole incoming lane.
1739
1932
  * `passthrough` observers shadow nothing — non-firing matches fall through.
1933
+ * `eventTypesFrom` leaves out types a rule's source is known never to send:
1934
+ * a rule for an MCPL server's events isn't told about another rule that
1935
+ * takes every `external-message`.
1740
1936
  */
1741
- export function findShadowedPolicies(policies: GatePolicy[]): ShadowWarning[] {
1937
+ export function findShadowedPolicies(policies: GatePolicy[], eventTypesFrom?: EventTypesFrom): ShadowWarning[] {
1742
1938
  const out: ShadowWarning[] = [];
1743
1939
  for (let i = 0; i < policies.length; i++) {
1744
1940
  const earlier = policies[i];
1745
1941
  if (earlier.passthrough) continue;
1746
1942
  for (let j = i + 1; j < policies.length; j++) {
1747
- const covered = subsumedEventTypes(earlier, policies[j]);
1748
- if (covered) {
1749
- out.push({ earlier: earlier.name, later: policies[j].name, eventTypes: covered });
1943
+ const found = subsumedEventTypes(earlier, policies[j], eventTypesFrom);
1944
+ if (found) {
1945
+ out.push({ earlier: earlier.name, later: policies[j].name, ...found });
1750
1946
  }
1751
1947
  }
1752
1948
  }
1753
1949
  return out;
1754
1950
  }
1755
1951
 
1756
- /** Human-readable one-liner for a ShadowWarning, used in tool results,
1757
- * traces, and gate_status alike so the same hazard reads the same way
1758
- * everywhere. */
1952
+ /** A ShadowWarning in words, used in tool results, traces, and gate_status
1953
+ * alike so the same note reads the same way everywhere. It says which of
1954
+ * three it is (the rule never applies; it misses some of its events; it
1955
+ * may not be meant for those events at all) and what to do. */
1759
1956
  export function formatShadowWarning(w: ShadowWarning): string {
1760
- const types = w.eventTypes === 'all' ? 'ALL event types' : w.eventTypes.join(', ');
1761
- return `rule "${w.earlier}" makes "${w.later}" unreachable for ${types} `
1762
- + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`;
1957
+ const later = `"${w.later}"`;
1958
+ const earlier = `"${w.earlier}"`;
1959
+ const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore: "${w.earlier}")`;
1960
+ if (w.whole) {
1961
+ return `${later} never applies: ${earlier} comes first and matches every event ${later} would. `
1962
+ + `If ${later} should apply, ${move}, or narrow ${earlier}.`;
1963
+ }
1964
+ const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.join(', ');
1965
+ if (w.known) {
1966
+ return `${earlier} comes first and takes ${later}'s ${types} events, so ${later} applies only to its others. `
1967
+ + `If it should apply to those too, ${move}.`;
1968
+ }
1969
+ return `${earlier} comes first and takes every ${types} event, so ${later} sees none. `
1970
+ + `Nothing to do unless ${later} is meant for ${types} events; then ${move}. `
1971
+ + `A scope on ${later} naming the event types it's for clears this note.`;
1763
1972
  }
1764
1973
 
1765
1974
  /** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */
@@ -1846,6 +2055,28 @@ export class EventGate {
1846
2055
  // Debounce state
1847
2056
  private debounceTimers = new Map<string, DebounceState>();
1848
2057
 
2058
+ // Reply judge state (`judge` rules). The judge itself, when the host wires
2059
+ // one; without it a judge rule's batch is delivered as a debounce's.
2060
+ private judgeFn: JudgeFn | undefined;
2061
+ private judgeTimeoutMs: number;
2062
+ private judgeOnError: 'wake' | 'skip';
2063
+ /** Per rule, the batch the judge is deciding now. */
2064
+ private judging = new Map<string, JudgingState>();
2065
+ /** Per rule, a batch that fired while a turn changing the judged context ran or the host was
2066
+ * quiesced, held to be judged once both are clear: the judge reads the
2067
+ * context the turn leaves, which may already answer it. */
2068
+ private judgeHeld = new Map<string, { events: PendingEvent[]; judge: JudgeConfig }>();
2069
+ private judgeRecords = new Map<string, JudgeRecord>();
2070
+ private changesJudgedContextFn: ((agentName: string) => boolean) | undefined;
2071
+ /** Lifetime count of events dropped from batches waiting on the judge, at
2072
+ * the inference buffer's cap (MAX_INFERENCE_BUFFER per rule). */
2073
+ private droppedJudgeEvents = 0;
2074
+ /** Judge requests failed in a row, and the newest failure's reason: a host
2075
+ * whose provider can't serve the judge's model fails every time, which
2076
+ * gate_status says once it's a streak (judgeFailingNotice). */
2077
+ private judgeFailStreak = 0;
2078
+ private judgeLastFailure = '';
2079
+
1849
2080
  // Rate-limit state — policy name → key → bucket.
1850
2081
  private rateLimitBuckets = new Map<string, Map<string, RateBucket>>();
1851
2082
  // Passive-sample state — policy name → key → counter (count since last fire).
@@ -1865,10 +2096,12 @@ export class EventGate {
1865
2096
  // Per-policy stats
1866
2097
  private stats = new Map<string, PolicyStats>();
1867
2098
 
1868
- // Shadow-lint cache (see refreshShadowWarnings) — recomputed on every
1869
- // config change, fingerprinted so an unchanged hazard set doesn't re-trace.
1870
- private shadowWarningsCache: ShadowWarning[] = [];
2099
+ // The shadow lint's last findings (see refreshShadowWarnings), recomputed
2100
+ // on every config change and fingerprinted so an unchanged set doesn't
2101
+ // re-trace.
1871
2102
  private shadowWarningsFingerprint = '[]';
2103
+ /** See the constructor's `eventTypesFrom`. */
2104
+ private eventTypesFromFn: EventTypesFrom | undefined;
1872
2105
 
1873
2106
  // Observability counters for events that didn't match any policy
1874
2107
  private totalEvaluations = 0;
@@ -1896,6 +2129,10 @@ export class EventGate {
1896
2129
  // self-wake (an external wake supersedes it; the timer would otherwise
1897
2130
  // land as a redundant empty wake right after the turn).
1898
2131
  private selfWakeTimers = new Map<string, ReturnType<typeof setTimeout>>();
2132
+ /** When each agent's pending self-wake fires, and what armed it
2133
+ * (upcomingWakes). Read only while its timer is in selfWakeTimers, so an
2134
+ * entry a fire or a cancel left behind says nothing. */
2135
+ private selfWakeDue = new Map<string, { at: number; source: string }>();
1899
2136
 
1900
2137
  // Privileged users who may wake the agent through sleep. Hot-reloaded from
1901
2138
  // privilegedUsersPath on change.
@@ -1947,6 +2184,18 @@ export class EventGate {
1947
2184
  now?: () => number;
1948
2185
  /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
1949
2186
  scriptTimeoutMs?: number;
2187
+ /** The reply judge `judge` rules ask (JudgeConfig). Absent: a judge
2188
+ * rule's batch is delivered as a debounce's, and gate_status says so. */
2189
+ judge?: JudgeFn;
2190
+ /** Whether a turn of this agent changes the context the judge reads. Such
2191
+ * turns, and only they, hold a judge rule's batch until they end, and one
2192
+ * that starts while the judge decides stops the judge, whose batch is
2193
+ * then judged after the turn. Absent: every agent's turns do. */
2194
+ changesJudgedContext?: (agentName: string) => boolean;
2195
+ /** How long a judge request may take (ms). Default 20,000. */
2196
+ judgeTimeoutMs?: number;
2197
+ /** What a judge failure does: wake (default) or skip. */
2198
+ judgeOnError?: 'wake' | 'skip';
1950
2199
  /** Map an event to the registered channel it arrived on (the host knows
1951
2200
  * how push-event origins compose into channel ids). The gate routes a
1952
2201
  * batched wake by it, tests a policy's `match.channel` against it when
@@ -1967,6 +2216,11 @@ export class EventGate {
1967
2216
  * (WakeProvenance.line), for a fresh start of the wake's turn to decide
1968
2217
  * (WakeLine). Absent: every batched wake writes its line, as before. */
1969
2218
  isNewestEntry?: (entry: GateEntryRef) => boolean;
2219
+ /** The event types the gate can see from a source, when the host knows
2220
+ * them (an MCPL server's id: MCPL_GATE_EVENT_TYPES), else undefined.
2221
+ * The rule-order notes (findShadowedPolicies) leave out types a rule's
2222
+ * source never sends. Absent: any source can send any type. */
2223
+ eventTypesFrom?: EventTypesFrom;
1970
2224
  }) {
1971
2225
  this.configPath = opts.configPath;
1972
2226
  this.privilegedUsersPath = opts.privilegedUsersPath;
@@ -1977,7 +2231,14 @@ export class EventGate {
1977
2231
  this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
1978
2232
  this.channelShownFn = opts.channelShown;
1979
2233
  this.isNewestEntryFn = opts.isNewestEntry;
2234
+ this.eventTypesFromFn = opts.eventTypesFrom;
1980
2235
  this.now = opts.now ?? (() => Date.now());
2236
+ this.judgeFn = opts.judge;
2237
+ this.changesJudgedContextFn = opts.changesJudgedContext;
2238
+ this.judgeTimeoutMs = opts.judgeTimeoutMs !== undefined && Number.isFinite(opts.judgeTimeoutMs) && opts.judgeTimeoutMs > 0
2239
+ ? Math.min(opts.judgeTimeoutMs, MAX_TIMER_MS)
2240
+ : DEFAULT_JUDGE_TIMEOUT_MS;
2241
+ this.judgeOnError = opts.judgeOnError === 'skip' ? 'skip' : 'wake';
1981
2242
  this.loadPrivileged();
1982
2243
 
1983
2244
  // Seed or reconcile the on-disk config from the host's starting rules
@@ -2488,6 +2749,7 @@ export class EventGate {
2488
2749
  for (const name of removed) {
2489
2750
  this.clearPolicyState(name);
2490
2751
  this.stats.delete(name);
2752
+ this.judgeRecords.delete(name);
2491
2753
  }
2492
2754
  const cleared = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
2493
2755
  for (const name of cleared) this.clearPolicyState(name);
@@ -2963,7 +3225,7 @@ export class EventGate {
2963
3225
  }
2964
3226
 
2965
3227
  if (typeof policy.behavior === 'object') {
2966
- if ('debounce' in policy.behavior) {
3228
+ if ('debounce' in policy.behavior || 'judge' in policy.behavior) {
2967
3229
  this.handleDebounce(policy, info);
2968
3230
  return { trigger: false, policyName: policy.name, behavior: policy.behavior };
2969
3231
  }
@@ -3115,7 +3377,7 @@ export class EventGate {
3115
3377
  if (b === 'always') return true;
3116
3378
  if (b === 'defer' || b === 'skip') return false;
3117
3379
  if (typeof b === 'object') {
3118
- if ('debounce' in b) return false; // batches; never triggers inline
3380
+ if ('debounce' in b || 'judge' in b) return false; // batches; never triggers inline
3119
3381
  if ('rate_limit' in b) {
3120
3382
  const cfg = b.rate_limit;
3121
3383
  const key = this.resolveKey(info, cfg.keyBy);
@@ -3255,22 +3517,32 @@ export class EventGate {
3255
3517
  /** Current shadow-lint findings for the live policy order. Fresh compute —
3256
3518
  * cheap (policy lists are tens of entries at most). */
3257
3519
  lintShadows(): ShadowWarning[] {
3258
- return findShadowedPolicies(this.config.policies);
3520
+ return findShadowedPolicies(this.config.policies, this.eventTypesFromFn);
3259
3521
  }
3260
3522
 
3261
3523
  /**
3262
- * Recompute shadow warnings and, when the set changed and is non-empty,
3263
- * emit a `gate:shadow-warnings` trace so the hazard lands in ops
3264
- * observability at the moment the order changed — not at the next missed
3265
- * DM. Called from writeConfig and reloadIfChanged.
3524
+ * The sources the host knows changed (eventTypesFrom answers differently),
3525
+ * as when an MCPL server connects or is removed: the rule-order notes are
3526
+ * made again, and traced if they changed, as a change to the rules would.
3527
+ */
3528
+ sourcesChanged(): void {
3529
+ this.refreshShadowWarnings('sources-changed');
3530
+ }
3531
+
3532
+ /**
3533
+ * Recompute shadow warnings and, when the set changed, emit a
3534
+ * `gate:shadow-warnings` trace so the hazard lands in ops observability at
3535
+ * the moment the order changed — not at the next missed DM — and, with an
3536
+ * empty list, the moment the last of them went. Called from writeConfig,
3537
+ * reloadIfChanged and sourcesChanged.
3266
3538
  */
3267
3539
  private refreshShadowWarnings(reason: string): void {
3268
3540
  const warnings = this.lintShadows();
3269
3541
  const fingerprint = JSON.stringify(warnings);
3270
3542
  if (fingerprint === this.shadowWarningsFingerprint) return;
3543
+ const hadSome = this.shadowWarningsFingerprint !== '[]';
3271
3544
  this.shadowWarningsFingerprint = fingerprint;
3272
- this.shadowWarningsCache = warnings;
3273
- if (warnings.length > 0) {
3545
+ if (warnings.length > 0 || hadSome) {
3274
3546
  this.emitTrace({
3275
3547
  type: 'gate:shadow-warnings',
3276
3548
  reason,
@@ -3316,7 +3588,8 @@ export class EventGate {
3316
3588
  }
3317
3589
 
3318
3590
  private handleDebounce(policy: GatePolicy, info: GateEventInfo): void {
3319
- const debounceMs = (policy.behavior as { debounce: number }).debounce;
3591
+ const debounceMs = batchQuietMs(policy.behavior) ?? MIN_DEBOUNCE_MS;
3592
+ const judge = typeof policy.behavior === 'object' && 'judge' in policy.behavior ? policy.behavior.judge : undefined;
3320
3593
 
3321
3594
  const event: PendingEvent = {
3322
3595
  policyName: policy.name,
@@ -3340,13 +3613,23 @@ export class EventGate {
3340
3613
  };
3341
3614
 
3342
3615
  const existing = this.debounceTimers.get(policy.name);
3616
+ // A judge's maxWaitMs caps how far new events push the batch back: it's
3617
+ // judged by then, from its first event, quiet or not.
3618
+ const delay = (firstAt: number, batchJudge: JudgeConfig | undefined): number => {
3619
+ const cap = batchJudge?.maxWaitMs;
3620
+ return cap === undefined ? debounceMs : Math.max(0, Math.min(debounceMs, firstAt + cap - this.now()));
3621
+ };
3343
3622
  if (existing) {
3344
3623
  clearTimeout(existing.timer);
3345
3624
  existing.events.push(event);
3346
- existing.timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
3625
+ const ms = delay(existing.firstAt, existing.judge);
3626
+ existing.timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3627
+ existing.dueAt = this.now() + ms;
3347
3628
  } else {
3348
- const timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
3349
- this.debounceTimers.set(policy.name, { timer, events: [event] });
3629
+ const firstAt = this.now();
3630
+ const ms = delay(firstAt, judge);
3631
+ const timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3632
+ this.debounceTimers.set(policy.name, { timer, events: [event], firstAt, dueAt: firstAt + ms, ...(judge ? { judge } : {}) });
3350
3633
  }
3351
3634
  }
3352
3635
 
@@ -3360,6 +3643,14 @@ export class EventGate {
3360
3643
  const events = state.events;
3361
3644
  this.debounceTimers.delete(policyName);
3362
3645
 
3646
+ // A judge rule's batch goes to the reply judge, which delivers it, or
3647
+ // doesn't, once it has decided (judgeBatch). Without a judge wired it's
3648
+ // delivered below, as a debounce's batch is.
3649
+ if (state.judge && this.judgeFn) {
3650
+ this.judgeBatch(policyName, events, state.judge);
3651
+ return;
3652
+ }
3653
+
3363
3654
  // If any agent is currently inferring — or the host is quiesced — buffer
3364
3655
  // for later (with cap). While quiesced, delivering would write a
3365
3656
  // "[Gate: N events]" message into the very context being operated on
@@ -3409,7 +3700,7 @@ export class EventGate {
3409
3700
  const quoted = events.filter(e => !e.inContext);
3410
3701
  const referenced = events.filter(e => e.inContext);
3411
3702
 
3412
- const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`);
3703
+ const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${suppliedValue(e.content)}`);
3413
3704
  // How each referenced channel is named in the line, in order.
3414
3705
  const shown: string[] = [];
3415
3706
  if (referenced.length > 0) {
@@ -3428,7 +3719,7 @@ export class EventGate {
3428
3719
  ? '(unknown channel)'
3429
3720
  : e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn
3430
3721
  ? this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId })
3431
- : id;
3722
+ : suppliedValue(id);
3432
3723
  const key = JSON.stringify([e.serverId ?? null, id ?? null]);
3433
3724
  const entry = byChannel.get(key) ?? { name, count: 0, oldest: e.timestamp, label: e.channelLabel };
3434
3725
  entry.count += 1;
@@ -3441,7 +3732,7 @@ export class EventGate {
3441
3732
  // agent can still address tools that want the composite id. A name
3442
3733
  // may carry its own `#`, so one is added only where the name lacks
3443
3734
  // it, as at the other places that add a `#`.
3444
- const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${name})` : name;
3735
+ const channel = label ? `${label.startsWith('#') ? '' : '#'}${suppliedValue(label)} (${name})` : name;
3445
3736
  shown.push(channel);
3446
3737
  lines.push(referenceLine(count, channel, oldest, Date.now()));
3447
3738
  }
@@ -3454,7 +3745,7 @@ export class EventGate {
3454
3745
  // gate hands the line to the wake (WakeProvenance.line) and a fresh start
3455
3746
  // of the wake's turn decides it (WakeLine). Several messages, one with
3456
3747
  // anything after it, or content the line quotes, keep the line now.
3457
- const text = `[Gate: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
3748
+ const text = `[${NOTICE_TAG.gate}: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
3458
3749
  const only = events.length === 1 && events[0].inContext ? events[0] : undefined;
3459
3750
  let line: WakeLine | undefined;
3460
3751
  if (only && this.newestEntry(only)) {
@@ -3499,12 +3790,303 @@ export class EventGate {
3499
3790
  }
3500
3791
  }
3501
3792
 
3793
+ // =========================================================================
3794
+ // Reply judge (`judge` rules)
3795
+ // =========================================================================
3796
+
3797
+ /**
3798
+ * Send one of a judge rule's batches to the reply judge. While a resident's
3799
+ * turn runs or the host is quiesced the batch is held instead, merged with
3800
+ * any the rule already holds, and judged once both are clear
3801
+ * (flushJudgeHeldIfClear): the judge then reads the context the turn left,
3802
+ * which may already have answered it. While the judge decides one of the
3803
+ * rule's batches, a later one waits for that verdict (finishJudging), so the
3804
+ * judge never decides two of a rule's batches at once.
3805
+ */
3806
+ private judgeBatch(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
3807
+ if (this.disposed) return;
3808
+ const deciding = this.judging.get(policyName);
3809
+ if (deciding) {
3810
+ deciding.queued.push(...events);
3811
+ this.capJudgeEvents(deciding.queued);
3812
+ return;
3813
+ }
3814
+ if (this.judgedTurnRunning() || this.quiesced) {
3815
+ this.holdForJudge(policyName, events, judge);
3816
+ return;
3817
+ }
3818
+ const fn = this.judgeFn;
3819
+ if (!fn) {
3820
+ this.deliverOrBuffer(events);
3821
+ return;
3822
+ }
3823
+ const abort = new AbortController();
3824
+ const agentNames = this.getAgentNamesFn();
3825
+ const state: JudgingState = { events, judge, started: Date.now(), abort, queued: [] };
3826
+ this.judging.set(policyName, state);
3827
+ const timedOut = new Promise<never>((_, reject) => {
3828
+ state.timer = setTimeout(() => {
3829
+ abort.abort();
3830
+ reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
3831
+ }, this.judgeTimeoutMs);
3832
+ // Never keep the process alive on the judge's account.
3833
+ state.timer.unref?.();
3834
+ });
3835
+ let asked: Promise<JudgeVerdict>;
3836
+ try {
3837
+ asked = fn({
3838
+ policyName,
3839
+ ...(judge.instructions !== undefined ? { instructions: judge.instructions } : {}),
3840
+ events: events.map((e) => this.judgeEventSummary(e)),
3841
+ agentNames,
3842
+ upcomingWakes: this.upcomingWakes(agentNames, policyName),
3843
+ signal: abort.signal,
3844
+ });
3845
+ } catch (err) {
3846
+ asked = Promise.reject(err);
3847
+ }
3848
+ Promise.race([asked, timedOut]).then(
3849
+ (verdict) => this.finishJudging(policyName, state, verdict),
3850
+ (err: unknown) => this.finishJudging(policyName, state, undefined, err),
3851
+ ).finally(() => {
3852
+ if (state.timer) clearTimeout(state.timer);
3853
+ }).catch((err: unknown) => {
3854
+ console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
3855
+ });
3856
+ }
3857
+
3858
+ /** Hold a batch for the judge until no turn changing the context it reads
3859
+ * runs and the host isn't quiesced, merged with what the rule already holds. */
3860
+ private holdForJudge(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
3861
+ const held = this.judgeHeld.get(policyName);
3862
+ if (held) {
3863
+ held.events.push(...events);
3864
+ held.judge = judge;
3865
+ this.capJudgeEvents(held.events);
3866
+ } else {
3867
+ const fresh = [...events];
3868
+ this.capJudgeEvents(fresh);
3869
+ this.judgeHeld.set(policyName, { events: fresh, judge });
3870
+ }
3871
+ }
3872
+
3873
+ /**
3874
+ * Keep a rule's events waiting on the judge to the inference buffer's cap,
3875
+ * dropping the oldest, counted and logged as bufferForInference counts its
3876
+ * drops: a turn that never ends (the wake-wedge) or a long quiesce on a busy
3877
+ * channel would otherwise grow them without bound. A queue beside a batch
3878
+ * the judge is deciding is capped on its own, so its newest events are never
3879
+ * the ones dropped for the batch ahead of it.
3880
+ */
3881
+ private capJudgeEvents(events: PendingEvent[]): void {
3882
+ const drop = events.length - MAX_INFERENCE_BUFFER;
3883
+ if (drop <= 0) return;
3884
+ events.splice(0, drop);
3885
+ const before = this.droppedJudgeEvents;
3886
+ this.droppedJudgeEvents += drop;
3887
+ if (Math.floor(before / 50) !== Math.floor(this.droppedJudgeEvents / 50) || before === 0) {
3888
+ console.error(
3889
+ `[gate] events waiting on the reply judge reached the cap (${MAX_INFERENCE_BUFFER} per rule): dropped `
3890
+ + `${this.droppedJudgeEvents} oldest so far${this.quiesced ? ' during quiesce' : ''}`,
3891
+ );
3892
+ }
3893
+ }
3894
+
3895
+ /** The rule's judge as it is now, when the rule is still a judge rule; else the one the batch was asked with. */
3896
+ private currentJudge(policyName: string, asked: JudgeConfig): JudgeConfig {
3897
+ const now = this.config.policies.find((p) => p.name === policyName)?.behavior;
3898
+ return typeof now === 'object' && 'judge' in now ? now.judge : asked;
3899
+ }
3900
+
3901
+ /**
3902
+ * The gate's own timers that will wake `agentNames` with no new event, for
3903
+ * the judge to weigh what waiting costs: each agent's pending self-wake
3904
+ * (skip_reply's wake_in_seconds), the end of a sleep, and the pending
3905
+ * batches of rules other than `exceptPolicy` (a debounce's wakes the agent
3906
+ * when it fires; a judge rule's goes to the judge, so it only may).
3907
+ */
3908
+ upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] {
3909
+ const wakes: UpcomingWake[] = [];
3910
+ const now = this.now();
3911
+ for (const name of agentNames) {
3912
+ const due = this.selfWakeDue.get(name);
3913
+ if (due && this.selfWakeTimers.has(name)) {
3914
+ wakes.push({ at: due.at, certain: true, what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
3915
+ }
3916
+ }
3917
+ if (this.sleepUntil > now && (this.sleepAgent === undefined || agentNames.includes(this.sleepAgent))) {
3918
+ wakes.push({ at: this.sleepUntil, certain: true, what: 'the end of its sleep' });
3919
+ }
3920
+ for (const [name, state] of this.debounceTimers) {
3921
+ if (name === exceptPolicy || state.events.length === 0) continue;
3922
+ const n = state.events.length;
3923
+ const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
3924
+ wakes.push(state.judge
3925
+ ? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
3926
+ : { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
3927
+ }
3928
+ return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
3929
+ }
3930
+
3931
+ /** A batch's event as the judge is shown it. */
3932
+ private judgeEventSummary(e: PendingEvent): JudgeEventSummary {
3933
+ const id = e.routeChannelId ?? e.channelId;
3934
+ let channel: string | undefined;
3935
+ if (id !== undefined) {
3936
+ let name = id;
3937
+ if (e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn) {
3938
+ try {
3939
+ name = this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId });
3940
+ } catch {
3941
+ name = id;
3942
+ }
3943
+ }
3944
+ channel = e.channelLabel ? `${e.channelLabel.startsWith('#') ? e.channelLabel : `#${e.channelLabel}`} (${name})` : name;
3945
+ }
3946
+ return {
3947
+ eventType: e.eventType,
3948
+ ...(channel !== undefined ? { channel } : {}),
3949
+ ...(e.authorId !== undefined ? { authorId: e.authorId } : {}),
3950
+ addressed: e.addressed,
3951
+ content: e.content,
3952
+ at: e.timestamp,
3953
+ };
3954
+ }
3955
+
3956
+ /**
3957
+ * Apply the judge's verdict on a batch. Reply: the batch, with any of the
3958
+ * rule's batches that fired meanwhile, is delivered as a debounce's is
3959
+ * (held for a turn that started meanwhile, as fireDebounce holds one). Wait:
3960
+ * nothing is delivered, and batches that fired meanwhile go to the judge
3961
+ * afresh. A judge that failed, timed out or answered nothing usable decides
3962
+ * by onError: "wake" delivers, "skip" doesn't. Each verdict is traced and
3963
+ * counted for gate_status, and a failure is logged.
3964
+ */
3965
+ private finishJudging(policyName: string, state: JudgingState, verdict: JudgeVerdict | undefined, err?: unknown): void {
3966
+ // A verdict for a batch the gate let go of (dispose() clears what the
3967
+ // judge was deciding) applies to nothing, even after a start() since.
3968
+ if (this.judging.get(policyName) !== state) return;
3969
+ this.judging.delete(policyName);
3970
+ if (state.timer) clearTimeout(state.timer);
3971
+ const durationMs = Date.now() - state.started;
3972
+ const failed = verdict == null || typeof verdict !== 'object' || typeof verdict.reply !== 'boolean';
3973
+ if (failed && (err as { providerAdmission?: unknown } | undefined)?.providerAdmission === 'deferred') {
3974
+ const wakes = this.judgeOnError === 'wake';
3975
+ console.error(
3976
+ `[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3977
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
3978
+ );
3979
+ if (this.disposed) return;
3980
+ if (wakes) this.deliverOrBuffer([...state.events, ...state.queued]);
3981
+ else if (state.queued.length > 0) this.judgeBatch(policyName, state.queued, this.currentJudge(policyName, state.judge));
3982
+ return;
3983
+ }
3984
+ const outcome: 'reply' | 'wait' | 'error' = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
3985
+ const reason = failed
3986
+ ? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
3987
+ : typeof verdict.reason === 'string' ? verdict.reason : '';
3988
+ const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
3989
+ record.verdicts[outcome]++;
3990
+ record.last = {
3991
+ at: Date.now(),
3992
+ verdict: outcome,
3993
+ reason: cutToFit(suppliedValue(reason), 300),
3994
+ eventCount: state.events.length,
3995
+ durationMs,
3996
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3997
+ };
3998
+ if (this.config.policies.some((p) => p.name === policyName)) this.judgeRecords.set(policyName, record);
3999
+ if (failed) {
4000
+ this.judgeFailStreak++;
4001
+ this.judgeLastFailure = cutToFit(suppliedValue(reason), 200);
4002
+ } else {
4003
+ this.judgeFailStreak = 0;
4004
+ }
4005
+ const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
4006
+ if (failed) {
4007
+ console.error(
4008
+ `[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
4009
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
4010
+ );
4011
+ }
4012
+ this.emitTrace({
4013
+ type: 'gate:judge-verdict',
4014
+ policyName,
4015
+ verdict: outcome,
4016
+ woke: wake,
4017
+ eventCount: state.events.length,
4018
+ durationMs,
4019
+ ...(!failed && verdict.model ? { model: verdict.model } : {}),
4020
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
4021
+ ...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
4022
+ ...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
4023
+ timestamp: Date.now(),
4024
+ });
4025
+ if (this.disposed) return;
4026
+ const judge = this.currentJudge(policyName, state.judge);
4027
+ if (wake) {
4028
+ this.deliverOrBuffer([...state.events, ...state.queued]);
4029
+ return;
4030
+ }
4031
+ if (state.queued.length > 0) this.judgeBatch(policyName, state.queued, judge);
4032
+ }
4033
+
4034
+ /** Deliver a batch now, or hold it for the agents' turns and the host's
4035
+ * quiesce to end, as fireDebounce does; a delivery that throws drops it,
4036
+ * named in the log. */
4037
+ private deliverOrBuffer(events: PendingEvent[]): void {
4038
+ if (events.length === 0) return;
4039
+ if (this.inferring.size > 0 || this.quiesced) {
4040
+ this.bufferForInference(events);
4041
+ return;
4042
+ }
4043
+ try {
4044
+ this.deliverEvents(events);
4045
+ } catch (err) {
4046
+ const one = events.length === 1;
4047
+ console.error(
4048
+ `[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
4049
+ + `(${errorText(err)}), so the batch is dropped`,
4050
+ );
4051
+ }
4052
+ }
4053
+
4054
+ private changesJudgedContext(agentName: string): boolean {
4055
+ return this.changesJudgedContextFn?.(agentName) ?? true;
4056
+ }
4057
+
4058
+ private judgedTurnRunning(): boolean {
4059
+ return [...this.inferring].some((name) => this.changesJudgedContext(name));
4060
+ }
4061
+
4062
+ private stopJudging(): void {
4063
+ for (const [policyName, state] of [...this.judging]) {
4064
+ this.judging.delete(policyName);
4065
+ if (state.timer) clearTimeout(state.timer);
4066
+ state.abort.abort();
4067
+ this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge);
4068
+ }
4069
+ }
4070
+
4071
+ /** Send the batches held for the judge once no turn changing the context it reads runs and the host isn't quiesced. */
4072
+ private flushJudgeHeldIfClear(): void {
4073
+ if (this.quiesced || this.judgedTurnRunning() || this.judgeHeld.size === 0) return;
4074
+ const held = [...this.judgeHeld];
4075
+ this.judgeHeld.clear();
4076
+ // Deferred, as the inference buffer's flush is, to stay out of the
4077
+ // turn-end callback that called this.
4078
+ queueMicrotask(() => {
4079
+ for (const [policyName, { events, judge }] of held) this.judgeBatch(policyName, events, this.currentJudge(policyName, judge));
4080
+ });
4081
+ }
4082
+
3502
4083
  // =========================================================================
3503
4084
  // Inference lifecycle
3504
4085
  // =========================================================================
3505
4086
 
3506
4087
  onInferenceStarted(agentName: string): void {
3507
4088
  this.inferring.add(agentName);
4089
+ if (this.changesJudgedContext(agentName)) this.stopJudging();
3508
4090
  // A turn is starting — whatever caused it supersedes any pending
3509
4091
  // self-wake. (Arming happens MID-turn via skip_reply, i.e. after this
3510
4092
  // ran for the arming turn, so a fresh arm always survives its own turn.)
@@ -3524,11 +4106,13 @@ export class EventGate {
3524
4106
  * still running. */
3525
4107
  onInferenceResumed(agentName: string): void {
3526
4108
  this.inferring.add(agentName);
4109
+ if (this.changesJudgedContext(agentName)) this.stopJudging();
3527
4110
  }
3528
4111
 
3529
4112
  onInferenceEnded(agentName: string): void {
3530
4113
  this.inferring.delete(agentName);
3531
4114
  this.flushInferenceBufferIfClear();
4115
+ this.flushJudgeHeldIfClear();
3532
4116
  }
3533
4117
 
3534
4118
  /**
@@ -3543,7 +4127,15 @@ export class EventGate {
3543
4127
  setQuiesced(quiesced: boolean): void {
3544
4128
  if (this.quiesced === quiesced) return;
3545
4129
  this.quiesced = quiesced;
3546
- if (!quiesced) this.flushInferenceBufferIfClear();
4130
+ if (quiesced) {
4131
+ // A judge reading the context now would read it mid-surgery, and call
4132
+ // its provider while the host holds still: it's stopped, and its batch
4133
+ // held to be judged at resume.
4134
+ this.stopJudging();
4135
+ } else {
4136
+ this.flushInferenceBufferIfClear();
4137
+ this.flushJudgeHeldIfClear();
4138
+ }
3547
4139
  }
3548
4140
 
3549
4141
  private flushInferenceBufferIfClear(): void {
@@ -3576,12 +4168,19 @@ export class EventGate {
3576
4168
  bufferedEvents: number;
3577
4169
  droppedBufferedEvents: number;
3578
4170
  sleepUntil: number;
4171
+ /** Events held for the reply judge until the turn or quiesce ends, and the judge's own drops at the cap. */
4172
+ judgeHeldEvents: number;
4173
+ droppedJudgeEvents: number;
3579
4174
  } {
4175
+ let judgeHeldEvents = 0;
4176
+ for (const held of this.judgeHeld.values()) judgeHeldEvents += held.events.length;
3580
4177
  return {
3581
4178
  inferring: [...this.inferring],
3582
4179
  bufferedEvents: this.inferenceBuffer.length,
3583
4180
  droppedBufferedEvents: this.droppedBufferedEvents,
3584
4181
  sleepUntil: this.sleepUntil,
4182
+ judgeHeldEvents,
4183
+ droppedJudgeEvents: this.droppedJudgeEvents,
3585
4184
  };
3586
4185
  }
3587
4186
 
@@ -3726,7 +4325,7 @@ export class EventGate {
3726
4325
  const placement: WritePlacement = {};
3727
4326
  this.addMessageFn('user', [{
3728
4327
  type: 'text',
3729
- text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
4328
+ text: `[${NOTICE_TAG.selfWake}] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
3730
4329
  }], { source: 'gate:self-wake' }, agentName, placement);
3731
4330
  this.requestInferenceFn(
3732
4331
  agentName,
@@ -3737,6 +4336,7 @@ export class EventGate {
3737
4336
  );
3738
4337
  }, ms);
3739
4338
  this.selfWakeTimers.set(agentName, timer);
4339
+ this.selfWakeDue.set(agentName, { at: this.now() + ms, source });
3740
4340
  this.emitTrace({
3741
4341
  type: 'gate:decision',
3742
4342
  eventType: 'selfwake:set',
@@ -3935,6 +4535,16 @@ export class EventGate {
3935
4535
  fireCount: this.passiveSampleFires.get(p.name) ?? 0,
3936
4536
  };
3937
4537
  }
4538
+ if (typeof p.behavior === 'object' && 'judge' in p.behavior) {
4539
+ const record = this.judgeRecords.get(p.name);
4540
+ result.judgeState = {
4541
+ pendingCount: debounce?.events.length ?? 0,
4542
+ judging: this.judging.has(p.name),
4543
+ heldCount: this.judgeHeld.get(p.name)?.events.length ?? 0,
4544
+ verdicts: { ...(record?.verdicts ?? { reply: 0, wait: 0, error: 0 }) },
4545
+ ...(record?.last ? { last: { ...record.last } } : {}),
4546
+ };
4547
+ }
3938
4548
  return result;
3939
4549
  });
3940
4550
 
@@ -3961,9 +4571,14 @@ export class EventGate {
3961
4571
  default: this.config.default ?? 'always',
3962
4572
  script: this.gateScript.status(),
3963
4573
  policies,
3964
- errors: [this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError]
4574
+ errors: [
4575
+ this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError,
4576
+ this.judgeUnwiredNotice(), this.judgeFailingNotice(),
4577
+ ]
3965
4578
  .filter((e): e is string => e !== null),
3966
- shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
4579
+ // Fresh, not the cache: the sources the host knows (eventTypesFrom)
4580
+ // change as servers connect, with no change to the rules.
4581
+ shadowWarnings: this.lintShadows().map(formatShadowWarning),
3967
4582
  totalEvaluations: this.totalEvaluations,
3968
4583
  defaultDecisions: {
3969
4584
  triggered: this.defaultTriggered,
@@ -3973,6 +4588,27 @@ export class EventGate {
3973
4588
  };
3974
4589
  }
3975
4590
 
4591
+ /** gate_status's word on judge rules in force with no judge wired. */
4592
+ private judgeUnwiredNotice(): string | null {
4593
+ if (this.judgeFn) return null;
4594
+ const names = this.config.policies
4595
+ .filter((p) => typeof p.behavior === 'object' && 'judge' in p.behavior)
4596
+ .map((p) => p.name);
4597
+ if (names.length === 0) return null;
4598
+ return `${quotedList(names)} ${names.length === 1 ? 'is a judge rule' : 'are judge rules'}, and this host wires no reply `
4599
+ + `judge, so ${names.length === 1 ? 'its batches wake' : 'their batches wake'} the agent as a debounce of the same quiet period would`;
4600
+ }
4601
+
4602
+ /** gate_status's word on a judge that keeps failing (JUDGE_FAILING_STREAK in a row). */
4603
+ private judgeFailingNotice(): string | null {
4604
+ if (this.judgeFailStreak < JUDGE_FAILING_STREAK) return null;
4605
+ return `the reply judge has failed its last ${this.judgeFailStreak} requests (the newest: ${this.judgeLastFailure}), `
4606
+ + (this.judgeOnError === 'wake'
4607
+ ? 'so judge rules wake the agent as debounces of the same quiet period would'
4608
+ : "so judge rules' batches don't wake the agent")
4609
+ + "; the host's judge model or provider may not serve it (GateOptions.judge)";
4610
+ }
4611
+
3976
4612
  // =========================================================================
3977
4613
  // Cleanup
3978
4614
  // =========================================================================
@@ -3983,10 +4619,19 @@ export class EventGate {
3983
4619
  clearTimeout(state.timer);
3984
4620
  }
3985
4621
  this.debounceTimers.clear();
4622
+ // A judge deciding now is told to stop, and its verdict, when it comes,
4623
+ // delivers nothing (finishJudging); held batches go with the buffer.
4624
+ for (const state of this.judging.values()) {
4625
+ if (state.timer) clearTimeout(state.timer);
4626
+ state.abort.abort();
4627
+ }
4628
+ this.judging.clear();
4629
+ this.judgeHeld.clear();
3986
4630
  for (const timer of this.selfWakeTimers.values()) {
3987
4631
  clearTimeout(timer);
3988
4632
  }
3989
4633
  this.selfWakeTimers.clear();
4634
+ this.selfWakeDue.clear();
3990
4635
  // The sleep stays, with its note and agent, and its timer goes: fired
3991
4636
  // after a stop(), its wake reached a framework that had stopped, and the
3992
4637
  // timer held the process until the sleep's end. The next start() arms it