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