@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
@@ -33,10 +33,18 @@ import type {
33
33
  GateStatus,
34
34
  ShadowWarning,
35
35
  GateProbeRow,
36
+ JudgeConfig,
37
+ JudgeEventSummary,
38
+ JudgeFn,
39
+ JudgeVerdict,
40
+ UpcomingWake,
36
41
  } from './types.js';
37
42
 
38
43
  // Re-export types consumers need
39
- export type { GateConfig, GateOptions, GateDecision, GateEntryRef, GateEventInfo, WakeLine } from './types.js';
44
+ export type {
45
+ GateConfig, GateOptions, GateDecision, GateEntryRef, GateEventInfo, WakeLine,
46
+ JudgeConfig, JudgeEventSummary, JudgeFn, JudgeRequest, JudgeVerdict, ReplyJudgeOptions, UpcomingWake,
47
+ } from './types.js';
40
48
 
41
49
  // ---------------------------------------------------------------------------
42
50
  // Constants
@@ -63,6 +71,15 @@ function parseGateJson(text: string): unknown {
63
71
  const MIN_DEBOUNCE_MS = 100;
64
72
  /** Maximum debounce value (ms). */
65
73
  const MAX_DEBOUNCE_MS = 300_000;
74
+ /** Maximum `judge.maxWaitMs` (ms): an hour. */
75
+ const MAX_JUDGE_WAIT_MS = 3_600_000;
76
+ /** Longest `judge.instructions` (characters) a rule may hold: they go to the
77
+ * judge with every batch, so they're kept short. */
78
+ const MAX_JUDGE_INSTRUCTIONS = 4_000;
79
+ /** How long a judge request may take by default (ms) before onError decides. */
80
+ const DEFAULT_JUDGE_TIMEOUT_MS = 20_000;
81
+ /** Judge failures in a row after which gate_status names the judge as failing. */
82
+ const JUDGE_FAILING_STREAK = 3;
66
83
 
67
84
  /** Whether a check made at `last` is still fresh at `now`, less than the
68
85
  * reload throttle ago. A clock stepped backwards since makes it stale, so
@@ -131,10 +148,11 @@ interface PendingEvent {
131
148
 
132
149
  /** Where the host put a message the gate wrote: the agent whose window it
133
150
  * went to and, when it was held for that agent's live turn, the id it waits
134
- * under in the host's queue. */
151
+ * under in the host's queue, or else the id it was stored under. */
135
152
  export interface WritePlacement {
136
153
  agent?: string;
137
154
  deferredId?: string;
155
+ messageId?: string;
138
156
  }
139
157
 
140
158
  /**
@@ -182,6 +200,33 @@ export interface WakeProvenance {
182
200
  interface DebounceState {
183
201
  timer: ReturnType<typeof setTimeout>;
184
202
  events: PendingEvent[];
203
+ /** When the batch's first event arrived (ms), for a judge's maxWaitMs. */
204
+ firstAt: number;
205
+ /** When its timer fires (ms), as of its last re-arm (upcomingWakes). */
206
+ dueAt: number;
207
+ /** The judge the batch goes to when it fires, as its rule held it when the
208
+ * batch began: a rule removed or changed meanwhile has its batch judged
209
+ * as the rule it was asked. Absent for a debounce. */
210
+ judge?: JudgeConfig;
211
+ }
212
+
213
+ /** A batch the reply judge is deciding now. */
214
+ interface JudgingState {
215
+ events: PendingEvent[];
216
+ judge: JudgeConfig;
217
+ started: number;
218
+ abort: AbortController;
219
+ /** The judge's time limit, cleared once it's decided or the gate disposed. */
220
+ timer?: ReturnType<typeof setTimeout>;
221
+ /** Batches of the same rule that fired meanwhile, judged once this one is
222
+ * decided: delivered with it on reply, judged afresh on wait. */
223
+ queued: PendingEvent[];
224
+ }
225
+
226
+ /** A rule's verdict counts and newest verdict (gate_status judgeState). */
227
+ interface JudgeRecord {
228
+ verdicts: { reply: number; wait: number; error: number };
229
+ last?: NonNullable<GatePolicyStats['judgeState']>['last'];
185
230
  }
186
231
 
187
232
  /** Token bucket for one (policy, key) pair under a rate_limit behavior. */
@@ -333,9 +378,21 @@ function formatBehavior(b: import('./types.js').GateBehavior): string {
333
378
  const k = b.passive_sample.keyBy ? `,keyBy:${b.passive_sample.keyBy}` : '';
334
379
  return `passive_sample:${b.passive_sample.every}${k}`;
335
380
  }
381
+ if ('judge' in b) {
382
+ const w = b.judge.maxWaitMs !== undefined ? `,maxWait:${b.judge.maxWaitMs}` : '';
383
+ return `judge:${b.judge.debounceMs}${w}`;
384
+ }
336
385
  return 'unknown';
337
386
  }
338
387
 
