@animalabs/agent-framework 0.27.0 → 0.28.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 (234) hide show
  1. package/README.md +56 -15
  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 +69 -11
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +4 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +3 -2
  10. package/dist/src/auto-continue.d.ts.map +1 -1
  11. package/dist/src/auto-continue.js +4 -3
  12. package/dist/src/auto-continue.js.map +1 -1
  13. package/dist/src/cut-off-round.d.ts +24 -0
  14. package/dist/src/cut-off-round.d.ts.map +1 -0
  15. package/dist/src/cut-off-round.js +87 -0
  16. package/dist/src/cut-off-round.js.map +1 -0
  17. package/dist/src/framework.d.ts +304 -89
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +2577 -678
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +99 -3
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +538 -13
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -1
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -0
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/reply-judge.d.ts +116 -0
  30. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  31. package/dist/src/gate/reply-judge.js +467 -0
  32. package/dist/src/gate/reply-judge.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +161 -0
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  36. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  37. package/dist/src/heartbeat/heartbeat.js +752 -0
  38. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  39. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  40. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  41. package/dist/src/heartbeat/reminder-time.js +107 -0
  42. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  43. package/dist/src/index.d.ts +4 -0
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +2 -0
  46. package/dist/src/index.js.map +1 -1
  47. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  48. package/dist/src/mcpl/capability-mask.js +3 -2
  49. package/dist/src/mcpl/capability-mask.js.map +1 -1
  50. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  51. package/dist/src/mcpl/channel-registry.js +2 -1
  52. package/dist/src/mcpl/channel-registry.js.map +1 -1
  53. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  54. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  55. package/dist/src/mcpl/inbound-source.js +40 -69
  56. package/dist/src/mcpl/inbound-source.js.map +1 -1
  57. package/dist/src/mcpl/server-connection.d.ts +18 -8
  58. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-connection.js +50 -10
  60. package/dist/src/mcpl/server-connection.js.map +1 -1
  61. package/dist/src/mcpl/tags.d.ts +9 -2
  62. package/dist/src/mcpl/tags.d.ts.map +1 -1
  63. package/dist/src/mcpl/tags.js +12 -2
  64. package/dist/src/mcpl/tags.js.map +1 -1
  65. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  66. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  67. package/dist/src/mcpl/tool-presence.js +134 -65
  68. package/dist/src/mcpl/tool-presence.js.map +1 -1
  69. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  70. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  71. package/dist/src/mcpl/tool-signature.js +47 -18
  72. package/dist/src/mcpl/tool-signature.js.map +1 -1
  73. package/dist/src/mcpl/toolset-host.d.ts +88 -37
  74. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  75. package/dist/src/mcpl/toolset-host.js +207 -108
  76. package/dist/src/mcpl/toolset-host.js.map +1 -1
  77. package/dist/src/mcpl/toolsets-module.d.ts +14 -11
  78. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  79. package/dist/src/mcpl/toolsets-module.js +141 -49
  80. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  81. package/dist/src/mcpl/types.d.ts +3 -1
  82. package/dist/src/mcpl/types.d.ts.map +1 -1
  83. package/dist/src/mcpl/types.js.map +1 -1
  84. package/dist/src/module-registry.d.ts +4 -1
  85. package/dist/src/module-registry.d.ts.map +1 -1
  86. package/dist/src/module-registry.js +9 -2
  87. package/dist/src/module-registry.js.map +1 -1
  88. package/dist/src/modules/history/index.d.ts.map +1 -1
  89. package/dist/src/modules/history/index.js +22 -11
  90. package/dist/src/modules/history/index.js.map +1 -1
  91. package/dist/src/modules/workspace/answers.d.ts +22 -0
  92. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/answers.js +123 -0
  94. package/dist/src/modules/workspace/answers.js.map +1 -0
  95. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  96. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  97. package/dist/src/modules/workspace/branch-intent.js +6 -2
  98. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  99. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  100. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  101. package/dist/src/modules/workspace/direct-write.js +57 -24
  102. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  103. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  104. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  106. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  107. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  108. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/edit-text.js +4 -2
  110. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/effects.js +20 -13
  113. package/dist/src/modules/workspace/effects.js.map +1 -1
  114. package/dist/src/modules/workspace/index.d.ts +62 -51
  115. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  116. package/dist/src/modules/workspace/index.js +934 -531
  117. package/dist/src/modules/workspace/index.js.map +1 -1
  118. package/dist/src/modules/workspace/observe.d.ts +75 -63
  119. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  120. package/dist/src/modules/workspace/observe.js +366 -180
  121. package/dist/src/modules/workspace/observe.js.map +1 -1
  122. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  123. package/dist/src/modules/workspace/read-page.js +7 -1
  124. package/dist/src/modules/workspace/read-page.js.map +1 -1
  125. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/reconcile.js +26 -16
  127. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  128. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  129. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  130. package/dist/src/modules/workspace/result-text.js.map +1 -1
  131. package/dist/src/modules/workspace/sync.d.ts +9 -0
  132. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  133. package/dist/src/modules/workspace/sync.js +14 -0
  134. package/dist/src/modules/workspace/sync.js.map +1 -1
  135. package/dist/src/modules/workspace/types.d.ts +96 -43
  136. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  137. package/dist/src/modules/workspace/types.js.map +1 -1
  138. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/watcher.js +8 -3
  140. package/dist/src/modules/workspace/watcher.js.map +1 -1
  141. package/dist/src/prose-drafts.d.ts +4 -3
  142. package/dist/src/prose-drafts.d.ts.map +1 -1
  143. package/dist/src/prose-drafts.js +3 -2
  144. package/dist/src/prose-drafts.js.map +1 -1
  145. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  146. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  147. package/dist/src/reasoning-loss-notice.js +50 -0
  148. package/dist/src/reasoning-loss-notice.js.map +1 -0
  149. package/dist/src/reasoning-loss.d.ts +24 -0
  150. package/dist/src/reasoning-loss.d.ts.map +1 -0
  151. package/dist/src/reasoning-loss.js +102 -0
  152. package/dist/src/reasoning-loss.js.map +1 -0
  153. package/dist/src/send-tools.d.ts +3 -2
  154. package/dist/src/send-tools.d.ts.map +1 -1
  155. package/dist/src/send-tools.js +3 -2
  156. package/dist/src/send-tools.js.map +1 -1
  157. package/dist/src/sent-rows.js +1 -1
  158. package/dist/src/thinking-drops.d.ts +4 -11
  159. package/dist/src/thinking-drops.d.ts.map +1 -1
  160. package/dist/src/thinking-drops.js +7 -14
  161. package/dist/src/thinking-drops.js.map +1 -1
  162. package/dist/src/tool-args.d.ts +12 -0
  163. package/dist/src/tool-args.d.ts.map +1 -1
  164. package/dist/src/tool-args.js +35 -1
  165. package/dist/src/tool-args.js.map +1 -1
  166. package/dist/src/tool-result-guard.d.ts +8 -4
  167. package/dist/src/tool-result-guard.d.ts.map +1 -1
  168. package/dist/src/tool-result-guard.js +23 -11
  169. package/dist/src/tool-result-guard.js.map +1 -1
  170. package/dist/src/tune-out/tools.d.ts +4 -1
  171. package/dist/src/tune-out/tools.d.ts.map +1 -1
  172. package/dist/src/types/agent.d.ts +40 -9
  173. package/dist/src/types/agent.d.ts.map +1 -1
  174. package/dist/src/types/events.d.ts +18 -8
  175. package/dist/src/types/events.d.ts.map +1 -1
  176. package/dist/src/types/framework.d.ts +15 -0
  177. package/dist/src/types/framework.d.ts.map +1 -1
  178. package/dist/src/types/module.d.ts +5 -2
  179. package/dist/src/types/module.d.ts.map +1 -1
  180. package/dist/src/types/trace.d.ts +34 -1
  181. package/dist/src/types/trace.d.ts.map +1 -1
  182. package/package.json +3 -3
  183. package/src/agent.ts +72 -11
  184. package/src/api/server.ts +4 -2
  185. package/src/auto-continue.ts +4 -3
  186. package/src/cut-off-round.ts +98 -0
  187. package/src/framework.ts +2694 -700
  188. package/src/gate/event-gate.ts +579 -15
  189. package/src/gate/index.ts +8 -0
  190. package/src/gate/reply-judge.ts +512 -0
  191. package/src/gate/types.ts +164 -1
  192. package/src/heartbeat/heartbeat.ts +804 -0
  193. package/src/heartbeat/reminder-time.ts +107 -0
  194. package/src/index.ts +4 -0
  195. package/src/mcpl/capability-mask.ts +3 -2
  196. package/src/mcpl/channel-registry.ts +2 -1
  197. package/src/mcpl/inbound-source.ts +42 -69
  198. package/src/mcpl/server-connection.ts +56 -16
  199. package/src/mcpl/tags.ts +13 -2
  200. package/src/mcpl/tool-presence.ts +133 -67
  201. package/src/mcpl/tool-signature.ts +51 -20
  202. package/src/mcpl/toolset-host.ts +197 -107
  203. package/src/mcpl/toolsets-module.ts +143 -49
  204. package/src/mcpl/types.ts +3 -1
  205. package/src/module-registry.ts +8 -2
  206. package/src/modules/history/index.ts +22 -12
  207. package/src/modules/workspace/answers.ts +133 -0
  208. package/src/modules/workspace/branch-intent.ts +10 -5
  209. package/src/modules/workspace/direct-write.ts +58 -23
  210. package/src/modules/workspace/disk-agreement.ts +52 -21
  211. package/src/modules/workspace/edit-text.ts +7 -5
  212. package/src/modules/workspace/effects.ts +21 -12
  213. package/src/modules/workspace/index.ts +927 -541
  214. package/src/modules/workspace/observe.ts +381 -177
  215. package/src/modules/workspace/read-page.ts +7 -1
  216. package/src/modules/workspace/reconcile.ts +27 -15
  217. package/src/modules/workspace/result-text.ts +2 -0
  218. package/src/modules/workspace/sync.ts +16 -0
  219. package/src/modules/workspace/types.ts +96 -43
  220. package/src/modules/workspace/watcher.ts +8 -3
  221. package/src/prose-drafts.ts +4 -3
  222. package/src/reasoning-loss-notice.ts +62 -0
  223. package/src/reasoning-loss.ts +120 -0
  224. package/src/send-tools.ts +3 -2
  225. package/src/sent-rows.ts +1 -1
  226. package/src/thinking-drops.ts +10 -16
  227. package/src/tool-args.ts +34 -1
  228. package/src/tool-result-guard.ts +26 -11
  229. package/src/tune-out/tools.ts +4 -1
  230. package/src/types/agent.ts +40 -9
  231. package/src/types/events.ts +18 -8
  232. package/src/types/framework.ts +15 -0
  233. package/src/types/module.ts +5 -2
  234. package/src/types/trace.ts +36 -1
