@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
@@ -10,6 +10,8 @@
10
10
  * and are always dispatched to modules — the gate decides whether to spend
11
11
  * inference tokens, not whether the agent should know about the event.
12
12
  */
13
+ import { NOTICE_TAG } from '../notice-tags.js';
14
+ import { cutToFit, suppliedValue } from '../supplied-text.js';
13
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
16
  import { basename, dirname, join } from 'node:path';
15
17
  import { GateScript } from './gate-script.js';
@@ -40,6 +42,15 @@ function parseGateJson(text) {
40
42
  const MIN_DEBOUNCE_MS = 100;
41
43
  /** Maximum debounce value (ms). */
42
44
  const MAX_DEBOUNCE_MS = 300_000;
45
+ /** Maximum `judge.maxWaitMs` (ms): an hour. */
46
+ const MAX_JUDGE_WAIT_MS = 3_600_000;
47
+ /** Longest `judge.instructions` (characters) a rule may hold: they go to the
48
+ * judge with every batch, so they're kept short. */
49
+ const MAX_JUDGE_INSTRUCTIONS = 4_000;
50
+ /** How long a judge request may take by default (ms) before onError decides. */
51
+ const DEFAULT_JUDGE_TIMEOUT_MS = 20_000;
52
+ /** Judge failures in a row after which gate_status names the judge as failing. */
53
+ const JUDGE_FAILING_STREAK = 3;
43
54
  /** Whether a check made at `last` is still fresh at `now`, less than the
44
55
  * reload throttle ago. A clock stepped backwards since makes it stale, so
45
56
  * the next check runs rather than wait for the clock to catch up. */
@@ -142,7 +153,7 @@ function referenceLine(count, channel, oldest, now) {
142
153
  /** A handed batched-wake line's text (WakeLine), as it reads when written at
143
154
  * `now`: the gate's words, its message's age counted to then. */
144
155
  export function wakeLineText(line, now) {
145
- return `[Gate: 1 event matched]\n\n${referenceLine(1, line.shown, line.at, now)}`;
156
+ return `[${NOTICE_TAG.gate}: 1 event matched]\n\n${referenceLine(1, line.shown, line.at, now)}`;
146
157
  }
147
158
  /** The event's own stored entry, as the framework is asked about it. */
148
159
  function entryRef(event) {
@@ -176,8 +187,22 @@ function formatBehavior(b) {
176
187
  const k = b.passive_sample.keyBy ? `,keyBy:${b.passive_sample.keyBy}` : '';
177
188
  return `passive_sample:${b.passive_sample.every}${k}`;
178
189
  }
190
+ if ('judge' in b) {
191
+ const w = b.judge.maxWaitMs !== undefined ? `,maxWait:${b.judge.maxWaitMs}` : '';
192
+ return `judge:${b.judge.debounceMs}${w}`;
193
+ }
179
194
  return 'unknown';
180
195
  }
196
+ /** The quiet period (ms) of a batching behavior: a debounce's, or a judge's. */
197
+ function batchQuietMs(b) {
198
+ if (typeof b !== 'object')
199
+ return undefined;
200
+ if ('debounce' in b)
201
+ return b.debounce;
202
+ if ('judge' in b)
203
+ return b.judge.debounceMs;
204
+ return undefined;
205
+ }
181
206
  /**
182
207
  * Stable fingerprint for a GateBehavior, used during reload to detect
183
208
  * config changes that invalidate per-policy state. JSON.stringify is
@@ -232,9 +257,10 @@ const MATCH_FIELDS = [
232
257
  'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
233
258
  ];
234
259
  const FILTER_FIELDS = ['type', 'pattern'];
235
- const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample'];
260
+ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'];
236
261
  const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
237
262
  const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
263
+ const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
238
264
  function isPlainObject(value) {
239
265
  return value !== null && typeof value === 'object' && !Array.isArray(value);
240
266
  }
@@ -410,6 +436,7 @@ function fieldLevels(file) {
410
436
  { name: '"behavior"', fields: BEHAVIOR_KINDS },
411
437
  { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
412
438
  { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
439
+ { name: '"behavior.judge"', fields: JUDGE_FIELDS },
413
440
  ];
414
441
  }
415
442
  /** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
@@ -589,7 +616,7 @@ function readRule(raw, unnamed, file = 'gate.json') {
589
616
  const of = (fields) => m.keys.some((key) => fields.includes(key));
590
617
  if (obj.match === undefined && (of(MATCH_FIELDS) || of(FILTER_FIELDS)))
591
618
  return 'so the rule matches every event';
592
- if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS))) {
619
+ if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS) || of(JUDGE_FIELDS))) {
593
620
  return 'so the rule\'s behavior is "always"';
594
621
  }
595
622
  return undefined;
@@ -618,7 +645,7 @@ function readRule(raw, unnamed, file = 'gate.json') {
618
645
  }
619
646
  return reading;
620
647
  }
621
- const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit" and "passive_sample"';
648
+ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit", "passive_sample" and "judge"';
622
649
  /**
623
650
  * A rule's behavior: "always" when left out; one of the three names; or an
624
651
  * object holding one of the timing kinds. Anything else but a list or an
@@ -626,7 +653,8 @@ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one
626
653
  * and is read so; a list, or an object holding none of the kinds, was
627
654
  * refused, and the rule is left out. Of
628
655
  * two kinds or more, "debounce", then "rate_limit", then "passive_sample" was
629
- * the one read, whatever their order, and is read so. Each other kind is read
656
+ * the one read, whatever their order, and is read so; "judge", newer than
657
+ * those, is read only where none of them is given. Each other kind is read
630
658
  * on its own as well, so that a refusal names its problems too, and a load
631
659
  * reads past them, since it isn't read either way. `kind` is the behavior's
632
660
  * kind even where it can't be read, for passthrough's check.
@@ -649,7 +677,7 @@ function readBehavior(r, value) {
649
677
  const kinds = BEHAVIOR_KINDS.filter((k) => b[k] !== undefined);
650
678
  const kind = kinds[0];
651
679
  if (kind === undefined) {
652
- r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit" and "passive_sample"' });
680
+ r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit", "passive_sample" and "judge"' });
653
681
  return {};
654
682
  }
655
683
  if (kinds.length > 1) {
@@ -777,11 +805,109 @@ function readPassiveSample(r, value) {
777
805
  out.keyBy = keyBy;
778
806
  return { passive_sample: out };
779
807
  }
808
+ /**
809
+ * A judge: a debounce's quiet period, read as `debounce` is, with at its
810
+ * nearest bound one out of range; an optional longest wait, from that
811
+ * quiet period to an hour, likewise; and the agent's optional instructions to
812
+ * the judge, text of at most MAX_JUDGE_INSTRUCTIONS characters. Earlier
813
+ * releases knew no judge, so nothing deployed has one: a rule whose judge
814
+ * can't be read is left out, and its instructions, which go to a model with
815
+ * every batch, are never cut to fit.
816
+ */
817
+ function readJudge(r, value) {
818
+ if (!isPlainObject(value)) {
819
+ r.problems.push({
820
+ text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
821
+ });
822
+ return undefined;
823
+ }
824
+ const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
825
+ maxWaitMs: 'a batch is judged only after its quiet period',
826
+ instructions: 'the judge decides without the agent\'s word',
827
+ });
828
+ readFieldNames(r, 'behavior.judge', conf, JUDGE_FIELDS);
829
+ let ok = true;
830
+ const ms = conf.debounceMs;
831
+ let debounceMs;
832
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
833
+ r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
834
+ ok = false;
835
+ }
836
+ else if (ms < MIN_DEBOUNCE_MS) {
837
+ r.problems.push({
838
+ text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
839
+ });
840
+ debounceMs = MIN_DEBOUNCE_MS;
841
+ }
842
+ else if (ms > MAX_DEBOUNCE_MS) {
843
+ r.problems.push({
844
+ text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
845
+ });
846
+ debounceMs = MAX_DEBOUNCE_MS;
847
+ }
848
+ else {
849
+ debounceMs = ms;
850
+ }
851
+ let maxWaitMs;
852
+ const wait = conf.maxWaitMs;
853
+ if (wait !== undefined) {
854
+ if (typeof wait !== 'number' || !Number.isFinite(wait)) {
855
+ r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
856
+ ok = false;
857
+ }
858
+ else {
859
+ const floor = debounceMs ?? MIN_DEBOUNCE_MS;
860
+ if (wait < floor) {
861
+ r.problems.push({
862
+ text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
863
+ read: `so it's read as ${floor} ms`,
864
+ });
865
+ maxWaitMs = floor;
866
+ }
867
+ else if (wait > MAX_JUDGE_WAIT_MS) {
868
+ r.problems.push({
869
+ text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
870
+ read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
871
+ });
872
+ maxWaitMs = MAX_JUDGE_WAIT_MS;
873
+ }
874
+ else {
875
+ maxWaitMs = wait;
876
+ }
877
+ }
878
+ }
879
+ const said = conf.instructions;
880
+ let instructions;
881
+ if (said !== undefined) {
882
+ if (typeof said !== 'string') {
883
+ r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
884
+ ok = false;
885
+ }
886
+ else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
887
+ r.problems.push({
888
+ text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
889
+ });
890
+ ok = false;
891
+ }
892
+ else {
893
+ instructions = said;
894
+ }
895
+ }
896
+ if (!ok || debounceMs === undefined)
897
+ return undefined;
898
+ const out = { debounceMs };
899
+ if (maxWaitMs !== undefined)
900
+ out.maxWaitMs = maxWaitMs;
901
+ if (instructions !== undefined)
902
+ out.instructions = instructions;
903
+ return { judge: out };
904
+ }
780
905
  /** How each kind of behavior object is read. */