388
+ /** The quiet period (ms) of a batching behavior: a debounce's, or a judge's. */
389
+ function batchQuietMs(b: GateBehavior): number | undefined {
390
+ if (typeof b !== 'object') return undefined;
391
+ if ('debounce' in b) return b.debounce;
392
+ if ('judge' in b) return b.judge.debounceMs;
393
+ return undefined;
394
+ }
395
+
339
396
  /**
340
397
  * Stable fingerprint for a GateBehavior, used during reload to detect
341
398
  * config changes that invalidate per-policy state. JSON.stringify is
@@ -393,9 +450,10 @@ const MATCH_FIELDS = [
393
450
  'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
394
451
  ];
395
452
  const FILTER_FIELDS = ['type', 'pattern'];
396
- const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample'] as const;
453
+ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'] as const;
397
454
  const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
398
455
  const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
456
+ const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
399
457
 
400
458
  function isPlainObject(value: unknown): value is Record<string, unknown> {
401
459
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -600,6 +658,7 @@ function fieldLevels(file: string): ReadonlyArray<{ name: string; fields: readon
600
658
  { name: '"behavior"', fields: BEHAVIOR_KINDS },
601
659
  { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
602
660
  { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
661
+ { name: '"behavior.judge"', fields: JUDGE_FIELDS },
603
662
  ];
604
663
  }
605
664
 
@@ -840,7 +899,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
840
899
  }, (m) => {
841
900
  const of = (fields: readonly string[]) => m.keys.some((key) => fields.includes(key));
842
901
  if (obj.match === undefined && (of(MATCH_FIELDS) || of(FILTER_FIELDS))) return 'so the rule matches every event';
843
- if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS))) {
902
+ if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS) || of(JUDGE_FIELDS))) {
844
903
  return 'so the rule\'s behavior is "always"';
845
904
  }
846
905
  return undefined;
@@ -869,7 +928,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
869
928
 
870
929
  type BehaviorKind = 'always' | 'defer' | 'skip' | (typeof BEHAVIOR_KINDS)[number];
871
930
 
872
- const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit" and "passive_sample"';
931
+ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit", "passive_sample" and "judge"';
873
932
 
874
933
  /**
875
934
  * A rule's behavior: "always" when left out; one of the three names; or an
@@ -878,7 +937,8 @@ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one
878
937
  * and is read so; a list, or an object holding none of the kinds, was
879
938
  * refused, and the rule is left out. Of
880
939
  * two kinds or more, "debounce", then "rate_limit", then "passive_sample" was
881
- * the one read, whatever their order, and is read so. Each other kind is read
940
+ * the one read, whatever their order, and is read so; "judge", newer than
941
+ * those, is read only where none of them is given. Each other kind is read
882
942
  * on its own as well, so that a refusal names its problems too, and a load
883
943
  * reads past them, since it isn't read either way. `kind` is the behavior's
884
944
  * kind even where it can't be read, for passthrough's check.
@@ -899,7 +959,7 @@ function readBehavior(r: Reader, value: unknown): { behavior?: GateBehavior | un
899
959
  const kinds = BEHAVIOR_KINDS.filter((k) => b[k] !== undefined);
900
960
  const kind = kinds[0];
901
961
  if (kind === undefined) {
902
- r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit" and "passive_sample"' });
962
+ r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit", "passive_sample" and "judge"' });
903
963
  return {};
904
964
  }
905
965
  if (kinds.length > 1) {
@@ -1022,11 +1082,99 @@ function readPassiveSample(r: Reader, value: unknown): GateBehavior | undefined
1022
1082
  return { passive_sample: out };
1023
1083
  }
1024
1084
 
1085
+ /**
1086
+ * A judge: a debounce's quiet period, read as `debounce` is, with at its
1087
+ * nearest bound one out of range; an optional longest wait, from that
1088
+ * quiet period to an hour, likewise; and the agent's optional instructions to
1089
+ * the judge, text of at most MAX_JUDGE_INSTRUCTIONS characters. Earlier
1090
+ * releases knew no judge, so nothing deployed has one: a rule whose judge
1091
+ * can't be read is left out, and its instructions, which go to a model with
1092
+ * every batch, are never cut to fit.
1093
+ */
1094
+ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
1095
+ if (!isPlainObject(value)) {
1096
+ r.problems.push({
1097
+ text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
1098
+ });
1099
+ return undefined;
1100
+ }
1101
+ const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
1102
+ maxWaitMs: 'a batch is judged only after its quiet period',
1103
+ instructions: 'the judge decides without the agent\'s word',
1104
+ });
1105
+ readFieldNames(r, 'behavior.judge', conf, JUDGE_FIELDS);
1106
+ let ok = true;
1107
+ const ms = conf.debounceMs;
1108
+ let debounceMs: number | undefined;
1109
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
1110
+ r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
1111
+ ok = false;
1112
+ } else if (ms < MIN_DEBOUNCE_MS) {
1113
+ r.problems.push({
1114
+ text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
1115
+ });
1116
+ debounceMs = MIN_DEBOUNCE_MS;
1117
+ } else if (ms > MAX_DEBOUNCE_MS) {
1118
+ r.problems.push({
1119
+ text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
1120
+ });
1121
+ debounceMs = MAX_DEBOUNCE_MS;
1122
+ } else {
1123
+ debounceMs = ms;
1124
+ }
1125
+ let maxWaitMs: number | undefined;
1126
+ const wait = conf.maxWaitMs;
1127
+ if (wait !== undefined) {
1128
+ if (typeof wait !== 'number' || !Number.isFinite(wait)) {
1129
+ r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
1130
+ ok = false;
1131
+ } else {
1132
+ const floor = debounceMs ?? MIN_DEBOUNCE_MS;
1133
+ if (wait < floor) {
1134
+ r.problems.push({
1135
+ text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
1136
+ read: `so it's read as ${floor} ms`,
1137
+ });
1138
+ maxWaitMs = floor;
1139
+ } else if (wait > MAX_JUDGE_WAIT_MS) {
1140
+ r.problems.push({
1141
+ text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
1142
+ read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
1143
+ });
1144
+ maxWaitMs = MAX_JUDGE_WAIT_MS;
1145
+ } else {
1146
+ maxWaitMs = wait;
1147
+ }
1148
+ }
1149
+ }
1150
+ const said = conf.instructions;
1151
+ let instructions: string | undefined;
1152
+ if (said !== undefined) {
1153
+ if (typeof said !== 'string') {
1154
+ r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
1155
+ ok = false;
1156
+ } else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
1157
+ r.problems.push({
1158
+ text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
1159
+ });
1160
+ ok = false;
1161
+ } else {
1162
+ instructions = said;
1163
+ }
1164
+ }
1165
+ if (!ok || debounceMs === undefined) return undefined;
1166
+ const out: JudgeConfig = { debounceMs };
1167
+ if (maxWaitMs !== undefined) out.maxWaitMs = maxWaitMs;
1168
+ if (instructions !== undefined) out.instructions = instructions;
1169
+ return { judge: out };
1170
+ }
1171
+
1025
1172
  /** How each kind of behavior object is read. */