@@ -40,6 +40,15 @@ function parseGateJson(text) {
40
40
  const MIN_DEBOUNCE_MS = 100;
41
41
  /** Maximum debounce value (ms). */
42
42
  const MAX_DEBOUNCE_MS = 300_000;
43
+ /** Maximum `judge.maxWaitMs` (ms): an hour. */
44
+ const MAX_JUDGE_WAIT_MS = 3_600_000;
45
+ /** Longest `judge.instructions` (characters) a rule may hold: they go to the
46
+ * judge with every batch, so they're kept short. */
47
+ const MAX_JUDGE_INSTRUCTIONS = 4_000;
48
+ /** How long a judge request may take by default (ms) before onError decides. */
49
+ const DEFAULT_JUDGE_TIMEOUT_MS = 20_000;
50
+ /** Judge failures in a row after which gate_status names the judge as failing. */
51
+ const JUDGE_FAILING_STREAK = 3;
43
52
  /** Whether a check made at `last` is still fresh at `now`, less than the
44
53
  * reload throttle ago. A clock stepped backwards since makes it stale, so
45
54
  * the next check runs rather than wait for the clock to catch up. */
@@ -176,8 +185,22 @@ function formatBehavior(b) {
176
185
  const k = b.passive_sample.keyBy ? `,keyBy:${b.passive_sample.keyBy}` : '';
177
186
  return `passive_sample:${b.passive_sample.every}${k}`;
178
187
  }
188
+ if ('judge' in b) {
189
+ const w = b.judge.maxWaitMs !== undefined ? `,maxWait:${b.judge.maxWaitMs}` : '';
190
+ return `judge:${b.judge.debounceMs}${w}`;
191
+ }
179
192
  return 'unknown';
180
193
  }