781
906
  const READ_KIND = {
782
907
  debounce: readDebounce,
783
908
  rate_limit: readRateLimit,
784
909
  passive_sample: readPassiveSample,
910
+ judge: readJudge,
785
911
  };
786
912
  /** A timing kind's keyBy: a field name. One of another type was read as none. */
787
913
  function readKeyBy(r, kind, value) {
@@ -1352,32 +1478,69 @@ function globAtLeastAsBroad(earlier, later) {
1352
1478
  return true;
1353
1479
  return earlier === later;
1354
1480
  }
1481
+ /** The event types the gate can see from an MCPL server: its pushes and its
1482
+ * channels' messages. */
1483
+ export const MCPL_GATE_EVENT_TYPES = ['mcpl:push-event', 'mcpl:channel-incoming'];
1484
+ /** The event types `rule` can match: its scope's, narrowed to those its
1485
+ * source is known to send; 'all' when neither says. */
1486
+ function typesSeenBy(rule, eventTypesFrom) {
1487
+ const scope = rule.match.scope && rule.match.scope.length > 0 ? rule.match.scope : undefined;
1488
+ const source = rule.match.source;
1489
+ let sent;
1490
+ if (eventTypesFrom && source !== undefined && !source.includes('*')) {
1491
+ try {
1492
+ sent = eventTypesFrom(source);
1493
+ }
1494
+ catch {
1495
+ sent = undefined;
1496
+ }
1497
+ }
1498
+ if (scope && sent)
1499
+ return { types: scope.filter((t) => sent.includes(t)), known: true };
1500
+ if (scope)
1501
+ return { types: scope, known: true };
1502
+ if (sent)
1503
+ return { types: [...sent], known: true };
1504
+ return { types: 'all', known: false };
1505
+ }
1355
1506
  /**
1356
- * Event types for which `earlier` provably matches EVERY event that `later`
1357
- * would match — i.e. the types for which `later` is unreachable when
1358
- * `earlier` sits before it and consumes its matches. Returns null when
1359
- * subsumption can't be proven (which is not proof of safety — the lint is
1360
- * deliberately one-sided so every warning it emits is true).
1507
+ * Whether `earlier` provably matches events `later` would match, so that
1508
+ * `later` doesn't see them when `earlier` sits before it and consumes its
1509
+ * matches: the event types concerned, whether that's every event `later`
1510
+ * would match, and whether `later` is known to see those types. Returns null
1511
+ * when it can't be proven (which is not proof of safety — the lint is
1512
+ * deliberately one-sided so every note it emits is true).
1361
1513
  */
1362
- function subsumedEventTypes(earlier, later) {
1514
+ function subsumedEventTypes(earlier, later, eventTypesFrom) {
1363
1515
  const e = earlier.match;
1364
1516
  const l = later.match;
1365
- // Scope: which of `later`'s event types does `earlier` cover?
1366
- let covered;
1367
- if (!e.scope || e.scope.length === 0) {
1368
- covered = l.scope && l.scope.length > 0 ? l.scope : 'all';
1369
- }
1370
- else if (!l.scope || l.scope.length === 0) {
1371
- // `later` matches all types; `earlier` only consumes its own scope, so
1372
- // the shadow is partial — exactly those types.
1373
- covered = e.scope;
1517
+ // Event types: which of those `later` can match does `earlier` cover?
1518
+ const seen = typesSeenBy(later, eventTypesFrom);
1519
+ const earlierTypes = e.scope && e.scope.length > 0 ? e.scope : 'all';
1520
+ let eventTypes;
1521
+ let whole;
1522
+ let known = seen.known;
1523
+ if (earlierTypes === 'all') {
1524
+ eventTypes = seen.types;
1525
+ whole = true;
1526
+ }
1527
+ else if (seen.types === 'all') {
1528
+ // `later` matches every type; `earlier` only consumes its own scope, so
1529
+ // the overlap is partial — exactly those types, which may never reach
1530
+ // `later`.
1531
+ eventTypes = earlierTypes;
1532
+ whole = false;
1374
1533
  }
1375
1534
  else {
1376
- const overlap = l.scope.filter((t) => e.scope.includes(t));
1535
+ const overlap = seen.types.filter((t) => earlierTypes.includes(t));
1377
1536
  if (overlap.length === 0)
1378
1537
  return null;
1379
- covered = overlap;
1538
+ eventTypes = overlap;
1539
+ whole = overlap.length === seen.types.length;
1540
+ known = true;
1380
1541
  }
1542
+ if (Array.isArray(eventTypes) && eventTypes.length === 0)
1543
+ return null;
1381
1544
  // Every remaining discriminator on `earlier` must be at least as broad as
1382
1545
  // `later`'s, otherwise some `later` events slip past `earlier`.
1383
1546
  if (!globAtLeastAsBroad(e.source, l.source))
@@ -1409,37 +1572,54 @@ function subsumedEventTypes(earlier, later) {
1409
1572
  return null;
1410
1573
  if (e.tagsNone && !subsetOf(e.tagsNone, l.tagsNone ?? []))
1411
1574
  return null;
1412
- return covered;
1575
+ return { eventTypes, whole, known };
1413
1576
  }
1414
1577
  /**
1415
- * Find rules made unreachable by earlier, broader rules (first match wins).
1578
+ * Find rules an earlier, broader rule keeps from applying (first match wins),
1579
+ * in whole or for some event types.
1416
1580
  * The 2026-08-08 Mythos incident is the canonical catch: a prepended
1417
1581
  * `{scope:[mcpl:channel-incoming]}` sampler silently made the
1418
1582
  * direct-address and mention rules dead for the whole incoming lane.
1419
1583
  * `passthrough` observers shadow nothing — non-firing matches fall through.
1584
+ * `eventTypesFrom` leaves out types a rule's source is known never to send:
1585
+ * a rule for an MCPL server's events isn't told about another rule that
1586
+ * takes every `external-message`.
1420
1587
  */
1421
- export function findShadowedPolicies(policies) {
1588
+ export function findShadowedPolicies(policies, eventTypesFrom) {
1422
1589
  const out = [];
1423
1590
  for (let i = 0; i < policies.length; i++) {
1424
1591
  const earlier = policies[i];
1425
1592
  if (earlier.passthrough)
1426
1593
  continue;
1427
1594
  for (let j = i + 1; j < policies.length; j++) {
1428
- const covered = subsumedEventTypes(earlier, policies[j]);
1429
- if (covered) {
1430
- out.push({ earlier: earlier.name, later: policies[j].name, eventTypes: covered });
1595
+ const found = subsumedEventTypes(earlier, policies[j], eventTypesFrom);
1596
+ if (found) {
1597
+ out.push({ earlier: earlier.name, later: policies[j].name, ...found });
1431
1598
  }
1432
1599
  }
1433
1600
  }
1434
1601
  return out;
1435
1602
  }
1436
- /** Human-readable one-liner for a ShadowWarning, used in tool results,
1437
- * traces, and gate_status alike so the same hazard reads the same way
1438
- * everywhere. */
1603
+ /** A ShadowWarning in words, used in tool results, traces, and gate_status
1604
+ * alike so the same note reads the same way everywhere. It says which of
1605
+ * three it is (the rule never applies; it misses some of its events; it
1606
+ * may not be meant for those events at all) and what to do. */
1439
1607
  export function formatShadowWarning(w) {
1440
- const types = w.eventTypes === 'all' ? 'ALL event types' : w.eventTypes.join(', ');
1441
- return `rule "${w.earlier}" makes "${w.later}" unreachable for ${types} `
1442
- + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`;
1608
+ const later = `"${w.later}"`;
1609
+ const earlier = `"${w.earlier}"`;
1610
+ const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore: "${w.earlier}")`;
1611
+ if (w.whole) {
1612
+ return `${later} never applies: ${earlier} comes first and matches every event ${later} would. `
1613
+ + `If ${later} should apply, ${move}, or narrow ${earlier}.`;
1614
+ }
1615
+ const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.join(', ');
1616
+ if (w.known) {
1617
+ return `${earlier} comes first and takes ${later}'s ${types} events, so ${later} applies only to its others. `
1618
+ + `If it should apply to those too, ${move}.`;
1619
+ }
1620
+ return `${earlier} comes first and takes every ${types} event, so ${later} sees none. `
1621
+ + `Nothing to do unless ${later} is meant for ${types} events; then ${move}. `
1622
+ + `A scope on ${later} naming the event types it's for clears this note.`;
1443
1623
  }
1444
1624
  /** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */
1445
1625
  function errorText(err) {
@@ -1521,6 +1701,27 @@ export class EventGate {
1521
1701
  startUnsavedMerged = false;
1522
1702
  // Debounce state
1523
1703
  debounceTimers = new Map();
1704
+ // Reply judge state (`judge` rules). The judge itself, when the host wires
1705
+ // one; without it a judge rule's batch is delivered as a debounce's.
1706
+ judgeFn;
1707
+ judgeTimeoutMs;
1708
+ judgeOnError;
1709
+ /** Per rule, the batch the judge is deciding now. */
1710
+ judging = new Map();
1711
+ /** Per rule, a batch that fired while a turn changing the judged context ran or the host was
1712
+ * quiesced, held to be judged once both are clear: the judge reads the
1713
+ * context the turn leaves, which may already answer it. */
1714
+ judgeHeld = new Map();
1715
+ judgeRecords = new Map();
1716
+ changesJudgedContextFn;
1717
+ /** Lifetime count of events dropped from batches waiting on the judge, at
1718
+ * the inference buffer's cap (MAX_INFERENCE_BUFFER per rule). */
1719
+ droppedJudgeEvents = 0;
1720
+ /** Judge requests failed in a row, and the newest failure's reason: a host
1721
+ * whose provider can't serve the judge's model fails every time, which
1722
+ * gate_status says once it's a streak (judgeFailingNotice). */
1723
+ judgeFailStreak = 0;
1724
+ judgeLastFailure = '';
1524
1725
  // Rate-limit state — policy name → key → bucket.
1525
1726
  rateLimitBuckets = new Map();
1526
1727
  // Passive-sample state — policy name → key → counter (count since last fire).
@@ -1537,10 +1738,12 @@ export class EventGate {
1537
1738
  droppedBufferedEvents = 0;
1538
1739
  // Per-policy stats
1539
1740
  stats = new Map();
1540
- // Shadow-lint cache (see refreshShadowWarnings) — recomputed on every
1541
- // config change, fingerprinted so an unchanged hazard set doesn't re-trace.
1542
- shadowWarningsCache = [];
1741
+ // The shadow lint's last findings (see refreshShadowWarnings), recomputed
1742
+ // on every config change and fingerprinted so an unchanged set doesn't
1743
+ // re-trace.
1543
1744
  shadowWarningsFingerprint = '[]';
1745
+ /** See the constructor's `eventTypesFrom`. */
1746
+ eventTypesFromFn;
1544
1747
  // Observability counters for events that didn't match any policy
1545
1748
  totalEvaluations = 0;
1546
1749
  defaultTriggered = 0;
@@ -1565,6 +1768,10 @@ export class EventGate {
1565
1768
  // self-wake (an external wake supersedes it; the timer would otherwise
1566
1769
  // land as a redundant empty wake right after the turn).
1567
1770
  selfWakeTimers = new Map();
1771
+ /** When each agent's pending self-wake fires, and what armed it
1772
+ * (upcomingWakes). Read only while its timer is in selfWakeTimers, so an
1773
+ * entry a fire or a cancel left behind says nothing. */
1774
+ selfWakeDue = new Map();
1568
1775
  // Privileged users who may wake the agent through sleep. Hot-reloaded from
1569
1776
  // privilegedUsersPath on change.
1570
1777
  privilegedUsersPath;
@@ -1594,7 +1801,14 @@ export class EventGate {
1594
1801
  this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
1595
1802
  this.channelShownFn = opts.channelShown;
1596
1803
  this.isNewestEntryFn = opts.isNewestEntry;
1804
+ this.eventTypesFromFn = opts.eventTypesFrom;
1597
1805
  this.now = opts.now ?? (() => Date.now());
1806
+ this.judgeFn = opts.judge;
1807
+ this.changesJudgedContextFn = opts.changesJudgedContext;
1808
+ this.judgeTimeoutMs = opts.judgeTimeoutMs !== undefined && Number.isFinite(opts.judgeTimeoutMs) && opts.judgeTimeoutMs > 0
1809
+ ? Math.min(opts.judgeTimeoutMs, MAX_TIMER_MS)
1810
+ : DEFAULT_JUDGE_TIMEOUT_MS;
1811
+ this.judgeOnError = opts.judgeOnError === 'skip' ? 'skip' : 'wake';
1598
1812
  this.loadPrivileged();
1599
1813
  // Seed or reconcile the on-disk config from the host's starting rules
1600
1814
  // (`initialConfig`), which are read as gate.json is, rule by rule: a rule
@@ -2110,6 +2324,7 @@ export class EventGate {
2110
2324
  for (const name of removed) {
2111
2325
  this.clearPolicyState(name);
2112
2326
  this.stats.delete(name);
2327
+ this.judgeRecords.delete(name);
2113
2328
  }
2114
2329
  const cleared = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
2115
2330
  for (const name of cleared)
@@ -2568,7 +2783,7 @@ export class EventGate {
2568
2783
  return { trigger: true, policyName: policy.name, behavior: 'always' };
2569
2784
  }
2570
2785
  if (typeof policy.behavior === 'object') {
2571
- if ('debounce' in policy.behavior) {
2786
+ if ('debounce' in policy.behavior || 'judge' in policy.behavior) {
2572
2787
  this.handleDebounce(policy, info);
2573
2788
  return { trigger: false, policyName: policy.name, behavior: policy.behavior };
2574
2789
  }
@@ -2709,7 +2924,7 @@ export class EventGate {
2709
2924
  if (b === 'defer' || b === 'skip')
2710
2925
  return false;
2711
2926
  if (typeof b === 'object') {
2712
- if ('debounce' in b)
2927
+ if ('debounce' in b || 'judge' in b)
2713
2928
  return false; // batches; never triggers inline
2714
2929
  if ('rate_limit' in b) {
2715
2930
  const cfg = b.rate_limit;
@@ -2832,22 +3047,31 @@ export class EventGate {
2832
3047
  /** Current shadow-lint findings for the live policy order. Fresh compute —
2833
3048
  * cheap (policy lists are tens of entries at most). */
2834
3049
  lintShadows() {
2835
- return findShadowedPolicies(this.config.policies);
3050
+ return findShadowedPolicies(this.config.policies, this.eventTypesFromFn);
2836
3051
  }
2837
3052
  /**
2838
- * Recompute shadow warnings and, when the set changed and is non-empty,
2839
- * emit a `gate:shadow-warnings` trace so the hazard lands in ops
2840
- * observability at the moment the order changed — not at the next missed
2841
- * DM. Called from writeConfig and reloadIfChanged.
3053
+ * The sources the host knows changed (eventTypesFrom answers differently),
3054
+ * as when an MCPL server connects or is removed: the rule-order notes are
3055
+ * made again, and traced if they changed, as a change to the rules would.
3056
+ */
3057
+ sourcesChanged() {
3058
+ this.refreshShadowWarnings('sources-changed');
3059
+ }
3060
+ /**
3061
+ * Recompute shadow warnings and, when the set changed, emit a
3062
+ * `gate:shadow-warnings` trace so the hazard lands in ops observability at
3063
+ * the moment the order changed — not at the next missed DM — and, with an
3064
+ * empty list, the moment the last of them went. Called from writeConfig,
3065
+ * reloadIfChanged and sourcesChanged.
2842
3066
  */
2843
3067
  refreshShadowWarnings(reason) {
2844
3068
  const warnings = this.lintShadows();
2845
3069
  const fingerprint = JSON.stringify(warnings);
2846
3070
  if (fingerprint === this.shadowWarningsFingerprint)
2847
3071
  return;
3072
+ const hadSome = this.shadowWarningsFingerprint !== '[]';
2848
3073
  this.shadowWarningsFingerprint = fingerprint;
2849
- this.shadowWarningsCache = warnings;
2850
- if (warnings.length > 0) {
3074
+ if (warnings.length > 0 || hadSome) {
2851
3075
  this.emitTrace({
2852
3076
  type: 'gate:shadow-warnings',
2853
3077
  reason,
@@ -2889,7 +3113,8 @@ export class EventGate {
2889
3113
  }
2890
3114
  }
2891
3115
  handleDebounce(policy, info) {
2892
- const debounceMs = policy.behavior.debounce;
3116
+ const debounceMs = batchQuietMs(policy.behavior) ?? MIN_DEBOUNCE_MS;
3117
+ const judge = typeof policy.behavior === 'object' && 'judge' in policy.behavior ? policy.behavior.judge : undefined;
2893
3118
  const event = {
2894
3119
  policyName: policy.name,
2895
3120
  content: info.content.length > MAX_CONTENT_SNIPPET
@@ -2910,14 +3135,24 @@ export class EventGate {
2910
3135
  ...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
2911
3136
  };
2912
3137
  const existing = this.debounceTimers.get(policy.name);
3138
+ // A judge's maxWaitMs caps how far new events push the batch back: it's
3139
+ // judged by then, from its first event, quiet or not.
3140
+ const delay = (firstAt, batchJudge) => {
3141
+ const cap = batchJudge?.maxWaitMs;
3142
+ return cap === undefined ? debounceMs : Math.max(0, Math.min(debounceMs, firstAt + cap - this.now()));
3143
+ };
2913
3144
  if (existing) {
2914
3145
  clearTimeout(existing.timer);
2915
3146
  existing.events.push(event);
2916
- existing.timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
3147
+ const ms = delay(existing.firstAt, existing.judge);
3148
+ existing.timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3149
+ existing.dueAt = this.now() + ms;
2917
3150
  }
2918
3151
  else {
2919
- const timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
2920
- this.debounceTimers.set(policy.name, { timer, events: [event] });
3152
+ const firstAt = this.now();
3153
+ const ms = delay(firstAt, judge);
3154
+ const timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3155
+ this.debounceTimers.set(policy.name, { timer, events: [event], firstAt, dueAt: firstAt + ms, ...(judge ? { judge } : {}) });
2921
3156
  }
2922
3157
  }
2923
3158
  fireDebounce(policyName) {
@@ -2928,6 +3163,13 @@ export class EventGate {
2928
3163
  }
2929
3164
  const events = state.events;
2930
3165
  this.debounceTimers.delete(policyName);
3166
+ // A judge rule's batch goes to the reply judge, which delivers it, or
3167
+ // doesn't, once it has decided (judgeBatch). Without a judge wired it's
3168
+ // delivered below, as a debounce's batch is.
3169
+ if (state.judge && this.judgeFn) {
3170
+ this.judgeBatch(policyName, events, state.judge);
3171
+ return;
3172
+ }
2931
3173
  // If any agent is currently inferring — or the host is quiesced — buffer
2932
3174
  // for later (with cap). While quiesced, delivering would write a
2933
3175
  // "[Gate: N events]" message into the very context being operated on
@@ -2970,7 +3212,7 @@ export class EventGate {
2970
3212
  // Only events with no context entry of their own carry their content.
2971
3213
  const quoted = events.filter(e => !e.inContext);
2972
3214
  const referenced = events.filter(e => e.inContext);
2973
- const lines = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`);
3215
+ const lines = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${suppliedValue(e.content)}`);
2974
3216
  // How each referenced channel is named in the line, in order.
2975
3217
  const shown = [];
2976
3218
  if (referenced.length > 0) {
@@ -2989,7 +3231,7 @@ export class EventGate {
2989
3231
  ? '(unknown channel)'
2990
3232
  : e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn
2991
3233
  ? this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId })
2992
- : id;
3234
+ : suppliedValue(id);
2993
3235
  const key = JSON.stringify([e.serverId ?? null, id ?? null]);
2994
3236
  const entry = byChannel.get(key) ?? { name, count: 0, oldest: e.timestamp, label: e.channelLabel };
2995
3237
  entry.count += 1;
@@ -3002,7 +3244,7 @@ export class EventGate {
3002
3244
  // agent can still address tools that want the composite id. A name
3003
3245
  // may carry its own `#`, so one is added only where the name lacks
3004
3246
  // it, as at the other places that add a `#`.
3005
- const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${name})` : name;
3247
+ const channel = label ? `${label.startsWith('#') ? '' : '#'}${suppliedValue(label)} (${name})` : name;
3006
3248
  shown.push(channel);
3007
3249
  lines.push(referenceLine(count, channel, oldest, Date.now()));
3008
3250
  }
@@ -3014,7 +3256,7 @@ export class EventGate {
3014
3256
  // gate hands the line to the wake (WakeProvenance.line) and a fresh start
3015
3257
  // of the wake's turn decides it (WakeLine). Several messages, one with
3016
3258
  // anything after it, or content the line quotes, keep the line now.
3017
- const text = `[Gate: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
3259
+ const text = `[${NOTICE_TAG.gate}: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
3018
3260
  const only = events.length === 1 && events[0].inContext ? events[0] : undefined;
3019
3261
  let line;
3020
3262
  if (only && this.newestEntry(only)) {
@@ -3060,10 +3302,299 @@ export class EventGate {
3060
3302
  }
3061
3303
  }
3062
3304
  // =========================================================================
3305
+ // Reply judge (`judge` rules)
3306
+ // =========================================================================
3307
+ /**
3308
+ * Send one of a judge rule's batches to the reply judge. While a resident's
3309
+ * turn runs or the host is quiesced the batch is held instead, merged with
3310
+ * any the rule already holds, and judged once both are clear
3311
+ * (flushJudgeHeldIfClear): the judge then reads the context the turn left,
3312
+ * which may already have answered it. While the judge decides one of the
3313
+ * rule's batches, a later one waits for that verdict (finishJudging), so the
3314
+ * judge never decides two of a rule's batches at once.
3315
+ */
3316
+ judgeBatch(policyName, events, judge) {
3317
+ if (this.disposed)
3318
+ return;
3319
+ const deciding = this.judging.get(policyName);
3320
+ if (deciding) {
3321
+ deciding.queued.push(...events);
3322
+ this.capJudgeEvents(deciding.queued);
3323
+ return;
3324
+ }
3325
+ if (this.judgedTurnRunning() || this.quiesced) {
3326
+ this.holdForJudge(policyName, events, judge);
3327
+ return;
3328
+ }
3329
+ const fn = this.judgeFn;
3330
+ if (!fn) {
3331
+ this.deliverOrBuffer(events);
3332
+ return;
3333
+ }
3334
+ const abort = new AbortController();
3335
+ const agentNames = this.getAgentNamesFn();
3336
+ const state = { events, judge, started: Date.now(), abort, queued: [] };
3337
+ this.judging.set(policyName, state);
3338
+ const timedOut = new Promise((_, reject) => {
3339
+ state.timer = setTimeout(() => {
3340
+ abort.abort();
3341
+ reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
3342
+ }, this.judgeTimeoutMs);
3343
+ // Never keep the process alive on the judge's account.
3344
+ state.timer.unref?.();
3345
+ });
3346
+ let asked;
3347
+ try {
3348
+ asked = fn({
3349
+ policyName,
3350
+ ...(judge.instructions !== undefined ? { instructions: judge.instructions } : {}),
3351
+ events: events.map((e) => this.judgeEventSummary(e)),
3352
+ agentNames,
3353
+ upcomingWakes: this.upcomingWakes(agentNames, policyName),
3354
+ signal: abort.signal,
3355
+ });
3356
+ }
3357
+ catch (err) {
3358
+ asked = Promise.reject(err);
3359
+ }
3360
+ Promise.race([asked, timedOut]).then((verdict) => this.finishJudging(policyName, state, verdict), (err) => this.finishJudging(policyName, state, undefined, err)).finally(() => {
3361
+ if (state.timer)
3362
+ clearTimeout(state.timer);
3363
+ }).catch((err) => {
3364
+ console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
3365
+ });
3366
+ }
3367
+ /** Hold a batch for the judge until no turn changing the context it reads
3368
+ * runs and the host isn't quiesced, merged with what the rule already holds. */
3369
+ holdForJudge(policyName, events, judge) {
3370
+ const held = this.judgeHeld.get(policyName);
3371
+ if (held) {
3372
+ held.events.push(...events);
3373
+ held.judge = judge;
3374
+ this.capJudgeEvents(held.events);
3375
+ }
3376
+ else {
3377
+ const fresh = [...events];
3378
+ this.capJudgeEvents(fresh);
3379
+ this.judgeHeld.set(policyName, { events: fresh, judge });
3380
+ }
3381
+ }
3382
+ /**
3383
+ * Keep a rule's events waiting on the judge to the inference buffer's cap,
3384
+ * dropping the oldest, counted and logged as bufferForInference counts its
3385
+ * drops: a turn that never ends (the wake-wedge) or a long quiesce on a busy
3386
+ * channel would otherwise grow them without bound. A queue beside a batch
3387
+ * the judge is deciding is capped on its own, so its newest events are never
3388
+ * the ones dropped for the batch ahead of it.
3389
+ */
3390
+ capJudgeEvents(events) {
3391
+ const drop = events.length - MAX_INFERENCE_BUFFER;
3392
+ if (drop <= 0)
3393
+ return;
3394
+ events.splice(0, drop);
3395
+ const before = this.droppedJudgeEvents;
3396
+ this.droppedJudgeEvents += drop;
3397
+ if (Math.floor(before / 50) !== Math.floor(this.droppedJudgeEvents / 50) || before === 0) {
3398
+ console.error(`[gate] events waiting on the reply judge reached the cap (${MAX_INFERENCE_BUFFER} per rule): dropped `
3399
+ + `${this.droppedJudgeEvents} oldest so far${this.quiesced ? ' during quiesce' : ''}`);
3400
+ }
3401
+ }
3402
+ /** The rule's judge as it is now, when the rule is still a judge rule; else the one the batch was asked with. */
3403
+ currentJudge(policyName, asked) {
3404
+ const now = this.config.policies.find((p) => p.name === policyName)?.behavior;
3405
+ return typeof now === 'object' && 'judge' in now ? now.judge : asked;
3406
+ }
3407
+ /**
3408
+ * The gate's own timers that will wake `agentNames` with no new event, for
3409
+ * the judge to weigh what waiting costs: each agent's pending self-wake
3410
+ * (skip_reply's wake_in_seconds), the end of a sleep, and the pending
3411
+ * batches of rules other than `exceptPolicy` (a debounce's wakes the agent
3412
+ * when it fires; a judge rule's goes to the judge, so it only may).
3413
+ */
3414
+ upcomingWakes(agentNames, exceptPolicy) {
3415
+ const wakes = [];
3416
+ const now = this.now();
3417
+ for (const name of agentNames) {
3418
+ const due = this.selfWakeDue.get(name);
3419
+ if (due && this.selfWakeTimers.has(name)) {
3420
+ wakes.push({ at: due.at, certain: true, what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
3421
+ }
3422
+ }
3423
+ if (this.sleepUntil > now && (this.sleepAgent === undefined || agentNames.includes(this.sleepAgent))) {
3424
+ wakes.push({ at: this.sleepUntil, certain: true, what: 'the end of its sleep' });
3425
+ }
3426
+ for (const [name, state] of this.debounceTimers) {
3427
+ if (name === exceptPolicy || state.events.length === 0)
3428
+ continue;
3429
+ const n = state.events.length;
3430
+ const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
3431
+ wakes.push(state.judge
3432
+ ? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
3433
+ : { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
3434
+ }
3435
+ return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
3436
+ }
3437
+ /** A batch's event as the judge is shown it. */
3438
+ judgeEventSummary(e) {
3439
+ const id = e.routeChannelId ?? e.channelId;
3440
+ let channel;
3441
+ if (id !== undefined) {
3442
+ let name = id;
3443
+ if (e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn) {
3444
+ try {
3445
+ name = this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId });
3446
+ }
3447
+ catch {
3448
+ name = id;
3449
+ }
3450
+ }
3451
+ channel = e.channelLabel ? `${e.channelLabel.startsWith('#') ? e.channelLabel : `#${e.channelLabel}`} (${name})` : name;
3452
+ }
3453
+ return {
3454
+ eventType: e.eventType,
3455
+ ...(channel !== undefined ? { channel } : {}),
3456
+ ...(e.authorId !== undefined ? { authorId: e.authorId } : {}),
3457
+ addressed: e.addressed,
3458
+ content: e.content,
3459
+ at: e.timestamp,
3460
+ };
3461
+ }
3462
+ /**
3463
+ * Apply the judge's verdict on a batch. Reply: the batch, with any of the
3464
+ * rule's batches that fired meanwhile, is delivered as a debounce's is
3465
+ * (held for a turn that started meanwhile, as fireDebounce holds one). Wait:
3466
+ * nothing is delivered, and batches that fired meanwhile go to the judge
3467
+ * afresh. A judge that failed, timed out or answered nothing usable decides
3468
+ * by onError: "wake" delivers, "skip" doesn't. Each verdict is traced and
3469
+ * counted for gate_status, and a failure is logged.
3470
+ */
3471
+ finishJudging(policyName, state, verdict, err) {
3472
+ // A verdict for a batch the gate let go of (dispose() clears what the
3473
+ // judge was deciding) applies to nothing, even after a start() since.
3474
+ if (this.judging.get(policyName) !== state)
3475
+ return;
3476
+ this.judging.delete(policyName);
3477
+ if (state.timer)
3478
+ clearTimeout(state.timer);
3479
+ const durationMs = Date.now() - state.started;
3480
+ const failed = verdict == null || typeof verdict !== 'object' || typeof verdict.reply !== 'boolean';
3481
+ if (failed && err?.providerAdmission === 'deferred') {
3482
+ const wakes = this.judgeOnError === 'wake';
3483
+ console.error(`[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3484
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3485
+ if (this.disposed)
3486
+ return;
3487
+ if (wakes)
3488
+ this.deliverOrBuffer([...state.events, ...state.queued]);
3489
+ else if (state.queued.length > 0)
3490
+ this.judgeBatch(policyName, state.queued, this.currentJudge(policyName, state.judge));
3491
+ return;
3492
+ }
3493
+ const outcome = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
3494
+ const reason = failed
3495
+ ? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
3496
+ : typeof verdict.reason === 'string' ? verdict.reason : '';
3497
+ const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
3498
+ record.verdicts[outcome]++;
3499
+ record.last = {
3500
+ at: Date.now(),
3501
+ verdict: outcome,
3502
+ reason: cutToFit(suppliedValue(reason), 300),
3503
+ eventCount: state.events.length,
3504
+ durationMs,
3505
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3506
+ };
3507
+ if (this.config.policies.some((p) => p.name === policyName))
3508
+ this.judgeRecords.set(policyName, record);
3509
+ if (failed) {
3510
+ this.judgeFailStreak++;
3511
+ this.judgeLastFailure = cutToFit(suppliedValue(reason), 200);
3512
+ }
3513
+ else {
3514
+ this.judgeFailStreak = 0;
3515
+ }
3516
+ const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
3517
+ if (failed) {
3518
+ console.error(`[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
3519
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3520
+ }
3521
+ this.emitTrace({
3522
+ type: 'gate:judge-verdict',
3523
+ policyName,
3524
+ verdict: outcome,
3525
+ woke: wake,
3526
+ eventCount: state.events.length,
3527
+ durationMs,
3528
+ ...(!failed && verdict.model ? { model: verdict.model } : {}),
3529
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3530
+ ...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
3531
+ ...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
3532
+ timestamp: Date.now(),
3533
+ });
3534
+ if (this.disposed)
3535
+ return;
3536
+ const judge = this.currentJudge(policyName, state.judge);
3537
+ if (wake) {
3538
+ this.deliverOrBuffer([...state.events, ...state.queued]);
3539
+ return;
3540
+ }
3541
+ if (state.queued.length > 0)
3542
+ this.judgeBatch(policyName, state.queued, judge);
3543
+ }
3544
+ /** Deliver a batch now, or hold it for the agents' turns and the host's
3545
+ * quiesce to end, as fireDebounce does; a delivery that throws drops it,
3546
+ * named in the log. */
3547
+ deliverOrBuffer(events) {
3548
+ if (events.length === 0)
3549
+ return;
3550
+ if (this.inferring.size > 0 || this.quiesced) {
3551
+ this.bufferForInference(events);
3552
+ return;
3553
+ }
3554
+ try {
3555
+ this.deliverEvents(events);
3556
+ }
3557
+ catch (err) {
3558
+ const one = events.length === 1;
3559
+ console.error(`[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3560
+ + `(${errorText(err)}), so the batch is dropped`);
3561
+ }
3562
+ }
3563
+ changesJudgedContext(agentName) {
3564
+ return this.changesJudgedContextFn?.(agentName) ?? true;
3565
+ }
3566
+ judgedTurnRunning() {
3567
+ return [...this.inferring].some((name) => this.changesJudgedContext(name));
3568
+ }
3569
+ stopJudging() {
3570
+ for (const [policyName, state] of [...this.judging]) {
3571
+ this.judging.delete(policyName);
3572
+ if (state.timer)
3573
+ clearTimeout(state.timer);
3574
+ state.abort.abort();
3575
+ this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge);
3576
+ }
3577
+ }
3578
+ /** Send the batches held for the judge once no turn changing the context it reads runs and the host isn't quiesced. */
3579
+ flushJudgeHeldIfClear() {
3580
+ if (this.quiesced || this.judgedTurnRunning() || this.judgeHeld.size === 0)
3581
+ return;
3582
+ const held = [...this.judgeHeld];
3583
+ this.judgeHeld.clear();
3584
+ // Deferred, as the inference buffer's flush is, to stay out of the
3585
+ // turn-end callback that called this.
3586
+ queueMicrotask(() => {
3587
+ for (const [policyName, { events, judge }] of held)
3588
+ this.judgeBatch(policyName, events, this.currentJudge(policyName, judge));
3589
+ });
3590
+ }
3591
+ // =========================================================================
3063
3592
  // Inference lifecycle
3064
3593
  // =========================================================================
3065
3594
  onInferenceStarted(agentName) {
3066
3595
  this.inferring.add(agentName);
3596
+ if (this.changesJudgedContext(agentName))
3597
+ this.stopJudging();
3067
3598
  // A turn is starting — whatever caused it supersedes any pending
3068
3599
  // self-wake. (Arming happens MID-turn via skip_reply, i.e. after this
3069
3600
  // ran for the arming turn, so a fresh arm always survives its own turn.)
@@ -3082,10 +3613,13 @@ export class EventGate {
3082
3613
  * still running. */
3083
3614
  onInferenceResumed(agentName) {
3084
3615
  this.inferring.add(agentName);
3616
+ if (this.changesJudgedContext(agentName))
3617
+ this.stopJudging();
3085
3618
  }
3086
3619
  onInferenceEnded(agentName) {
3087
3620
  this.inferring.delete(agentName);
3088
3621
  this.flushInferenceBufferIfClear();
3622
+ this.flushJudgeHeldIfClear();
3089
3623
  }
3090
3624
  /**
3091
3625
  * Host quiesce suppression (issue #122). While set, debounce firings buffer
@@ -3100,8 +3634,16 @@ export class EventGate {
3100
3634
  if (this.quiesced === quiesced)
3101
3635
  return;
3102
3636
  this.quiesced = quiesced;
3103
- if (!quiesced)
3637
+ if (quiesced) {
3638
+ // A judge reading the context now would read it mid-surgery, and call
3639
+ // its provider while the host holds still: it's stopped, and its batch
3640
+ // held to be judged at resume.
3641
+ this.stopJudging();
3642
+ }
3643
+ else {
3104
3644
  this.flushInferenceBufferIfClear();
3645
+ this.flushJudgeHeldIfClear();
3646
+ }
3105
3647
  }
3106
3648
  flushInferenceBufferIfClear() {
3107
3649
  if (this.quiesced)
@@ -3128,11 +3670,16 @@ export class EventGate {
3128
3670
  * is the signature of the wake-wedge: an agent stuck mid-"inference" so all
3129
3671
  * incoming events buffer and never trigger. */
3130
3672
  inferenceDiagnostics() {
3673
+ let judgeHeldEvents = 0;
3674
+ for (const held of this.judgeHeld.values())
3675
+ judgeHeldEvents += held.events.length;
3131
3676
  return {
3132
3677
  inferring: [...this.inferring],
3133
3678
  bufferedEvents: this.inferenceBuffer.length,
3134
3679
  droppedBufferedEvents: this.droppedBufferedEvents,
3135
3680
  sleepUntil: this.sleepUntil,
3681
+ judgeHeldEvents,
3682
+ droppedJudgeEvents: this.droppedJudgeEvents,
3136
3683
  };
3137
3684
  }
3138
3685
  // =========================================================================
@@ -3273,11 +3820,12 @@ export class EventGate {
3273
3820
  const placement = {};
3274
3821
  this.addMessageFn('user', [{
3275
3822
  type: 'text',
3276
- text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
3823
+ text: `[${NOTICE_TAG.selfWake}] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
3277
3824
  }], { source: 'gate:self-wake' }, agentName, placement);
3278
3825
  this.requestInferenceFn(agentName, `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`, 'self-wake', undefined, placement);
3279
3826
  }, ms);
3280
3827
  this.selfWakeTimers.set(agentName, timer);
3828
+ this.selfWakeDue.set(agentName, { at: this.now() + ms, source });
3281
3829
  this.emitTrace({
3282
3830
  type: 'gate:decision',
3283
3831
  eventType: 'selfwake:set',
@@ -3489,6 +4037,16 @@ export class EventGate {
3489
4037
  fireCount: this.passiveSampleFires.get(p.name) ?? 0,
3490
4038
  };
3491
4039
  }
4040
+ if (typeof p.behavior === 'object' && 'judge' in p.behavior) {
4041
+ const record = this.judgeRecords.get(p.name);
4042
+ result.judgeState = {
4043
+ pendingCount: debounce?.events.length ?? 0,
4044
+ judging: this.judging.has(p.name),
4045
+ heldCount: this.judgeHeld.get(p.name)?.events.length ?? 0,
4046
+ verdicts: { ...(record?.verdicts ?? { reply: 0, wait: 0, error: 0 }) },
4047
+ ...(record?.last ? { last: { ...record.last } } : {}),
4048
+ };
4049
+ }
3492
4050
  return result;
3493
4051
  });
3494
4052
  const byEventType = {};
@@ -3513,9 +4071,14 @@ export class EventGate {
3513
4071
  default: this.config.default ?? 'always',
3514
4072
  script: this.gateScript.status(),
3515
4073
  policies,
3516
- errors: [this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError]
4074
+ errors: [
4075
+ this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError,
4076
+ this.judgeUnwiredNotice(), this.judgeFailingNotice(),
4077
+ ]
3517
4078
  .filter((e) => e !== null),
3518
- shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
4079
+ // Fresh, not the cache: the sources the host knows (eventTypesFrom)
4080
+ // change as servers connect, with no change to the rules.
4081
+ shadowWarnings: this.lintShadows().map(formatShadowWarning),
3519
4082
  totalEvaluations: this.totalEvaluations,
3520
4083
  defaultDecisions: {
3521
4084
  triggered: this.defaultTriggered,
@@ -3524,6 +4087,28 @@ export class EventGate {
3524
4087
  },
3525
4088
  };
3526
4089
  }
4090
+ /** gate_status's word on judge rules in force with no judge wired. */
4091
+ judgeUnwiredNotice() {
4092
+ if (this.judgeFn)
4093
+ return null;
4094
+ const names = this.config.policies
4095
+ .filter((p) => typeof p.behavior === 'object' && 'judge' in p.behavior)
4096
+ .map((p) => p.name);
4097
+ if (names.length === 0)
4098
+ return null;
4099
+ return `${quotedList(names)} ${names.length === 1 ? 'is a judge rule' : 'are judge rules'}, and this host wires no reply `
4100
+ + `judge, so ${names.length === 1 ? 'its batches wake' : 'their batches wake'} the agent as a debounce of the same quiet period would`;
4101
+ }
4102
+ /** gate_status's word on a judge that keeps failing (JUDGE_FAILING_STREAK in a row). */
4103
+ judgeFailingNotice() {
4104
+ if (this.judgeFailStreak < JUDGE_FAILING_STREAK)
4105
+ return null;
4106
+ return `the reply judge has failed its last ${this.judgeFailStreak} requests (the newest: ${this.judgeLastFailure}), `
4107
+ + (this.judgeOnError === 'wake'
4108
+ ? 'so judge rules wake the agent as debounces of the same quiet period would'
4109
+ : "so judge rules' batches don't wake the agent")
4110
+ + "; the host's judge model or provider may not serve it (GateOptions.judge)";
4111
+ }
3527
4112
  // =========================================================================
3528
4113
  // Cleanup
3529
4114
  // =========================================================================
@@ -3533,10 +4118,20 @@ export class EventGate {
3533
4118
  clearTimeout(state.timer);
3534
4119
  }
3535
4120
  this.debounceTimers.clear();
4121
+ // A judge deciding now is told to stop, and its verdict, when it comes,
4122
+ // delivers nothing (finishJudging); held batches go with the buffer.
4123
+ for (const state of this.judging.values()) {
4124
+ if (state.timer)
4125
+ clearTimeout(state.timer);
4126
+ state.abort.abort();
4127
+ }
4128
+ this.judging.clear();
4129
+ this.judgeHeld.clear();
3536
4130
  for (const timer of this.selfWakeTimers.values()) {
3537
4131
  clearTimeout(timer);
3538
4132
  }
3539
4133
  this.selfWakeTimers.clear();
4134
+ this.selfWakeDue.clear();
3540
4135
  // The sleep stays, with its note and agent, and its timer goes: fired
3541
4136
  // after a stop(), its wake reached a framework that had stopped, and the
3542
4137
  // timer held the process until the sleep's end. The next start() arms it