1026
1173
  const READ_KIND: Readonly<Record<(typeof BEHAVIOR_KINDS)[number], (r: Reader, value: unknown) => GateBehavior | undefined>> = {
1027
1174
  debounce: readDebounce,
1028
1175
  rate_limit: readRateLimit,
1029
1176
  passive_sample: readPassiveSample,
1177
+ judge: readJudge,
1030
1178
  };
1031
1179
 
1032
1180
  /** A timing kind's keyBy: a field name. One of another type was read as none. */
@@ -1846,6 +1994,28 @@ export class EventGate {
1846
1994
  // Debounce state
1847
1995
  private debounceTimers = new Map<string, DebounceState>();
1848
1996
 
1997
+ // Reply judge state (`judge` rules). The judge itself, when the host wires
1998
+ // one; without it a judge rule's batch is delivered as a debounce's.
1999
+ private judgeFn: JudgeFn | undefined;
2000
+ private judgeTimeoutMs: number;
2001
+ private judgeOnError: 'wake' | 'skip';
2002
+ /** Per rule, the batch the judge is deciding now. */
2003
+ private judging = new Map<string, JudgingState>();
2004
+ /** Per rule, a batch that fired while a turn changing the judged context ran or the host was
2005
+ * quiesced, held to be judged once both are clear: the judge reads the
2006
+ * context the turn leaves, which may already answer it. */
2007
+ private judgeHeld = new Map<string, { events: PendingEvent[]; judge: JudgeConfig }>();
2008
+ private judgeRecords = new Map<string, JudgeRecord>();
2009
+ private changesJudgedContextFn: ((agentName: string) => boolean) | undefined;
2010
+ /** Lifetime count of events dropped from batches waiting on the judge, at
2011
+ * the inference buffer's cap (MAX_INFERENCE_BUFFER per rule). */
2012
+ private droppedJudgeEvents = 0;
2013
+ /** Judge requests failed in a row, and the newest failure's reason: a host
2014
+ * whose provider can't serve the judge's model fails every time, which
2015
+ * gate_status says once it's a streak (judgeFailingNotice). */
2016
+ private judgeFailStreak = 0;
2017
+ private judgeLastFailure = '';
2018
+
1849
2019
  // Rate-limit state — policy name → key → bucket.
1850
2020
  private rateLimitBuckets = new Map<string, Map<string, RateBucket>>();
1851
2021
  // Passive-sample state — policy name → key → counter (count since last fire).
@@ -1896,6 +2066,10 @@ export class EventGate {
1896
2066
  // self-wake (an external wake supersedes it; the timer would otherwise
1897
2067
  // land as a redundant empty wake right after the turn).
1898
2068
  private selfWakeTimers = new Map<string, ReturnType<typeof setTimeout>>();
2069
+ /** When each agent's pending self-wake fires, and what armed it
2070
+ * (upcomingWakes). Read only while its timer is in selfWakeTimers, so an
2071
+ * entry a fire or a cancel left behind says nothing. */
2072
+ private selfWakeDue = new Map<string, { at: number; source: string }>();
1899
2073
 
1900
2074
  // Privileged users who may wake the agent through sleep. Hot-reloaded from
1901
2075
  // privilegedUsersPath on change.
@@ -1947,6 +2121,18 @@ export class EventGate {
1947
2121
  now?: () => number;
1948
2122
  /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
1949
2123
  scriptTimeoutMs?: number;
2124
+ /** The reply judge `judge` rules ask (JudgeConfig). Absent: a judge
2125
+ * rule's batch is delivered as a debounce's, and gate_status says so. */
2126
+ judge?: JudgeFn;
2127
+ /** Whether a turn of this agent changes the context the judge reads. Such
2128
+ * turns, and only they, hold a judge rule's batch until they end, and one
2129
+ * that starts while the judge decides stops the judge, whose batch is
2130
+ * then judged after the turn. Absent: every agent's turns do. */
2131
+ changesJudgedContext?: (agentName: string) => boolean;
2132
+ /** How long a judge request may take (ms). Default 20,000. */
2133
+ judgeTimeoutMs?: number;
2134
+ /** What a judge failure does: wake (default) or skip. */
2135
+ judgeOnError?: 'wake' | 'skip';
1950
2136
  /** Map an event to the registered channel it arrived on (the host knows
1951
2137
  * how push-event origins compose into channel ids). The gate routes a
1952
2138
  * batched wake by it, tests a policy's `match.channel` against it when
@@ -1978,6 +2164,12 @@ export class EventGate {
1978
2164
  this.channelShownFn = opts.channelShown;
1979
2165
  this.isNewestEntryFn = opts.isNewestEntry;
1980
2166
  this.now = opts.now ?? (() => Date.now());
2167
+ this.judgeFn = opts.judge;
2168
+ this.changesJudgedContextFn = opts.changesJudgedContext;
2169
+ this.judgeTimeoutMs = opts.judgeTimeoutMs !== undefined && Number.isFinite(opts.judgeTimeoutMs) && opts.judgeTimeoutMs > 0
2170
+ ? Math.min(opts.judgeTimeoutMs, MAX_TIMER_MS)
2171
+ : DEFAULT_JUDGE_TIMEOUT_MS;
2172
+ this.judgeOnError = opts.judgeOnError === 'skip' ? 'skip' : 'wake';
1981
2173
  this.loadPrivileged();
1982
2174
 
1983
2175
  // Seed or reconcile the on-disk config from the host's starting rules
@@ -2488,6 +2680,7 @@ export class EventGate {
2488
2680
  for (const name of removed) {
2489
2681
  this.clearPolicyState(name);
2490
2682
  this.stats.delete(name);
2683
+ this.judgeRecords.delete(name);
2491
2684
  }
2492
2685
  const cleared = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
2493
2686
  for (const name of cleared) this.clearPolicyState(name);
@@ -2963,7 +3156,7 @@ export class EventGate {
2963
3156
  }
2964
3157
 
2965
3158
  if (typeof policy.behavior === 'object') {
2966
- if ('debounce' in policy.behavior) {
3159
+ if ('debounce' in policy.behavior || 'judge' in policy.behavior) {
2967
3160
  this.handleDebounce(policy, info);
2968
3161
  return { trigger: false, policyName: policy.name, behavior: policy.behavior };
2969
3162
  }
@@ -3115,7 +3308,7 @@ export class EventGate {
3115
3308
  if (b === 'always') return true;
3116
3309
  if (b === 'defer' || b === 'skip') return false;
3117
3310
  if (typeof b === 'object') {
3118
- if ('debounce' in b) return false; // batches; never triggers inline
3311
+ if ('debounce' in b || 'judge' in b) return false; // batches; never triggers inline
3119
3312
  if ('rate_limit' in b) {
3120
3313
  const cfg = b.rate_limit;
3121
3314
  const key = this.resolveKey(info, cfg.keyBy);
@@ -3316,7 +3509,8 @@ export class EventGate {
3316
3509
  }
3317
3510
 
3318
3511
  private handleDebounce(policy: GatePolicy, info: GateEventInfo): void {
3319
- const debounceMs = (policy.behavior as { debounce: number }).debounce;
3512
+ const debounceMs = batchQuietMs(policy.behavior) ?? MIN_DEBOUNCE_MS;
3513
+ const judge = typeof policy.behavior === 'object' && 'judge' in policy.behavior ? policy.behavior.judge : undefined;
3320
3514
 
3321
3515
  const event: PendingEvent = {
3322
3516
  policyName: policy.name,
@@ -3340,13 +3534,23 @@ export class EventGate {
3340
3534
  };
3341
3535
 
3342
3536
  const existing = this.debounceTimers.get(policy.name);
3537
+ // A judge's maxWaitMs caps how far new events push the batch back: it's
3538
+ // judged by then, from its first event, quiet or not.
3539
+ const delay = (firstAt: number, batchJudge: JudgeConfig | undefined): number => {
3540
+ const cap = batchJudge?.maxWaitMs;
3541
+ return cap === undefined ? debounceMs : Math.max(0, Math.min(debounceMs, firstAt + cap - this.now()));
3542
+ };
3343
3543
  if (existing) {
3344
3544
  clearTimeout(existing.timer);
3345
3545
  existing.events.push(event);
3346
- existing.timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
3546
+ const ms = delay(existing.firstAt, existing.judge);
3547
+ existing.timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3548
+ existing.dueAt = this.now() + ms;
3347
3549
  } else {
3348
- const timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
3349
- this.debounceTimers.set(policy.name, { timer, events: [event] });
3550
+ const firstAt = this.now();
3551
+ const ms = delay(firstAt, judge);
3552
+ const timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3553
+ this.debounceTimers.set(policy.name, { timer, events: [event], firstAt, dueAt: firstAt + ms, ...(judge ? { judge } : {}) });
3350
3554
  }
3351
3555
  }
3352
3556
 
@@ -3360,6 +3564,14 @@ export class EventGate {
3360
3564
  const events = state.events;
3361
3565
  this.debounceTimers.delete(policyName);
3362
3566
 
3567
+ // A judge rule's batch goes to the reply judge, which delivers it, or
3568
+ // doesn't, once it has decided (judgeBatch). Without a judge wired it's
3569
+ // delivered below, as a debounce's batch is.
3570
+ if (state.judge && this.judgeFn) {
3571
+ this.judgeBatch(policyName, events, state.judge);
3572
+ return;
3573
+ }
3574
+
3363
3575
  // If any agent is currently inferring — or the host is quiesced — buffer
3364
3576
  // for later (with cap). While quiesced, delivering would write a
3365
3577
  // "[Gate: N events]" message into the very context being operated on
@@ -3499,12 +3711,303 @@ export class EventGate {
3499
3711
  }
3500
3712
  }
3501
3713
 
3714
+ // =========================================================================
3715
+ // Reply judge (`judge` rules)
3716
+ // =========================================================================
3717
+
3718
+ /**
3719
+ * Send one of a judge rule's batches to the reply judge. While a resident's
3720
+ * turn runs or the host is quiesced the batch is held instead, merged with
3721
+ * any the rule already holds, and judged once both are clear
3722
+ * (flushJudgeHeldIfClear): the judge then reads the context the turn left,
3723
+ * which may already have answered it. While the judge decides one of the
3724
+ * rule's batches, a later one waits for that verdict (finishJudging), so the
3725
+ * judge never decides two of a rule's batches at once.
3726
+ */
3727
+ private judgeBatch(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
3728
+ if (this.disposed) return;
3729
+ const deciding = this.judging.get(policyName);
3730
+ if (deciding) {
3731
+ deciding.queued.push(...events);
3732
+ this.capJudgeEvents(deciding.queued);
3733
+ return;
3734
+ }
3735
+ if (this.judgedTurnRunning() || this.quiesced) {
3736
+ this.holdForJudge(policyName, events, judge);
3737
+ return;
3738
+ }
3739
+ const fn = this.judgeFn;
3740
+ if (!fn) {
3741
+ this.deliverOrBuffer(events);
3742
+ return;
3743
+ }
3744
+ const abort = new AbortController();
3745
+ const agentNames = this.getAgentNamesFn();
3746
+ const state: JudgingState = { events, judge, started: Date.now(), abort, queued: [] };
3747
+ this.judging.set(policyName, state);
3748
+ const timedOut = new Promise<never>((_, reject) => {
3749
+ state.timer = setTimeout(() => {
3750
+ abort.abort();
3751
+ reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
3752
+ }, this.judgeTimeoutMs);
3753
+ // Never keep the process alive on the judge's account.
3754
+ state.timer.unref?.();
3755
+ });
3756
+ let asked: Promise<JudgeVerdict>;
3757
+ try {
3758
+ asked = fn({
3759
+ policyName,
3760
+ ...(judge.instructions !== undefined ? { instructions: judge.instructions } : {}),
3761
+ events: events.map((e) => this.judgeEventSummary(e)),
3762
+ agentNames,
3763
+ upcomingWakes: this.upcomingWakes(agentNames, policyName),
3764
+ signal: abort.signal,
3765
+ });
3766
+ } catch (err) {
3767
+ asked = Promise.reject(err);
3768
+ }
3769
+ Promise.race([asked, timedOut]).then(
3770
+ (verdict) => this.finishJudging(policyName, state, verdict),
3771
+ (err: unknown) => this.finishJudging(policyName, state, undefined, err),
3772
+ ).finally(() => {
3773
+ if (state.timer) clearTimeout(state.timer);
3774
+ }).catch((err: unknown) => {
3775
+ console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
3776
+ });
3777
+ }
3778
+
3779
+ /** Hold a batch for the judge until no turn changing the context it reads
3780
+ * runs and the host isn't quiesced, merged with what the rule already holds. */
3781
+ private holdForJudge(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
3782
+ const held = this.judgeHeld.get(policyName);
3783
+ if (held) {
3784
+ held.events.push(...events);
3785
+ held.judge = judge;
3786
+ this.capJudgeEvents(held.events);
3787
+ } else {
3788
+ const fresh = [...events];
3789
+ this.capJudgeEvents(fresh);
3790
+ this.judgeHeld.set(policyName, { events: fresh, judge });
3791
+ }
3792
+ }
3793
+
3794
+ /**
3795
+ * Keep a rule's events waiting on the judge to the inference buffer's cap,
3796
+ * dropping the oldest, counted and logged as bufferForInference counts its
3797
+ * drops: a turn that never ends (the wake-wedge) or a long quiesce on a busy
3798
+ * channel would otherwise grow them without bound. A queue beside a batch
3799
+ * the judge is deciding is capped on its own, so its newest events are never
3800
+ * the ones dropped for the batch ahead of it.
3801
+ */
3802
+ private capJudgeEvents(events: PendingEvent[]): void {
3803
+ const drop = events.length - MAX_INFERENCE_BUFFER;
3804
+ if (drop <= 0) return;
3805
+ events.splice(0, drop);
3806
+ const before = this.droppedJudgeEvents;
3807
+ this.droppedJudgeEvents += drop;
3808
+ if (Math.floor(before / 50) !== Math.floor(this.droppedJudgeEvents / 50) || before === 0) {
3809
+ console.error(
3810
+ `[gate] events waiting on the reply judge reached the cap (${MAX_INFERENCE_BUFFER} per rule): dropped `
3811
+ + `${this.droppedJudgeEvents} oldest so far${this.quiesced ? ' during quiesce' : ''}`,
3812
+ );
3813
+ }
3814
+ }
3815
+
3816
+ /** The rule's judge as it is now, when the rule is still a judge rule; else the one the batch was asked with. */
3817
+ private currentJudge(policyName: string, asked: JudgeConfig): JudgeConfig {
3818
+ const now = this.config.policies.find((p) => p.name === policyName)?.behavior;
3819
+ return typeof now === 'object' && 'judge' in now ? now.judge : asked;
3820
+ }
3821
+
3822
+ /**
3823
+ * The gate's own timers that will wake `agentNames` with no new event, for
3824
+ * the judge to weigh what waiting costs: each agent's pending self-wake
3825
+ * (skip_reply's wake_in_seconds), the end of a sleep, and the pending
3826
+ * batches of rules other than `exceptPolicy` (a debounce's wakes the agent
3827
+ * when it fires; a judge rule's goes to the judge, so it only may).
3828
+ */
3829
+ upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] {
3830
+ const wakes: UpcomingWake[] = [];
3831
+ const now = this.now();
3832
+ for (const name of agentNames) {
3833
+ const due = this.selfWakeDue.get(name);
3834
+ if (due && this.selfWakeTimers.has(name)) {
3835
+ wakes.push({ at: due.at, certain: true, what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
3836
+ }
3837
+ }
3838
+ if (this.sleepUntil > now && (this.sleepAgent === undefined || agentNames.includes(this.sleepAgent))) {
3839
+ wakes.push({ at: this.sleepUntil, certain: true, what: 'the end of its sleep' });
3840
+ }
3841
+ for (const [name, state] of this.debounceTimers) {
3842
+ if (name === exceptPolicy || state.events.length === 0) continue;
3843
+ const n = state.events.length;
3844
+ const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
3845
+ wakes.push(state.judge
3846
+ ? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
3847
+ : { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
3848
+ }
3849
+ return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
3850
+ }
3851
+
3852
+ /** A batch's event as the judge is shown it. */
3853
+ private judgeEventSummary(e: PendingEvent): JudgeEventSummary {
3854
+ const id = e.routeChannelId ?? e.channelId;
3855
+ let channel: string | undefined;
3856
+ if (id !== undefined) {
3857
+ let name = id;
3858
+ if (e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn) {
3859
+ try {
3860
+ name = this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId });
3861
+ } catch {
3862
+ name = id;
3863
+ }
3864
+ }
3865
+ channel = e.channelLabel ? `${e.channelLabel.startsWith('#') ? e.channelLabel : `#${e.channelLabel}`} (${name})` : name;
3866
+ }
3867
+ return {
3868
+ eventType: e.eventType,
3869
+ ...(channel !== undefined ? { channel } : {}),
3870
+ ...(e.authorId !== undefined ? { authorId: e.authorId } : {}),
3871
+ addressed: e.addressed,
3872
+ content: e.content,
3873
+ at: e.timestamp,
3874
+ };
3875
+ }
3876
+
3877
+ /**
3878
+ * Apply the judge's verdict on a batch. Reply: the batch, with any of the
3879
+ * rule's batches that fired meanwhile, is delivered as a debounce's is
3880
+ * (held for a turn that started meanwhile, as fireDebounce holds one). Wait:
3881
+ * nothing is delivered, and batches that fired meanwhile go to the judge
3882
+ * afresh. A judge that failed, timed out or answered nothing usable decides
3883
+ * by onError: "wake" delivers, "skip" doesn't. Each verdict is traced and
3884
+ * counted for gate_status, and a failure is logged.
3885
+ */
3886
+ private finishJudging(policyName: string, state: JudgingState, verdict: JudgeVerdict | undefined, err?: unknown): void {
3887
+ // A verdict for a batch the gate let go of (dispose() clears what the
3888
+ // judge was deciding) applies to nothing, even after a start() since.
3889
+ if (this.judging.get(policyName) !== state) return;
3890
+ this.judging.delete(policyName);
3891
+ if (state.timer) clearTimeout(state.timer);
3892
+ const durationMs = Date.now() - state.started;
3893
+ const failed = verdict == null || typeof verdict !== 'object' || typeof verdict.reply !== 'boolean';
3894
+ if (failed && (err as { providerAdmission?: unknown } | undefined)?.providerAdmission === 'deferred') {
3895
+ const wakes = this.judgeOnError === 'wake';
3896
+ console.error(
3897
+ `[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3898
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
3899
+ );
3900
+ if (this.disposed) return;
3901
+ if (wakes) this.deliverOrBuffer([...state.events, ...state.queued]);
3902
+ else if (state.queued.length > 0) this.judgeBatch(policyName, state.queued, this.currentJudge(policyName, state.judge));
3903
+ return;
3904
+ }
3905
+ const outcome: 'reply' | 'wait' | 'error' = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
3906
+ const reason = failed
3907
+ ? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
3908
+ : typeof verdict.reason === 'string' ? verdict.reason : '';
3909
+ const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
3910
+ record.verdicts[outcome]++;
3911
+ record.last = {
3912
+ at: Date.now(),
3913
+ verdict: outcome,
3914
+ reason: safeSlice(reason, 0, 300),
3915
+ eventCount: state.events.length,
3916
+ durationMs,
3917
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3918
+ };
3919
+ if (this.config.policies.some((p) => p.name === policyName)) this.judgeRecords.set(policyName, record);
3920
+ if (failed) {
3921
+ this.judgeFailStreak++;
3922
+ this.judgeLastFailure = safeSlice(reason, 0, 200);
3923
+ } else {
3924
+ this.judgeFailStreak = 0;
3925
+ }
3926
+ const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
3927
+ if (failed) {
3928
+ console.error(
3929
+ `[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
3930
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
3931
+ );
3932
+ }
3933
+ this.emitTrace({
3934
+ type: 'gate:judge-verdict',
3935
+ policyName,
3936
+ verdict: outcome,
3937
+ woke: wake,
3938
+ eventCount: state.events.length,
3939
+ durationMs,
3940
+ ...(!failed && verdict.model ? { model: verdict.model } : {}),
3941
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3942
+ ...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
3943
+ ...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
3944
+ timestamp: Date.now(),
3945
+ });
3946
+ if (this.disposed) return;
3947
+ const judge = this.currentJudge(policyName, state.judge);
3948
+ if (wake) {
3949
+ this.deliverOrBuffer([...state.events, ...state.queued]);
3950
+ return;
3951
+ }
3952
+ if (state.queued.length > 0) this.judgeBatch(policyName, state.queued, judge);
3953
+ }
3954
+
3955
+ /** Deliver a batch now, or hold it for the agents' turns and the host's
3956
+ * quiesce to end, as fireDebounce does; a delivery that throws drops it,
3957
+ * named in the log. */
3958
+ private deliverOrBuffer(events: PendingEvent[]): void {
3959
+ if (events.length === 0) return;
3960
+ if (this.inferring.size > 0 || this.quiesced) {
3961
+ this.bufferForInference(events);
3962
+ return;
3963
+ }
3964
+ try {
3965
+ this.deliverEvents(events);
3966
+ } catch (err) {
3967
+ const one = events.length === 1;
3968
+ console.error(
3969
+ `[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3970
+ + `(${errorText(err)}), so the batch is dropped`,
3971
+ );
3972
+ }
3973
+ }
3974
+
3975
+ private changesJudgedContext(agentName: string): boolean {
3976
+ return this.changesJudgedContextFn?.(agentName) ?? true;
3977
+ }
3978
+
3979
+ private judgedTurnRunning(): boolean {
3980
+ return [...this.inferring].some((name) => this.changesJudgedContext(name));
3981
+ }
3982
+
3983
+ private stopJudging(): void {
3984
+ for (const [policyName, state] of [...this.judging]) {
3985
+ this.judging.delete(policyName);
3986
+ if (state.timer) clearTimeout(state.timer);
3987
+ state.abort.abort();
3988
+ this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge);
3989
+ }
3990
+ }
3991
+
3992
+ /** Send the batches held for the judge once no turn changing the context it reads runs and the host isn't quiesced. */
3993
+ private flushJudgeHeldIfClear(): void {
3994
+ if (this.quiesced || this.judgedTurnRunning() || this.judgeHeld.size === 0) return;
3995
+ const held = [...this.judgeHeld];
3996
+ this.judgeHeld.clear();
3997
+ // Deferred, as the inference buffer's flush is, to stay out of the
3998
+ // turn-end callback that called this.
3999
+ queueMicrotask(() => {
4000
+ for (const [policyName, { events, judge }] of held) this.judgeBatch(policyName, events, this.currentJudge(policyName, judge));
4001
+ });
4002
+ }
4003
+
3502
4004
  // =========================================================================
3503
4005
  // Inference lifecycle
3504
4006
  // =========================================================================
3505
4007
 
3506
4008
  onInferenceStarted(agentName: string): void {
3507
4009
  this.inferring.add(agentName);
4010
+ if (this.changesJudgedContext(agentName)) this.stopJudging();
3508
4011
  // A turn is starting — whatever caused it supersedes any pending
3509
4012
  // self-wake. (Arming happens MID-turn via skip_reply, i.e. after this
3510
4013
  // ran for the arming turn, so a fresh arm always survives its own turn.)
@@ -3524,11 +4027,13 @@ export class EventGate {
3524
4027
  * still running. */
3525
4028
  onInferenceResumed(agentName: string): void {
3526
4029
  this.inferring.add(agentName);
4030
+ if (this.changesJudgedContext(agentName)) this.stopJudging();
3527
4031
  }
3528
4032
 
3529
4033
  onInferenceEnded(agentName: string): void {
3530
4034
  this.inferring.delete(agentName);
3531
4035
  this.flushInferenceBufferIfClear();
4036
+ this.flushJudgeHeldIfClear();
3532
4037
  }
3533
4038
 
3534
4039
  /**
@@ -3543,7 +4048,15 @@ export class EventGate {
3543
4048
  setQuiesced(quiesced: boolean): void {
3544
4049
  if (this.quiesced === quiesced) return;
3545
4050
  this.quiesced = quiesced;
3546
- if (!quiesced) this.flushInferenceBufferIfClear();
4051
+ if (quiesced) {
4052
+ // A judge reading the context now would read it mid-surgery, and call
4053
+ // its provider while the host holds still: it's stopped, and its batch
4054
+ // held to be judged at resume.
4055
+ this.stopJudging();
4056
+ } else {
4057
+ this.flushInferenceBufferIfClear();
4058
+ this.flushJudgeHeldIfClear();
4059
+ }
3547
4060
  }
3548
4061
 
3549
4062
  private flushInferenceBufferIfClear(): void {
@@ -3576,12 +4089,19 @@ export class EventGate {
3576
4089
  bufferedEvents: number;
3577
4090
  droppedBufferedEvents: number;
3578
4091
  sleepUntil: number;
4092
+ /** Events held for the reply judge until the turn or quiesce ends, and the judge's own drops at the cap. */
4093
+ judgeHeldEvents: number;
4094
+ droppedJudgeEvents: number;
3579
4095
  } {
4096
+ let judgeHeldEvents = 0;
4097
+ for (const held of this.judgeHeld.values()) judgeHeldEvents += held.events.length;
3580
4098
  return {
3581
4099
  inferring: [...this.inferring],
3582
4100
  bufferedEvents: this.inferenceBuffer.length,
3583
4101
  droppedBufferedEvents: this.droppedBufferedEvents,
3584
4102
  sleepUntil: this.sleepUntil,
4103
+ judgeHeldEvents,
4104
+ droppedJudgeEvents: this.droppedJudgeEvents,
3585
4105
  };
3586
4106
  }
3587
4107
 
@@ -3737,6 +4257,7 @@ export class EventGate {
3737
4257
  );
3738
4258
  }, ms);
3739
4259
  this.selfWakeTimers.set(agentName, timer);
4260
+ this.selfWakeDue.set(agentName, { at: this.now() + ms, source });
3740
4261
  this.emitTrace({
3741
4262
  type: 'gate:decision',
3742
4263
  eventType: 'selfwake:set',
@@ -3935,6 +4456,16 @@ export class EventGate {
3935
4456
  fireCount: this.passiveSampleFires.get(p.name) ?? 0,
3936
4457
  };
3937
4458
  }
4459
+ if (typeof p.behavior === 'object' && 'judge' in p.behavior) {
4460
+ const record = this.judgeRecords.get(p.name);
4461
+ result.judgeState = {
4462
+ pendingCount: debounce?.events.length ?? 0,
4463
+ judging: this.judging.has(p.name),
4464
+ heldCount: this.judgeHeld.get(p.name)?.events.length ?? 0,
4465
+ verdicts: { ...(record?.verdicts ?? { reply: 0, wait: 0, error: 0 }) },
4466
+ ...(record?.last ? { last: { ...record.last } } : {}),
4467
+ };
4468
+ }
3938
4469
  return result;
3939
4470
  });
3940
4471
 
@@ -3961,7 +4492,10 @@ export class EventGate {
3961
4492
  default: this.config.default ?? 'always',
3962
4493
  script: this.gateScript.status(),
3963
4494
  policies,
3964
- errors: [this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError]
4495
+ errors: [
4496
+ this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError,
4497
+ this.judgeUnwiredNotice(), this.judgeFailingNotice(),
4498
+ ]
3965
4499
  .filter((e): e is string => e !== null),
3966
4500
  shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
3967
4501
  totalEvaluations: this.totalEvaluations,
@@ -3973,6 +4507,27 @@ export class EventGate {
3973
4507
  };
3974
4508
  }
3975
4509
 
4510
+ /** gate_status's word on judge rules in force with no judge wired. */
4511
+ private judgeUnwiredNotice(): string | null {
4512
+ if (this.judgeFn) return null;
4513
+ const names = this.config.policies
4514
+ .filter((p) => typeof p.behavior === 'object' && 'judge' in p.behavior)
4515
+ .map((p) => p.name);
4516
+ if (names.length === 0) return null;
4517
+ return `${quotedList(names)} ${names.length === 1 ? 'is a judge rule' : 'are judge rules'}, and this host wires no reply `
4518
+ + `judge, so ${names.length === 1 ? 'its batches wake' : 'their batches wake'} the agent as a debounce of the same quiet period would`;
4519
+ }
4520
+
4521
+ /** gate_status's word on a judge that keeps failing (JUDGE_FAILING_STREAK in a row). */
4522
+ private judgeFailingNotice(): string | null {
4523
+ if (this.judgeFailStreak < JUDGE_FAILING_STREAK) return null;
4524
+ return `the reply judge has failed its last ${this.judgeFailStreak} requests (the newest: ${this.judgeLastFailure}), `
4525
+ + (this.judgeOnError === 'wake'
4526
+ ? 'so judge rules wake the agent as debounces of the same quiet period would'
4527
+ : "so judge rules' batches don't wake the agent")
4528
+ + "; the host's judge model or provider may not serve it (GateOptions.judge)";
4529
+ }
4530
+
3976
4531
  // =========================================================================
3977
4532
  // Cleanup
3978
4533
  // =========================================================================
@@ -3983,10 +4538,19 @@ export class EventGate {
3983
4538
  clearTimeout(state.timer);
3984
4539
  }
3985
4540
  this.debounceTimers.clear();
4541
+ // A judge deciding now is told to stop, and its verdict, when it comes,
4542
+ // delivers nothing (finishJudging); held batches go with the buffer.
4543
+ for (const state of this.judging.values()) {
4544
+ if (state.timer) clearTimeout(state.timer);
4545
+ state.abort.abort();
4546
+ }
4547
+ this.judging.clear();
4548
+ this.judgeHeld.clear();
3986
4549
  for (const timer of this.selfWakeTimers.values()) {
3987
4550
  clearTimeout(timer);
3988
4551
  }
3989
4552
  this.selfWakeTimers.clear();
4553
+ this.selfWakeDue.clear();
3990
4554
  // The sleep stays, with its note and agent, and its timer goes: fired
3991
4555
  // after a stop(), its wake reached a framework that had stopped, and the
3992
4556
  // timer held the process until the sleep's end. The next start() arms it