194
+ /** The quiet period (ms) of a batching behavior: a debounce's, or a judge's. */
195
+ function batchQuietMs(b) {
196
+ if (typeof b !== 'object')
197
+ return undefined;
198
+ if ('debounce' in b)
199
+ return b.debounce;
200
+ if ('judge' in b)
201
+ return b.judge.debounceMs;
202
+ return undefined;
203
+ }
181
204
  /**
182
205
  * Stable fingerprint for a GateBehavior, used during reload to detect
183
206
  * config changes that invalidate per-policy state. JSON.stringify is
@@ -232,9 +255,10 @@ const MATCH_FIELDS = [
232
255
  'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
233
256
  ];
234
257
  const FILTER_FIELDS = ['type', 'pattern'];
235
- const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample'];
258
+ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'];
236
259
  const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
237
260
  const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
261
+ const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
238
262
  function isPlainObject(value) {
239
263
  return value !== null && typeof value === 'object' && !Array.isArray(value);
240
264
  }
@@ -410,6 +434,7 @@ function fieldLevels(file) {
410
434
  { name: '"behavior"', fields: BEHAVIOR_KINDS },
411
435
  { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
412
436
  { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
437
+ { name: '"behavior.judge"', fields: JUDGE_FIELDS },
413
438
  ];
414
439
  }
415
440
  /** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
@@ -589,7 +614,7 @@ function readRule(raw, unnamed, file = 'gate.json') {
589
614
  const of = (fields) => m.keys.some((key) => fields.includes(key));
590
615
  if (obj.match === undefined && (of(MATCH_FIELDS) || of(FILTER_FIELDS)))
591
616
  return 'so the rule matches every event';
592
- if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS))) {
617
+ if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS) || of(JUDGE_FIELDS))) {
593
618
  return 'so the rule\'s behavior is "always"';
594
619
  }
595
620
  return undefined;
@@ -618,7 +643,7 @@ function readRule(raw, unnamed, file = 'gate.json') {
618
643
  }
619
644
  return reading;
620
645
  }
621
- const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit" and "passive_sample"';
646
+ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit", "passive_sample" and "judge"';
622
647
  /**
623
648
  * A rule's behavior: "always" when left out; one of the three names; or an
624
649
  * object holding one of the timing kinds. Anything else but a list or an
@@ -626,7 +651,8 @@ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one
626
651
  * and is read so; a list, or an object holding none of the kinds, was
627
652
  * refused, and the rule is left out. Of
628
653
  * 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
654
+ * the one read, whatever their order, and is read so; "judge", newer than
655
+ * those, is read only where none of them is given. Each other kind is read
630
656
  * on its own as well, so that a refusal names its problems too, and a load
631
657
  * reads past them, since it isn't read either way. `kind` is the behavior's
632
658
  * kind even where it can't be read, for passthrough's check.
@@ -649,7 +675,7 @@ function readBehavior(r, value) {
649
675
  const kinds = BEHAVIOR_KINDS.filter((k) => b[k] !== undefined);
650
676
  const kind = kinds[0];
651
677
  if (kind === undefined) {
652
- r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit" and "passive_sample"' });
678
+ r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit", "passive_sample" and "judge"' });
653
679
  return {};
654
680
  }
655
681
  if (kinds.length > 1) {
@@ -777,11 +803,109 @@ function readPassiveSample(r, value) {
777
803
  out.keyBy = keyBy;
778
804
  return { passive_sample: out };
779
805
  }
806
+ /**
807
+ * A judge: a debounce's quiet period, read as `debounce` is, with at its
808
+ * nearest bound one out of range; an optional longest wait, from that
809
+ * quiet period to an hour, likewise; and the agent's optional instructions to
810
+ * the judge, text of at most MAX_JUDGE_INSTRUCTIONS characters. Earlier
811
+ * releases knew no judge, so nothing deployed has one: a rule whose judge
812
+ * can't be read is left out, and its instructions, which go to a model with
813
+ * every batch, are never cut to fit.
814
+ */
815
+ function readJudge(r, value) {
816
+ if (!isPlainObject(value)) {
817
+ r.problems.push({
818
+ text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
819
+ });
820
+ return undefined;
821
+ }
822
+ const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
823
+ maxWaitMs: 'a batch is judged only after its quiet period',
824
+ instructions: 'the judge decides without the agent\'s word',
825
+ });
826
+ readFieldNames(r, 'behavior.judge', conf, JUDGE_FIELDS);
827
+ let ok = true;
828
+ const ms = conf.debounceMs;
829
+ let debounceMs;
830
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
831
+ r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
832
+ ok = false;
833
+ }
834
+ else if (ms < MIN_DEBOUNCE_MS) {
835
+ r.problems.push({
836
+ text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
837
+ });
838
+ debounceMs = MIN_DEBOUNCE_MS;
839
+ }
840
+ else if (ms > MAX_DEBOUNCE_MS) {
841
+ r.problems.push({
842
+ text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
843
+ });
844
+ debounceMs = MAX_DEBOUNCE_MS;
845
+ }
846
+ else {
847
+ debounceMs = ms;
848
+ }
849
+ let maxWaitMs;
850
+ const wait = conf.maxWaitMs;
851
+ if (wait !== undefined) {
852
+ if (typeof wait !== 'number' || !Number.isFinite(wait)) {
853
+ r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
854
+ ok = false;
855
+ }
856
+ else {
857
+ const floor = debounceMs ?? MIN_DEBOUNCE_MS;
858
+ if (wait < floor) {
859
+ r.problems.push({
860
+ text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
861
+ read: `so it's read as ${floor} ms`,
862
+ });
863
+ maxWaitMs = floor;
864
+ }
865
+ else if (wait > MAX_JUDGE_WAIT_MS) {
866
+ r.problems.push({
867
+ text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
868
+ read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
869
+ });
870
+ maxWaitMs = MAX_JUDGE_WAIT_MS;
871
+ }
872
+ else {
873
+ maxWaitMs = wait;
874
+ }
875
+ }
876
+ }
877
+ const said = conf.instructions;
878
+ let instructions;
879
+ if (said !== undefined) {
880
+ if (typeof said !== 'string') {
881
+ r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
882
+ ok = false;
883
+ }
884
+ else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
885
+ r.problems.push({
886
+ text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
887
+ });
888
+ ok = false;
889
+ }
890
+ else {
891
+ instructions = said;
892
+ }
893
+ }
894
+ if (!ok || debounceMs === undefined)
895
+ return undefined;
896
+ const out = { debounceMs };
897
+ if (maxWaitMs !== undefined)
898
+ out.maxWaitMs = maxWaitMs;
899
+ if (instructions !== undefined)
900
+ out.instructions = instructions;
901
+ return { judge: out };
902
+ }
780
903
  /** How each kind of behavior object is read. */
