@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.
- package/README.md +56 -15
- package/dist/src/agent.d.ts +29 -2
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +69 -11
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +4 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +3 -2
- package/dist/src/auto-continue.d.ts.map +1 -1
- package/dist/src/auto-continue.js +4 -3
- package/dist/src/auto-continue.js.map +1 -1
- package/dist/src/cut-off-round.d.ts +24 -0
- package/dist/src/cut-off-round.d.ts.map +1 -0
- package/dist/src/cut-off-round.js +87 -0
- package/dist/src/cut-off-round.js.map +1 -0
- package/dist/src/framework.d.ts +304 -89
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +2577 -678
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +99 -3
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +538 -13
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +2 -1
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -0
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/gate/reply-judge.d.ts +116 -0
- package/dist/src/gate/reply-judge.d.ts.map +1 -0
- package/dist/src/gate/reply-judge.js +467 -0
- package/dist/src/gate/reply-judge.js.map +1 -0
- package/dist/src/gate/types.d.ts +161 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/heartbeat/heartbeat.d.ts +184 -0
- package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat/heartbeat.js +752 -0
- package/dist/src/heartbeat/heartbeat.js.map +1 -0
- package/dist/src/heartbeat/reminder-time.d.ts +5 -0
- package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
- package/dist/src/heartbeat/reminder-time.js +107 -0
- package/dist/src/heartbeat/reminder-time.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
- package/dist/src/mcpl/capability-mask.js +3 -2
- package/dist/src/mcpl/capability-mask.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +2 -1
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +26 -10
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +40 -69
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -8
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +50 -10
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +9 -2
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +12 -2
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-presence.d.ts +66 -14
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
- package/dist/src/mcpl/tool-presence.js +134 -65
- package/dist/src/mcpl/tool-presence.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +10 -3
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
- package/dist/src/mcpl/tool-signature.js +47 -18
- package/dist/src/mcpl/tool-signature.js.map +1 -1
- package/dist/src/mcpl/toolset-host.d.ts +88 -37
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
- package/dist/src/mcpl/toolset-host.js +207 -108
- package/dist/src/mcpl/toolset-host.js.map +1 -1
- package/dist/src/mcpl/toolsets-module.d.ts +14 -11
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
- package/dist/src/mcpl/toolsets-module.js +141 -49
- package/dist/src/mcpl/toolsets-module.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +3 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +4 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +9 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +22 -11
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/workspace/answers.d.ts +22 -0
- package/dist/src/modules/workspace/answers.d.ts.map +1 -0
- package/dist/src/modules/workspace/answers.js +123 -0
- package/dist/src/modules/workspace/answers.js.map +1 -0
- package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +6 -2
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/direct-write.d.ts +3 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
- package/dist/src/modules/workspace/direct-write.js +57 -24
- package/dist/src/modules/workspace/direct-write.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +48 -17
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/edit-text.d.ts +2 -1
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/edit-text.js +4 -2
- package/dist/src/modules/workspace/edit-text.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +20 -13
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +62 -51
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +934 -531
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +75 -63
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +366 -180
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
- package/dist/src/modules/workspace/read-page.js +7 -1
- package/dist/src/modules/workspace/read-page.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +26 -16
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/result-text.d.ts +1 -0
- package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/result-text.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +9 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +14 -0
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +96 -43
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +8 -3
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/prose-drafts.d.ts +4 -3
- package/dist/src/prose-drafts.d.ts.map +1 -1
- package/dist/src/prose-drafts.js +3 -2
- package/dist/src/prose-drafts.js.map +1 -1
- package/dist/src/reasoning-loss-notice.d.ts +23 -0
- package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
- package/dist/src/reasoning-loss-notice.js +50 -0
- package/dist/src/reasoning-loss-notice.js.map +1 -0
- package/dist/src/reasoning-loss.d.ts +24 -0
- package/dist/src/reasoning-loss.d.ts.map +1 -0
- package/dist/src/reasoning-loss.js +102 -0
- package/dist/src/reasoning-loss.js.map +1 -0
- package/dist/src/send-tools.d.ts +3 -2
- package/dist/src/send-tools.d.ts.map +1 -1
- package/dist/src/send-tools.js +3 -2
- package/dist/src/send-tools.js.map +1 -1
- package/dist/src/sent-rows.js +1 -1
- package/dist/src/thinking-drops.d.ts +4 -11
- package/dist/src/thinking-drops.d.ts.map +1 -1
- package/dist/src/thinking-drops.js +7 -14
- package/dist/src/thinking-drops.js.map +1 -1
- package/dist/src/tool-args.d.ts +12 -0
- package/dist/src/tool-args.d.ts.map +1 -1
- package/dist/src/tool-args.js +35 -1
- package/dist/src/tool-args.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +8 -4
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +23 -11
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +4 -1
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/types/agent.d.ts +40 -9
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +18 -8
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +15 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +5 -2
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +34 -1
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agent.ts +72 -11
- package/src/api/server.ts +4 -2
- package/src/auto-continue.ts +4 -3
- package/src/cut-off-round.ts +98 -0
- package/src/framework.ts +2694 -700
- package/src/gate/event-gate.ts +579 -15
- package/src/gate/index.ts +8 -0
- package/src/gate/reply-judge.ts +512 -0
- package/src/gate/types.ts +164 -1
- package/src/heartbeat/heartbeat.ts +804 -0
- package/src/heartbeat/reminder-time.ts +107 -0
- package/src/index.ts +4 -0
- package/src/mcpl/capability-mask.ts +3 -2
- package/src/mcpl/channel-registry.ts +2 -1
- package/src/mcpl/inbound-source.ts +42 -69
- package/src/mcpl/server-connection.ts +56 -16
- package/src/mcpl/tags.ts +13 -2
- package/src/mcpl/tool-presence.ts +133 -67
- package/src/mcpl/tool-signature.ts +51 -20
- package/src/mcpl/toolset-host.ts +197 -107
- package/src/mcpl/toolsets-module.ts +143 -49
- package/src/mcpl/types.ts +3 -1
- package/src/module-registry.ts +8 -2
- package/src/modules/history/index.ts +22 -12
- package/src/modules/workspace/answers.ts +133 -0
- package/src/modules/workspace/branch-intent.ts +10 -5
- package/src/modules/workspace/direct-write.ts +58 -23
- package/src/modules/workspace/disk-agreement.ts +52 -21
- package/src/modules/workspace/edit-text.ts +7 -5
- package/src/modules/workspace/effects.ts +21 -12
- package/src/modules/workspace/index.ts +927 -541
- package/src/modules/workspace/observe.ts +381 -177
- package/src/modules/workspace/read-page.ts +7 -1
- package/src/modules/workspace/reconcile.ts +27 -15
- package/src/modules/workspace/result-text.ts +2 -0
- package/src/modules/workspace/sync.ts +16 -0
- package/src/modules/workspace/types.ts +96 -43
- package/src/modules/workspace/watcher.ts +8 -3
- package/src/prose-drafts.ts +4 -3
- package/src/reasoning-loss-notice.ts +62 -0
- package/src/reasoning-loss.ts +120 -0
- package/src/send-tools.ts +3 -2
- package/src/sent-rows.ts +1 -1
- package/src/thinking-drops.ts +10 -16
- package/src/tool-args.ts +34 -1
- package/src/tool-result-guard.ts +26 -11
- package/src/tune-out/tools.ts +4 -1
- package/src/types/agent.ts +40 -9
- package/src/types/events.ts +18 -8
- package/src/types/framework.ts +15 -0
- package/src/types/module.ts +5 -2
- package/src/types/trace.ts +36 -1
package/src/gate/event-gate.ts
CHANGED
|
@@ -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 {
|
|
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 "
|
|
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
|
|
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 "
|
|
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
|
|
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
|
-
|
|
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
|
|
3349
|
-
|
|
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 (
|
|
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: [
|
|
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
|