781
904
  const READ_KIND = {
782
905
  debounce: readDebounce,
783
906
  rate_limit: readRateLimit,
784
907
  passive_sample: readPassiveSample,
908
+ judge: readJudge,
785
909
  };
786
910
  /** A timing kind's keyBy: a field name. One of another type was read as none. */
787
911
  function readKeyBy(r, kind, value) {
@@ -1521,6 +1645,27 @@ export class EventGate {
1521
1645
  startUnsavedMerged = false;
1522
1646
  // Debounce state
1523
1647
  debounceTimers = new Map();
1648
+ // Reply judge state (`judge` rules). The judge itself, when the host wires
1649
+ // one; without it a judge rule's batch is delivered as a debounce's.
1650
+ judgeFn;
1651
+ judgeTimeoutMs;
1652
+ judgeOnError;
1653
+ /** Per rule, the batch the judge is deciding now. */
1654
+ judging = new Map();
1655
+ /** Per rule, a batch that fired while a turn changing the judged context ran or the host was
1656
+ * quiesced, held to be judged once both are clear: the judge reads the
1657
+ * context the turn leaves, which may already answer it. */
1658
+ judgeHeld = new Map();
1659
+ judgeRecords = new Map();
1660
+ changesJudgedContextFn;
1661
+ /** Lifetime count of events dropped from batches waiting on the judge, at
1662
+ * the inference buffer's cap (MAX_INFERENCE_BUFFER per rule). */
1663
+ droppedJudgeEvents = 0;
1664
+ /** Judge requests failed in a row, and the newest failure's reason: a host
1665
+ * whose provider can't serve the judge's model fails every time, which
1666
+ * gate_status says once it's a streak (judgeFailingNotice). */
1667
+ judgeFailStreak = 0;
1668
+ judgeLastFailure = '';
1524
1669
  // Rate-limit state — policy name → key → bucket.
1525
1670
  rateLimitBuckets = new Map();
1526
1671
  // Passive-sample state — policy name → key → counter (count since last fire).
@@ -1565,6 +1710,10 @@ export class EventGate {
1565
1710
  // self-wake (an external wake supersedes it; the timer would otherwise
1566
1711
  // land as a redundant empty wake right after the turn).
1567
1712
  selfWakeTimers = new Map();
1713
+ /** When each agent's pending self-wake fires, and what armed it
1714
+ * (upcomingWakes). Read only while its timer is in selfWakeTimers, so an
1715
+ * entry a fire or a cancel left behind says nothing. */
1716
+ selfWakeDue = new Map();
1568
1717
  // Privileged users who may wake the agent through sleep. Hot-reloaded from
1569
1718
  // privilegedUsersPath on change.
1570
1719
  privilegedUsersPath;
@@ -1595,6 +1744,12 @@ export class EventGate {
1595
1744
  this.channelShownFn = opts.channelShown;
1596
1745
  this.isNewestEntryFn = opts.isNewestEntry;
1597
1746
  this.now = opts.now ?? (() => Date.now());
1747
+ this.judgeFn = opts.judge;
1748
+ this.changesJudgedContextFn = opts.changesJudgedContext;
1749
+ this.judgeTimeoutMs = opts.judgeTimeoutMs !== undefined && Number.isFinite(opts.judgeTimeoutMs) && opts.judgeTimeoutMs > 0
1750
+ ? Math.min(opts.judgeTimeoutMs, MAX_TIMER_MS)
1751
+ : DEFAULT_JUDGE_TIMEOUT_MS;
1752
+ this.judgeOnError = opts.judgeOnError === 'skip' ? 'skip' : 'wake';
1598
1753
  this.loadPrivileged();
1599
1754
  // Seed or reconcile the on-disk config from the host's starting rules
1600
1755
  // (`initialConfig`), which are read as gate.json is, rule by rule: a rule
@@ -2110,6 +2265,7 @@ export class EventGate {
2110
2265
  for (const name of removed) {
2111
2266
  this.clearPolicyState(name);
2112
2267
  this.stats.delete(name);
2268
+ this.judgeRecords.delete(name);
2113
2269
  }
2114
2270
  const cleared = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
2115
2271
  for (const name of cleared)
@@ -2568,7 +2724,7 @@ export class EventGate {
2568
2724
  return { trigger: true, policyName: policy.name, behavior: 'always' };
2569
2725
  }
2570
2726
  if (typeof policy.behavior === 'object') {
2571
- if ('debounce' in policy.behavior) {
2727
+ if ('debounce' in policy.behavior || 'judge' in policy.behavior) {
2572
2728
  this.handleDebounce(policy, info);
2573
2729
  return { trigger: false, policyName: policy.name, behavior: policy.behavior };
2574
2730
  }
@@ -2709,7 +2865,7 @@ export class EventGate {
2709
2865
  if (b === 'defer' || b === 'skip')
2710
2866
  return false;
2711
2867
  if (typeof b === 'object') {
2712
- if ('debounce' in b)
2868
+ if ('debounce' in b || 'judge' in b)
2713
2869
  return false; // batches; never triggers inline
2714
2870
  if ('rate_limit' in b) {
2715
2871
  const cfg = b.rate_limit;
@@ -2889,7 +3045,8 @@ export class EventGate {
2889
3045
  }
2890
3046
  }
2891
3047
  handleDebounce(policy, info) {
2892
- const debounceMs = policy.behavior.debounce;
3048
+ const debounceMs = batchQuietMs(policy.behavior) ?? MIN_DEBOUNCE_MS;
3049
+ const judge = typeof policy.behavior === 'object' && 'judge' in policy.behavior ? policy.behavior.judge : undefined;
2893
3050
  const event = {
2894
3051
  policyName: policy.name,
2895
3052
  content: info.content.length > MAX_CONTENT_SNIPPET
@@ -2910,14 +3067,24 @@ export class EventGate {
2910
3067
  ...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
2911
3068
  };
2912
3069
  const existing = this.debounceTimers.get(policy.name);
3070
+ // A judge's maxWaitMs caps how far new events push the batch back: it's
3071
+ // judged by then, from its first event, quiet or not.
3072
+ const delay = (firstAt, batchJudge) => {
3073
+ const cap = batchJudge?.maxWaitMs;
3074
+ return cap === undefined ? debounceMs : Math.max(0, Math.min(debounceMs, firstAt + cap - this.now()));
3075
+ };
2913
3076
  if (existing) {
2914
3077
  clearTimeout(existing.timer);
2915
3078
  existing.events.push(event);
2916
- existing.timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
3079
+ const ms = delay(existing.firstAt, existing.judge);
3080
+ existing.timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3081
+ existing.dueAt = this.now() + ms;
2917
3082
  }
2918
3083
  else {
2919
- const timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
2920
- this.debounceTimers.set(policy.name, { timer, events: [event] });
3084
+ const firstAt = this.now();
3085
+ const ms = delay(firstAt, judge);
3086
+ const timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3087
+ this.debounceTimers.set(policy.name, { timer, events: [event], firstAt, dueAt: firstAt + ms, ...(judge ? { judge } : {}) });
2921
3088
  }
2922
3089
  }
2923
3090
  fireDebounce(policyName) {
@@ -2928,6 +3095,13 @@ export class EventGate {
2928
3095
  }
2929
3096
  const events = state.events;
2930
3097
  this.debounceTimers.delete(policyName);
3098
+ // A judge rule's batch goes to the reply judge, which delivers it, or
3099
+ // doesn't, once it has decided (judgeBatch). Without a judge wired it's
3100
+ // delivered below, as a debounce's batch is.
3101
+ if (state.judge && this.judgeFn) {
3102
+ this.judgeBatch(policyName, events, state.judge);
3103
+ return;
3104
+ }
2931
3105
  // If any agent is currently inferring — or the host is quiesced — buffer
2932
3106
  // for later (with cap). While quiesced, delivering would write a
2933
3107
  // "[Gate: N events]" message into the very context being operated on
@@ -3060,10 +3234,299 @@ export class EventGate {
3060
3234
  }
3061
3235
  }
3062
3236
  // =========================================================================
3237
+ // Reply judge (`judge` rules)
3238
+ // =========================================================================
3239
+ /**
3240
+ * Send one of a judge rule's batches to the reply judge. While a resident's
3241
+ * turn runs or the host is quiesced the batch is held instead, merged with
3242
+ * any the rule already holds, and judged once both are clear
3243
+ * (flushJudgeHeldIfClear): the judge then reads the context the turn left,
3244
+ * which may already have answered it. While the judge decides one of the
3245
+ * rule's batches, a later one waits for that verdict (finishJudging), so the
3246
+ * judge never decides two of a rule's batches at once.
3247
+ */
3248
+ judgeBatch(policyName, events, judge) {
3249
+ if (this.disposed)
3250
+ return;
3251
+ const deciding = this.judging.get(policyName);
3252
+ if (deciding) {
3253
+ deciding.queued.push(...events);
3254
+ this.capJudgeEvents(deciding.queued);
3255
+ return;
3256
+ }
3257
+ if (this.judgedTurnRunning() || this.quiesced) {
3258
+ this.holdForJudge(policyName, events, judge);
3259
+ return;
3260
+ }
3261
+ const fn = this.judgeFn;
3262
+ if (!fn) {
3263
+ this.deliverOrBuffer(events);
3264
+ return;
3265
+ }
3266
+ const abort = new AbortController();
3267
+ const agentNames = this.getAgentNamesFn();
3268
+ const state = { events, judge, started: Date.now(), abort, queued: [] };
3269
+ this.judging.set(policyName, state);
3270
+ const timedOut = new Promise((_, reject) => {
3271
+ state.timer = setTimeout(() => {
3272
+ abort.abort();
3273
+ reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
3274
+ }, this.judgeTimeoutMs);
3275
+ // Never keep the process alive on the judge's account.
3276
+ state.timer.unref?.();
3277
+ });
3278
+ let asked;
3279
+ try {
3280
+ asked = fn({
3281
+ policyName,
3282
+ ...(judge.instructions !== undefined ? { instructions: judge.instructions } : {}),
3283
+ events: events.map((e) => this.judgeEventSummary(e)),
3284
+ agentNames,
3285
+ upcomingWakes: this.upcomingWakes(agentNames, policyName),
3286
+ signal: abort.signal,
3287
+ });
3288
+ }
3289
+ catch (err) {
3290
+ asked = Promise.reject(err);
3291
+ }
3292
+ Promise.race([asked, timedOut]).then((verdict) => this.finishJudging(policyName, state, verdict), (err) => this.finishJudging(policyName, state, undefined, err)).finally(() => {
3293
+ if (state.timer)
3294
+ clearTimeout(state.timer);
3295
+ }).catch((err) => {
3296
+ console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
3297
+ });
3298
+ }
3299
+ /** Hold a batch for the judge until no turn changing the context it reads
3300
+ * runs and the host isn't quiesced, merged with what the rule already holds. */
3301
+ holdForJudge(policyName, events, judge) {
3302
+ const held = this.judgeHeld.get(policyName);
3303
+ if (held) {
3304
+ held.events.push(...events);
3305
+ held.judge = judge;
3306
+ this.capJudgeEvents(held.events);
3307
+ }
3308
+ else {
3309
+ const fresh = [...events];
3310
+ this.capJudgeEvents(fresh);
3311
+ this.judgeHeld.set(policyName, { events: fresh, judge });
3312
+ }
3313
+ }
3314
+ /**
3315
+ * Keep a rule's events waiting on the judge to the inference buffer's cap,
3316
+ * dropping the oldest, counted and logged as bufferForInference counts its
3317
+ * drops: a turn that never ends (the wake-wedge) or a long quiesce on a busy
3318
+ * channel would otherwise grow them without bound. A queue beside a batch
3319
+ * the judge is deciding is capped on its own, so its newest events are never
3320
+ * the ones dropped for the batch ahead of it.
3321
+ */
3322
+ capJudgeEvents(events) {
3323
+ const drop = events.length - MAX_INFERENCE_BUFFER;
3324
+ if (drop <= 0)
3325
+ return;
3326
+ events.splice(0, drop);
3327
+ const before = this.droppedJudgeEvents;
3328
+ this.droppedJudgeEvents += drop;
3329
+ if (Math.floor(before / 50) !== Math.floor(this.droppedJudgeEvents / 50) || before === 0) {
3330
+ console.error(`[gate] events waiting on the reply judge reached the cap (${MAX_INFERENCE_BUFFER} per rule): dropped `
3331
+ + `${this.droppedJudgeEvents} oldest so far${this.quiesced ? ' during quiesce' : ''}`);
3332
+ }
3333
+ }
3334
+ /** The rule's judge as it is now, when the rule is still a judge rule; else the one the batch was asked with. */
3335
+ currentJudge(policyName, asked) {
3336
+ const now = this.config.policies.find((p) => p.name === policyName)?.behavior;
3337
+ return typeof now === 'object' && 'judge' in now ? now.judge : asked;
3338
+ }
3339
+ /**
3340
+ * The gate's own timers that will wake `agentNames` with no new event, for
3341
+ * the judge to weigh what waiting costs: each agent's pending self-wake
3342
+ * (skip_reply's wake_in_seconds), the end of a sleep, and the pending
3343
+ * batches of rules other than `exceptPolicy` (a debounce's wakes the agent
3344
+ * when it fires; a judge rule's goes to the judge, so it only may).
3345
+ */
3346
+ upcomingWakes(agentNames, exceptPolicy) {
3347
+ const wakes = [];
3348
+ const now = this.now();
3349
+ for (const name of agentNames) {
3350
+ const due = this.selfWakeDue.get(name);
3351
+ if (due && this.selfWakeTimers.has(name)) {
3352
+ wakes.push({ at: due.at, certain: true, what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
3353
+ }
3354
+ }
3355
+ if (this.sleepUntil > now && (this.sleepAgent === undefined || agentNames.includes(this.sleepAgent))) {
3356
+ wakes.push({ at: this.sleepUntil, certain: true, what: 'the end of its sleep' });
3357
+ }
3358
+ for (const [name, state] of this.debounceTimers) {
3359
+ if (name === exceptPolicy || state.events.length === 0)
3360
+ continue;
3361
+ const n = state.events.length;
3362
+ const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
3363
+ wakes.push(state.judge
3364
+ ? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
3365
+ : { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
3366
+ }
3367
+ return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
3368
+ }
3369
+ /** A batch's event as the judge is shown it. */
3370
+ judgeEventSummary(e) {
3371
+ const id = e.routeChannelId ?? e.channelId;
3372
+ let channel;
3373
+ if (id !== undefined) {
3374
+ let name = id;
3375
+ if (e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn) {
3376
+ try {
3377
+ name = this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId });
3378
+ }
3379
+ catch {
3380
+ name = id;
3381
+ }
3382
+ }
3383
+ channel = e.channelLabel ? `${e.channelLabel.startsWith('#') ? e.channelLabel : `#${e.channelLabel}`} (${name})` : name;
3384
+ }
3385
+ return {
3386
+ eventType: e.eventType,
3387
+ ...(channel !== undefined ? { channel } : {}),
3388
+ ...(e.authorId !== undefined ? { authorId: e.authorId } : {}),
3389
+ addressed: e.addressed,
3390
+ content: e.content,
3391
+ at: e.timestamp,
3392
+ };
3393
+ }
3394
+ /**
3395
+ * Apply the judge's verdict on a batch. Reply: the batch, with any of the
3396
+ * rule's batches that fired meanwhile, is delivered as a debounce's is
3397
+ * (held for a turn that started meanwhile, as fireDebounce holds one). Wait:
3398
+ * nothing is delivered, and batches that fired meanwhile go to the judge
3399
+ * afresh. A judge that failed, timed out or answered nothing usable decides
3400
+ * by onError: "wake" delivers, "skip" doesn't. Each verdict is traced and
3401
+ * counted for gate_status, and a failure is logged.
3402
+ */
3403
+ finishJudging(policyName, state, verdict, err) {
3404
+ // A verdict for a batch the gate let go of (dispose() clears what the
3405
+ // judge was deciding) applies to nothing, even after a start() since.
3406
+ if (this.judging.get(policyName) !== state)
3407
+ return;
3408
+ this.judging.delete(policyName);
3409
+ if (state.timer)
3410
+ clearTimeout(state.timer);
3411
+ const durationMs = Date.now() - state.started;
3412
+ const failed = verdict == null || typeof verdict !== 'object' || typeof verdict.reply !== 'boolean';
3413
+ if (failed && err?.providerAdmission === 'deferred') {
3414
+ const wakes = this.judgeOnError === 'wake';
3415
+ console.error(`[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3416
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3417
+ if (this.disposed)
3418
+ return;
3419
+ if (wakes)
3420
+ this.deliverOrBuffer([...state.events, ...state.queued]);
3421
+ else if (state.queued.length > 0)
3422
+ this.judgeBatch(policyName, state.queued, this.currentJudge(policyName, state.judge));
3423
+ return;
3424
+ }
3425
+ const outcome = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
3426
+ const reason = failed
3427
+ ? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
3428
+ : typeof verdict.reason === 'string' ? verdict.reason : '';
3429
+ const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
3430
+ record.verdicts[outcome]++;
3431
+ record.last = {
3432
+ at: Date.now(),
3433
+ verdict: outcome,
3434
+ reason: safeSlice(reason, 0, 300),
3435
+ eventCount: state.events.length,
3436
+ durationMs,
3437
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3438
+ };
3439
+ if (this.config.policies.some((p) => p.name === policyName))
3440
+ this.judgeRecords.set(policyName, record);
3441
+ if (failed) {
3442
+ this.judgeFailStreak++;
3443
+ this.judgeLastFailure = safeSlice(reason, 0, 200);
3444
+ }
3445
+ else {
3446
+ this.judgeFailStreak = 0;
3447
+ }
3448
+ const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
3449
+ if (failed) {
3450
+ console.error(`[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
3451
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3452
+ }
3453
+ this.emitTrace({
3454
+ type: 'gate:judge-verdict',
3455
+ policyName,
3456
+ verdict: outcome,
3457
+ woke: wake,
3458
+ eventCount: state.events.length,
3459
+ durationMs,
3460
+ ...(!failed && verdict.model ? { model: verdict.model } : {}),
3461
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3462
+ ...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
3463
+ ...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
3464
+ timestamp: Date.now(),
3465
+ });
3466
+ if (this.disposed)
3467
+ return;
3468
+ const judge = this.currentJudge(policyName, state.judge);
3469
+ if (wake) {
3470
+ this.deliverOrBuffer([...state.events, ...state.queued]);
3471
+ return;
3472
+ }
3473
+ if (state.queued.length > 0)
3474
+ this.judgeBatch(policyName, state.queued, judge);
3475
+ }
3476
+ /** Deliver a batch now, or hold it for the agents' turns and the host's
3477
+ * quiesce to end, as fireDebounce does; a delivery that throws drops it,
3478
+ * named in the log. */
3479
+ deliverOrBuffer(events) {
3480
+ if (events.length === 0)
3481
+ return;
3482
+ if (this.inferring.size > 0 || this.quiesced) {
3483
+ this.bufferForInference(events);
3484
+ return;
3485
+ }
3486
+ try {
3487
+ this.deliverEvents(events);
3488
+ }
3489
+ catch (err) {
3490
+ const one = events.length === 1;
3491
+ console.error(`[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3492
+ + `(${errorText(err)}), so the batch is dropped`);
3493
+ }
3494
+ }
3495
+ changesJudgedContext(agentName) {
3496
+ return this.changesJudgedContextFn?.(agentName) ?? true;
3497
+ }
3498
+ judgedTurnRunning() {
3499
+ return [...this.inferring].some((name) => this.changesJudgedContext(name));
3500
+ }
3501
+ stopJudging() {
3502
+ for (const [policyName, state] of [...this.judging]) {
3503
+ this.judging.delete(policyName);
3504
+ if (state.timer)
3505
+ clearTimeout(state.timer);
3506
+ state.abort.abort();
3507
+ this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge);
3508
+ }
3509
+ }
3510
+ /** Send the batches held for the judge once no turn changing the context it reads runs and the host isn't quiesced. */
3511
+ flushJudgeHeldIfClear() {
3512
+ if (this.quiesced || this.judgedTurnRunning() || this.judgeHeld.size === 0)
3513
+ return;
3514
+ const held = [...this.judgeHeld];
3515
+ this.judgeHeld.clear();
3516
+ // Deferred, as the inference buffer's flush is, to stay out of the
3517
+ // turn-end callback that called this.
3518
+ queueMicrotask(() => {
3519
+ for (const [policyName, { events, judge }] of held)
3520
+ this.judgeBatch(policyName, events, this.currentJudge(policyName, judge));
3521
+ });
3522
+ }
3523
+ // =========================================================================
3063
3524
  // Inference lifecycle
3064
3525
  // =========================================================================
3065
3526
  onInferenceStarted(agentName) {
3066
3527
  this.inferring.add(agentName);
3528
+ if (this.changesJudgedContext(agentName))
3529
+ this.stopJudging();
3067
3530
  // A turn is starting — whatever caused it supersedes any pending
3068
3531
  // self-wake. (Arming happens MID-turn via skip_reply, i.e. after this
3069
3532
  // ran for the arming turn, so a fresh arm always survives its own turn.)
@@ -3082,10 +3545,13 @@ export class EventGate {
3082
3545
  * still running. */
3083
3546
  onInferenceResumed(agentName) {
3084
3547
  this.inferring.add(agentName);
3548
+ if (this.changesJudgedContext(agentName))
3549
+ this.stopJudging();
3085
3550
  }
3086
3551
  onInferenceEnded(agentName) {
3087
3552
  this.inferring.delete(agentName);
3088
3553
  this.flushInferenceBufferIfClear();
3554
+ this.flushJudgeHeldIfClear();
3089
3555
  }
3090
3556
  /**
3091
3557
  * Host quiesce suppression (issue #122). While set, debounce firings buffer
@@ -3100,8 +3566,16 @@ export class EventGate {
3100
3566
  if (this.quiesced === quiesced)
3101
3567
  return;
3102
3568
  this.quiesced = quiesced;
3103
- if (!quiesced)
3569
+ if (quiesced) {
3570
+ // A judge reading the context now would read it mid-surgery, and call
3571
+ // its provider while the host holds still: it's stopped, and its batch
3572
+ // held to be judged at resume.
3573
+ this.stopJudging();
3574
+ }
3575
+ else {
3104
3576
  this.flushInferenceBufferIfClear();
3577
+ this.flushJudgeHeldIfClear();
3578
+ }
3105
3579
  }
3106
3580
  flushInferenceBufferIfClear() {
3107
3581
  if (this.quiesced)
@@ -3128,11 +3602,16 @@ export class EventGate {
3128
3602
  * is the signature of the wake-wedge: an agent stuck mid-"inference" so all
3129
3603
  * incoming events buffer and never trigger. */
3130
3604
  inferenceDiagnostics() {
3605
+ let judgeHeldEvents = 0;
3606
+ for (const held of this.judgeHeld.values())
3607
+ judgeHeldEvents += held.events.length;
3131
3608
  return {
3132
3609
  inferring: [...this.inferring],
3133
3610
  bufferedEvents: this.inferenceBuffer.length,
3134
3611
  droppedBufferedEvents: this.droppedBufferedEvents,
3135
3612
  sleepUntil: this.sleepUntil,
3613
+ judgeHeldEvents,
3614
+ droppedJudgeEvents: this.droppedJudgeEvents,
3136
3615
  };
3137
3616
  }
3138
3617
  // =========================================================================
@@ -3278,6 +3757,7 @@ export class EventGate {
3278
3757
  this.requestInferenceFn(agentName, `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`, 'self-wake', undefined, placement);
3279
3758
  }, ms);
3280
3759
  this.selfWakeTimers.set(agentName, timer);
3760
+ this.selfWakeDue.set(agentName, { at: this.now() + ms, source });
3281
3761
  this.emitTrace({
3282
3762
  type: 'gate:decision',
3283
3763
  eventType: 'selfwake:set',
@@ -3489,6 +3969,16 @@ export class EventGate {
3489
3969
  fireCount: this.passiveSampleFires.get(p.name) ?? 0,
3490
3970
  };
3491
3971
  }
3972
+ if (typeof p.behavior === 'object' && 'judge' in p.behavior) {
3973
+ const record = this.judgeRecords.get(p.name);
3974
+ result.judgeState = {
3975
+ pendingCount: debounce?.events.length ?? 0,
3976
+ judging: this.judging.has(p.name),
3977
+ heldCount: this.judgeHeld.get(p.name)?.events.length ?? 0,
3978
+ verdicts: { ...(record?.verdicts ?? { reply: 0, wait: 0, error: 0 }) },
3979
+ ...(record?.last ? { last: { ...record.last } } : {}),
3980
+ };
3981
+ }
3492
3982
  return result;
3493
3983
  });
3494
3984
  const byEventType = {};
@@ -3513,7 +4003,10 @@ export class EventGate {
3513
4003
  default: this.config.default ?? 'always',
3514
4004
  script: this.gateScript.status(),
3515
4005
  policies,
3516
- errors: [this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError]
4006
+ errors: [
4007
+ this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError,
4008
+ this.judgeUnwiredNotice(), this.judgeFailingNotice(),
4009
+ ]
3517
4010
  .filter((e) => e !== null),
3518
4011
  shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
3519
4012
  totalEvaluations: this.totalEvaluations,
@@ -3524,6 +4017,28 @@ export class EventGate {
3524
4017
  },
3525
4018
  };
3526
4019
  }
4020
+ /** gate_status's word on judge rules in force with no judge wired. */
4021
+ judgeUnwiredNotice() {
4022
+ if (this.judgeFn)
4023
+ return null;
4024
+ const names = this.config.policies
4025
+ .filter((p) => typeof p.behavior === 'object' && 'judge' in p.behavior)
4026
+ .map((p) => p.name);
4027
+ if (names.length === 0)
4028
+ return null;
4029
+ return `${quotedList(names)} ${names.length === 1 ? 'is a judge rule' : 'are judge rules'}, and this host wires no reply `
4030
+ + `judge, so ${names.length === 1 ? 'its batches wake' : 'their batches wake'} the agent as a debounce of the same quiet period would`;
4031
+ }
4032
+ /** gate_status's word on a judge that keeps failing (JUDGE_FAILING_STREAK in a row). */
4033
+ judgeFailingNotice() {
4034
+ if (this.judgeFailStreak < JUDGE_FAILING_STREAK)
4035
+ return null;
4036
+ return `the reply judge has failed its last ${this.judgeFailStreak} requests (the newest: ${this.judgeLastFailure}), `
4037
+ + (this.judgeOnError === 'wake'
4038
+ ? 'so judge rules wake the agent as debounces of the same quiet period would'
4039
+ : "so judge rules' batches don't wake the agent")
4040
+ + "; the host's judge model or provider may not serve it (GateOptions.judge)";
4041
+ }
3527
4042
  // =========================================================================
3528
4043
  // Cleanup
3529
4044
  // =========================================================================
@@ -3533,10 +4048,20 @@ export class EventGate {
3533
4048
  clearTimeout(state.timer);
3534
4049
  }
3535
4050
  this.debounceTimers.clear();
4051
+ // A judge deciding now is told to stop, and its verdict, when it comes,
4052
+ // delivers nothing (finishJudging); held batches go with the buffer.
4053
+ for (const state of this.judging.values()) {
4054
+ if (state.timer)
4055
+ clearTimeout(state.timer);
4056
+ state.abort.abort();
4057
+ }
4058
+ this.judging.clear();
4059
+ this.judgeHeld.clear();
3536
4060
  for (const timer of this.selfWakeTimers.values()) {
3537
4061
  clearTimeout(timer);
3538
4062
  }
3539
4063
  this.selfWakeTimers.clear();
4064
+ this.selfWakeDue.clear();
3540
4065
  // The sleep stays, with its note and agent, and its timer goes: fired
3541
4066
  // after a stop(), its wake reached a framework that had stopped, and the
3542
4067
  // timer held the process until the sleep's end. The next start() arms it