@animalabs/agent-framework 0.5.6 → 0.6.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/dist/src/agent.d.ts +20 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +45 -14
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +220 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1489 -69
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +71 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +292 -9
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/gate-script-worker.d.ts +2 -0
- package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
- package/dist/src/gate/gate-script-worker.js +69 -0
- package/dist/src/gate/gate-script-worker.js.map +1 -0
- package/dist/src/gate/gate-script.d.ts +44 -0
- package/dist/src/gate/gate-script.d.ts.map +1 -0
- package/dist/src/gate/gate-script.js +164 -0
- package/dist/src/gate/gate-script.js.map +1 -0
- package/dist/src/gate/types.d.ts +35 -3
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -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/channel-registry.d.ts +93 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +260 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js +15 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +161 -0
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
- package/dist/src/mcpl/conversation-router.js +179 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +10 -1
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +2 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +35 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +149 -206
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +98 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -0
- package/dist/src/mcpl/transport.js +281 -0
- package/dist/src/mcpl/transport.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +42 -2
- 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 +5 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +16 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +10 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +83 -0
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/prose-segments.d.ts +18 -0
- package/dist/src/prose-segments.d.ts.map +1 -0
- package/dist/src/prose-segments.js +39 -0
- package/dist/src/prose-segments.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
- package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
- package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog.js +106 -0
- package/dist/src/runtime/liveness-watchdog.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +17 -0
- package/dist/src/tool-result-history.d.ts.map +1 -0
- package/dist/src/tool-result-history.js +60 -0
- package/dist/src/tool-result-history.js.map +1 -0
- package/dist/src/types/agent.d.ts +21 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +22 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +27 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +28 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.d.ts +2 -0
- package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
- package/dist/test/channel-registry-routing.test.js +182 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -0
- package/dist/test/checkpoint-attribution.test.d.ts +2 -0
- package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
- package/dist/test/checkpoint-attribution.test.js +40 -0
- package/dist/test/checkpoint-attribution.test.js.map +1 -0
- package/dist/test/conversation-router.test.d.ts +2 -0
- package/dist/test/conversation-router.test.d.ts.map +1 -0
- package/dist/test/conversation-router.test.js +160 -0
- package/dist/test/conversation-router.test.js.map +1 -0
- package/dist/test/conversation-routing.test.d.ts +7 -0
- package/dist/test/conversation-routing.test.d.ts.map +1 -0
- package/dist/test/conversation-routing.test.js +203 -0
- package/dist/test/conversation-routing.test.js.map +1 -0
- package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
- package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
- package/dist/test/event-gate-policy-mutation.test.js +137 -0
- package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
- package/dist/test/event-gate-script.test.d.ts +2 -0
- package/dist/test/event-gate-script.test.d.ts.map +1 -0
- package/dist/test/event-gate-script.test.js +95 -0
- package/dist/test/event-gate-script.test.js.map +1 -0
- package/dist/test/event-gate-tags.test.d.ts +2 -0
- package/dist/test/event-gate-tags.test.d.ts.map +1 -0
- package/dist/test/event-gate-tags.test.js +76 -0
- package/dist/test/event-gate-tags.test.js.map +1 -0
- package/dist/test/framework.test.js +73 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +41 -0
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
- package/dist/test/helpers/mock-membrane.js +161 -0
- package/dist/test/helpers/mock-membrane.js.map +1 -0
- package/dist/test/inference-failure-observability.test.d.ts +2 -0
- package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
- package/dist/test/inference-failure-observability.test.js +75 -0
- package/dist/test/inference-failure-observability.test.js.map +1 -0
- package/dist/test/liveness-watchdog.test.d.ts +2 -0
- package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
- package/dist/test/liveness-watchdog.test.js +78 -0
- package/dist/test/liveness-watchdog.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
- package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +155 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -0
- package/dist/test/prose-segments.test.d.ts +2 -0
- package/dist/test/prose-segments.test.d.ts.map +1 -0
- package/dist/test/prose-segments.test.js +52 -0
- package/dist/test/prose-segments.test.js.map +1 -0
- package/dist/test/tool-result-history.test.d.ts +7 -0
- package/dist/test/tool-result-history.test.d.ts.map +1 -0
- package/dist/test/tool-result-history.test.js +77 -0
- package/dist/test/tool-result-history.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.d.ts +19 -0
- package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +175 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +53 -18
- package/src/framework.ts +1685 -71
- package/src/gate/event-gate.ts +297 -9
- package/src/gate/gate-script-worker.ts +73 -0
- package/src/gate/gate-script.ts +180 -0
- package/src/gate/types.ts +36 -3
- package/src/index.ts +12 -0
- package/src/mcpl/channel-registry.ts +331 -6
- package/src/mcpl/checkpoint-manager.ts +16 -0
- package/src/mcpl/conversation-router.ts +284 -0
- package/src/mcpl/feature-set-manager.ts +14 -1
- package/src/mcpl/index.ts +12 -0
- package/src/mcpl/push-handler.ts +1 -0
- package/src/mcpl/server-connection.ts +157 -224
- package/src/mcpl/transport.ts +307 -0
- package/src/mcpl/types.ts +49 -2
- package/src/module-registry.ts +25 -2
- package/src/modules/discord/index.ts +93 -0
- package/src/prose-segments.ts +42 -0
- package/src/runtime/liveness-watchdog-worker.ts +66 -0
- package/src/runtime/liveness-watchdog.ts +122 -0
- package/src/tool-result-history.ts +57 -0
- package/src/types/agent.ts +21 -0
- package/src/types/framework.ts +24 -0
- package/src/types/module.ts +29 -0
- package/src/types/trace.ts +36 -0
package/src/gate/event-gate.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
15
|
-
import { dirname } from 'node:path';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { GateScript } from './gate-script.js';
|
|
16
17
|
|
|
17
18
|
import type { ToolDefinition } from '../types/events.js';
|
|
18
19
|
import type {
|
|
@@ -89,6 +90,9 @@ interface CompiledPolicy {
|
|
|
89
90
|
channelRegex?: RegExp;
|
|
90
91
|
mountRegex?: RegExp;
|
|
91
92
|
pathRegex?: RegExp;
|
|
93
|
+
tagsAnyRe?: RegExp[];
|
|
94
|
+
tagsAllRe?: RegExp[];
|
|
95
|
+
tagsNoneRe?: RegExp[];
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
interface PolicyStats {
|
|
@@ -170,8 +174,8 @@ function validateConfig(raw: unknown): GateConfig {
|
|
|
170
174
|
const obj = raw as Record<string, unknown>;
|
|
171
175
|
|
|
172
176
|
const defaultBehavior = obj.default ?? 'always';
|
|
173
|
-
if (defaultBehavior !== 'always' && defaultBehavior !== 'skip') {
|
|
174
|
-
throw new Error(`gate.json "default" must be "always" or "skip", got: ${defaultBehavior}`);
|
|
177
|
+
if (defaultBehavior !== 'always' && defaultBehavior !== 'defer' && defaultBehavior !== 'skip') {
|
|
178
|
+
throw new Error(`gate.json "default" must be "always", "defer", or "skip", got: ${defaultBehavior}`);
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
const policies: GatePolicy[] = [];
|
|
@@ -197,7 +201,7 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
197
201
|
|
|
198
202
|
// Validate behavior
|
|
199
203
|
let behavior: GateBehavior;
|
|
200
|
-
if (obj.behavior === 'always' || obj.behavior === 'skip') {
|
|
204
|
+
if (obj.behavior === 'always' || obj.behavior === 'defer' || obj.behavior === 'skip') {
|
|
201
205
|
behavior = obj.behavior;
|
|
202
206
|
} else if (obj.behavior && typeof obj.behavior === 'object') {
|
|
203
207
|
const b = obj.behavior as Record<string, unknown>;
|
|
@@ -247,7 +251,7 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
247
251
|
}
|
|
248
252
|
behavior = { passive_sample: out };
|
|
249
253
|
} else {
|
|
250
|
-
throw new Error(`Policy "${obj.name}": unknown behavior — expected always | skip | { debounce } | { rate_limit } | { passive_sample }`);
|
|
254
|
+
throw new Error(`Policy "${obj.name}": unknown behavior — expected always | defer | skip | { debounce } | { rate_limit } | { passive_sample }`);
|
|
251
255
|
}
|
|
252
256
|
} else {
|
|
253
257
|
behavior = 'always';
|
|
@@ -266,6 +270,14 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
266
270
|
const fields = m.metadataTrue.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
|
267
271
|
if (fields.length > 0) match.metadataTrue = fields;
|
|
268
272
|
}
|
|
273
|
+
const tagList = (v: unknown): string[] | undefined => {
|
|
274
|
+
if (!Array.isArray(v)) return undefined;
|
|
275
|
+
const out = v.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
|
276
|
+
return out.length > 0 ? out : undefined;
|
|
277
|
+
};
|
|
278
|
+
const tagsAny = tagList(m.tagsAny); if (tagsAny) match.tagsAny = tagsAny;
|
|
279
|
+
const tagsAll = tagList(m.tagsAll); if (tagsAll) match.tagsAll = tagsAll;
|
|
280
|
+
const tagsNone = tagList(m.tagsNone); if (tagsNone) match.tagsNone = tagsNone;
|
|
269
281
|
if (m.filter && typeof m.filter === 'object') {
|
|
270
282
|
const f = m.filter as Record<string, unknown>;
|
|
271
283
|
if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
|
|
@@ -299,6 +311,8 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
299
311
|
export class EventGate {
|
|
300
312
|
private configPath: string;
|
|
301
313
|
private config: GateConfig;
|
|
314
|
+
/** Optional agent-authored programmable gate (gate.js). */
|
|
315
|
+
private gateScript: GateScript;
|
|
302
316
|
private compiledPolicies: CompiledPolicy[] = [];
|
|
303
317
|
private configMtime: number = 0;
|
|
304
318
|
private lastReloadCheck: number = 0;
|
|
@@ -330,6 +344,20 @@ export class EventGate {
|
|
|
330
344
|
private defaultSkipped = 0;
|
|
331
345
|
private defaultByEventType = new Map<string, { triggered: number; skipped: number }>();
|
|
332
346
|
|
|
347
|
+
// Sleep state (set via the `sleep` tool). Passive: evaluate() suppresses
|
|
348
|
+
// non-privileged wakes while now() < sleepUntil. No timer — after expiry the
|
|
349
|
+
// next event evaluates normally.
|
|
350
|
+
private sleepUntil = 0;
|
|
351
|
+
private sleepNote: string | undefined;
|
|
352
|
+
private sleepSuppressed = 0;
|
|
353
|
+
|
|
354
|
+
// Privileged users who may wake the agent through sleep. Hot-reloaded from
|
|
355
|
+
// privilegedUsersPath on change.
|
|
356
|
+
private privilegedUsersPath: string | undefined;
|
|
357
|
+
private privilegedUserIds = new Set<string>();
|
|
358
|
+
private privilegedMtime = 0;
|
|
359
|
+
private lastPrivilegedCheck = 0;
|
|
360
|
+
|
|
333
361
|
// Dependency-injected callbacks
|
|
334
362
|
private emitTrace: (event: TraceEventLike) => void;
|
|
335
363
|
private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
|
|
@@ -342,19 +370,24 @@ export class EventGate {
|
|
|
342
370
|
constructor(opts: {
|
|
343
371
|
configPath: string;
|
|
344
372
|
initialConfig?: GateConfig;
|
|
373
|
+
privilegedUsersPath?: string;
|
|
345
374
|
emitTrace: (event: TraceEventLike) => void;
|
|
346
375
|
addMessage: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
|
|
347
376
|
requestInference: (agentName: string, reason: string, source: string) => void;
|
|
348
377
|
getAgentNames: () => string[];
|
|
349
378
|
/** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
|
|
350
379
|
now?: () => number;
|
|
380
|
+
/** Per-event timeout (ms) for the optional gate.js script. Default 50. */
|
|
381
|
+
scriptTimeoutMs?: number;
|
|
351
382
|
}) {
|
|
352
383
|
this.configPath = opts.configPath;
|
|
384
|
+
this.privilegedUsersPath = opts.privilegedUsersPath;
|
|
353
385
|
this.emitTrace = opts.emitTrace;
|
|
354
386
|
this.addMessageFn = opts.addMessage;
|
|
355
387
|
this.requestInferenceFn = opts.requestInference;
|
|
356
388
|
this.getAgentNamesFn = opts.getAgentNames;
|
|
357
389
|
this.now = opts.now ?? (() => Date.now());
|
|
390
|
+
this.loadPrivileged();
|
|
358
391
|
|
|
359
392
|
// Seed or reconcile the on-disk config.
|
|
360
393
|
//
|
|
@@ -400,6 +433,14 @@ export class EventGate {
|
|
|
400
433
|
// Load config
|
|
401
434
|
this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG };
|
|
402
435
|
this.compiledPolicies = this.compilePolicies(this.config);
|
|
436
|
+
|
|
437
|
+
// Programmable gate: an optional agent-authored `gate.js` next to gate.json.
|
|
438
|
+
// Absent ⇒ inactive (zero overhead beyond a throttled stat check).
|
|
439
|
+
this.gateScript = new GateScript(
|
|
440
|
+
join(dirname(this.configPath), 'gate.js'),
|
|
441
|
+
opts.scriptTimeoutMs ?? 50,
|
|
442
|
+
this.now,
|
|
443
|
+
);
|
|
403
444
|
}
|
|
404
445
|
|
|
405
446
|
// =========================================================================
|
|
@@ -449,6 +490,9 @@ export class EventGate {
|
|
|
449
490
|
if (policy.match.pathGlob) {
|
|
450
491
|
compiled.pathRegex = compileGlob(policy.match.pathGlob);
|
|
451
492
|
}
|
|
493
|
+
if (policy.match.tagsAny) compiled.tagsAnyRe = policy.match.tagsAny.map(compileGlob);
|
|
494
|
+
if (policy.match.tagsAll) compiled.tagsAllRe = policy.match.tagsAll.map(compileGlob);
|
|
495
|
+
if (policy.match.tagsNone) compiled.tagsNoneRe = policy.match.tagsNone.map(compileGlob);
|
|
452
496
|
return compiled;
|
|
453
497
|
});
|
|
454
498
|
}
|
|
@@ -517,6 +561,105 @@ export class EventGate {
|
|
|
517
561
|
}
|
|
518
562
|
}
|
|
519
563
|
|
|
564
|
+
// =========================================================================
|
|
565
|
+
// Runtime policy mutation (backs wake_add_rule / wake_remove_rule)
|
|
566
|
+
// =========================================================================
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Add or replace (upsert) a single policy in gate.json and apply it live.
|
|
570
|
+
*
|
|
571
|
+
* The raw policy is validated with the SAME `validatePolicy` used when loading
|
|
572
|
+
* gate.json — an invalid shape throws (nothing is written) and the caller
|
|
573
|
+
* surfaces it as a tool error. On success the policy is persisted to gate.json
|
|
574
|
+
* (so it survives restart and is visible to `workspace--edit _config/gate.json`)
|
|
575
|
+
* and the in-memory config is swapped in immediately, so the rule takes effect
|
|
576
|
+
* without waiting for the throttled mtime reload.
|
|
577
|
+
*
|
|
578
|
+
* The freshest on-disk config is re-read first, so a concurrent operator edit
|
|
579
|
+
* isn't clobbered. A policy with the same `name` is replaced IN PLACE (order
|
|
580
|
+
* preserved); otherwise it is appended, or prepended when `position:'prepend'`
|
|
581
|
+
* — first match wins, so a wake rule that must beat a broad defer/debounce
|
|
582
|
+
* belongs at the front.
|
|
583
|
+
*/
|
|
584
|
+
addPolicy(rawPolicy: unknown, options?: { position?: 'append' | 'prepend' }): GatePolicy {
|
|
585
|
+
const policy = validatePolicy(rawPolicy);
|
|
586
|
+
const current = this.loadConfig() ?? this.config;
|
|
587
|
+
const policies = [...current.policies];
|
|
588
|
+
const idx = policies.findIndex((p) => p.name === policy.name);
|
|
589
|
+
if (idx >= 0) {
|
|
590
|
+
policies[idx] = policy;
|
|
591
|
+
} else if (options?.position === 'prepend') {
|
|
592
|
+
policies.unshift(policy);
|
|
593
|
+
} else {
|
|
594
|
+
policies.push(policy);
|
|
595
|
+
}
|
|
596
|
+
this.writeConfig({ ...current, policies });
|
|
597
|
+
this.emitTrace({
|
|
598
|
+
type: 'gate:policy-added',
|
|
599
|
+
configPath: this.configPath,
|
|
600
|
+
policyName: policy.name,
|
|
601
|
+
replaced: idx >= 0,
|
|
602
|
+
timestamp: Date.now(),
|
|
603
|
+
});
|
|
604
|
+
return policy;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Remove a policy by name from gate.json and clear its live state. Any pending
|
|
609
|
+
* debounce batch is delivered first (matching reloadIfChanged's removal path,
|
|
610
|
+
* so buffered events aren't silently dropped). Returns false if no policy of
|
|
611
|
+
* that name exists (nothing written). Freshest on-disk config is re-read first
|
|
612
|
+
* (see addPolicy).
|
|
613
|
+
*/
|
|
614
|
+
removePolicy(name: string): boolean {
|
|
615
|
+
const current = this.loadConfig() ?? this.config;
|
|
616
|
+
if (!current.policies.some((p) => p.name === name)) {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
// Deliver any pending debounce batch, then drop timers/buckets/counters.
|
|
620
|
+
this.clearPolicyState(name, { deliverPendingDebounce: true });
|
|
621
|
+
this.stats.delete(name);
|
|
622
|
+
const policies = current.policies.filter((p) => p.name !== name);
|
|
623
|
+
this.writeConfig({ ...current, policies });
|
|
624
|
+
this.emitTrace({
|
|
625
|
+
type: 'gate:policy-removed',
|
|
626
|
+
configPath: this.configPath,
|
|
627
|
+
policyName: name,
|
|
628
|
+
timestamp: Date.now(),
|
|
629
|
+
});
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* List the current policy names (freshest on-disk view). Cheap helper for
|
|
635
|
+
* tools/UX that want to show what's active without the full status payload.
|
|
636
|
+
*/
|
|
637
|
+
listPolicyNames(): string[] {
|
|
638
|
+
const current = this.loadConfig() ?? this.config;
|
|
639
|
+
return current.policies.map((p) => p.name);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Persist a config to gate.json and apply it in memory together: write the
|
|
644
|
+
* file, swap in the compiled policies, and sync `configMtime` to the new file
|
|
645
|
+
* so `reloadIfChanged` doesn't redundantly re-parse identical content. Throws
|
|
646
|
+
* if the write fails (disk/permission) — callers surface it as a tool error.
|
|
647
|
+
*/
|
|
648
|
+
private writeConfig(config: GateConfig): void {
|
|
649
|
+
mkdirSync(dirname(this.configPath), { recursive: true });
|
|
650
|
+
writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
|
|
651
|
+
this.config = config;
|
|
652
|
+
this.compiledPolicies = this.compilePolicies(config);
|
|
653
|
+
this.configSource = 'file';
|
|
654
|
+
this.configErrors = [];
|
|
655
|
+
this.lastReloadTimestamp = Date.now();
|
|
656
|
+
try {
|
|
657
|
+
this.configMtime = statSync(this.configPath).mtimeMs;
|
|
658
|
+
} catch {
|
|
659
|
+
// A failed stat only risks one redundant reload; keep the old mtime.
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
520
663
|
// =========================================================================
|
|
521
664
|
// Policy matching — first match wins
|
|
522
665
|
// =========================================================================
|
|
@@ -587,6 +730,16 @@ export class EventGate {
|
|
|
587
730
|
if (!anyTrue) return false;
|
|
588
731
|
}
|
|
589
732
|
|
|
733
|
+
// Tag matching (MCPL RFC-001). Patterns are globs; `tags` is the event's
|
|
734
|
+
// (implication-expanded) tag set. A pattern "matches" if it matches ANY tag.
|
|
735
|
+
if (compiled.tagsAnyRe || compiled.tagsAllRe || compiled.tagsNoneRe) {
|
|
736
|
+
const tags = info.tags ?? [];
|
|
737
|
+
const patternHits = (re: RegExp): boolean => tags.some(t => re.test(t));
|
|
738
|
+
if (compiled.tagsAnyRe && !compiled.tagsAnyRe.some(patternHits)) return false;
|
|
739
|
+
if (compiled.tagsAllRe && !compiled.tagsAllRe.every(patternHits)) return false;
|
|
740
|
+
if (compiled.tagsNoneRe && compiled.tagsNoneRe.some(patternHits)) return false;
|
|
741
|
+
}
|
|
742
|
+
|
|
590
743
|
return true;
|
|
591
744
|
}
|
|
592
745
|
|
|
@@ -597,7 +750,41 @@ export class EventGate {
|
|
|
597
750
|
evaluate(info: GateEventInfo): GateDecision {
|
|
598
751
|
this.reloadIfChanged();
|
|
599
752
|
|
|
600
|
-
|
|
753
|
+
// Sleep suppression: while asleep, drop EXTERNAL wakes. Two things still
|
|
754
|
+
// rouse the agent — its own heartbeat (an internal clock, not an outside
|
|
755
|
+
// interruption: each tick lets the agent reconsider and optionally `wake`)
|
|
756
|
+
// and a privileged user's ping. The event always enters context regardless
|
|
757
|
+
// (the gate only governs inference triggering).
|
|
758
|
+
if (this.sleepUntil > this.now()) {
|
|
759
|
+
this.reloadPrivilegedIfChanged();
|
|
760
|
+
const isHeartbeat =
|
|
761
|
+
info.metadata?.source === 'heartbeat' || info.serverId === 'heartbeat';
|
|
762
|
+
const authorId = this.extractAuthorId(info.metadata);
|
|
763
|
+
const privileged = authorId !== null && this.privilegedUserIds.has(authorId);
|
|
764
|
+
if (!isHeartbeat && !privileged) {
|
|
765
|
+
this.sleepSuppressed++;
|
|
766
|
+
this.totalEvaluations++;
|
|
767
|
+
this.emitTrace({
|
|
768
|
+
type: 'gate:decision',
|
|
769
|
+
eventType: info.eventType,
|
|
770
|
+
serverId: info.serverId || undefined,
|
|
771
|
+
channelId: info.channelId || undefined,
|
|
772
|
+
matchedPolicy: 'asleep',
|
|
773
|
+
trigger: false,
|
|
774
|
+
behavior: 'skip',
|
|
775
|
+
timestamp: this.now(),
|
|
776
|
+
});
|
|
777
|
+
return { trigger: false, policyName: 'asleep', behavior: 'skip' };
|
|
778
|
+
}
|
|
779
|
+
// Privileged author — fall through to normal policy evaluation.
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Programmable gate (gate.js) takes precedence when present and returns a
|
|
783
|
+
// behavior; null/undefined (inactive, error, or a deliberate pass) falls
|
|
784
|
+
// through to the declarative gate.json policies below.
|
|
785
|
+
const scripted = this.gateScript.evaluate(info);
|
|
786
|
+
const policy: GatePolicy | null =
|
|
787
|
+
scripted != null ? { name: 'gate.js', match: {}, behavior: scripted } : this.matchPolicy(info);
|
|
601
788
|
const decision = this.computeDecision(policy, info);
|
|
602
789
|
|
|
603
790
|
this.totalEvaluations++;
|
|
@@ -650,8 +837,9 @@ export class EventGate {
|
|
|
650
837
|
timestamp: this.now(),
|
|
651
838
|
});
|
|
652
839
|
|
|
653
|
-
if (policy.behavior === 'skip') {
|
|
654
|
-
|
|
840
|
+
if (policy.behavior === 'defer' || policy.behavior === 'skip') {
|
|
841
|
+
// Report the configured name faithfully ('defer' or legacy 'skip').
|
|
842
|
+
return { trigger: false, policyName: policy.name, behavior: policy.behavior };
|
|
655
843
|
}
|
|
656
844
|
|
|
657
845
|
if (policy.behavior === 'always') {
|
|
@@ -808,6 +996,7 @@ export class EventGate {
|
|
|
808
996
|
serverId: (metadata.serverId as string) ?? '',
|
|
809
997
|
channelId: (metadata.channelId as string) ?? '',
|
|
810
998
|
metadata,
|
|
999
|
+
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : undefined,
|
|
811
1000
|
});
|
|
812
1001
|
return decision.trigger;
|
|
813
1002
|
};
|
|
@@ -927,9 +1116,93 @@ export class EventGate {
|
|
|
927
1116
|
// Tool: gate:status
|
|
928
1117
|
// =========================================================================
|
|
929
1118
|
|
|
1119
|
+
// =========================================================================
|
|
1120
|
+
// Sleep
|
|
1121
|
+
// =========================================================================
|
|
1122
|
+
|
|
1123
|
+
/** Begin (or extend) a sleep window of `seconds`. Returns the wake time. */
|
|
1124
|
+
setSleep(seconds: number, note?: string): { until: number } {
|
|
1125
|
+
const ms = Math.max(0, Math.floor(seconds * 1000));
|
|
1126
|
+
this.sleepUntil = this.now() + ms;
|
|
1127
|
+
this.sleepNote = note;
|
|
1128
|
+
this.sleepSuppressed = 0;
|
|
1129
|
+
this.reloadPrivilegedIfChanged();
|
|
1130
|
+
this.emitTrace({
|
|
1131
|
+
type: 'gate:decision',
|
|
1132
|
+
eventType: 'sleep:set',
|
|
1133
|
+
matchedPolicy: 'asleep',
|
|
1134
|
+
trigger: false,
|
|
1135
|
+
behavior: 'skip',
|
|
1136
|
+
timestamp: this.now(),
|
|
1137
|
+
});
|
|
1138
|
+
return { until: this.sleepUntil };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** End sleep immediately. Returns true if the agent was asleep. */
|
|
1142
|
+
clearSleep(): boolean {
|
|
1143
|
+
const wasAsleep = this.sleepUntil > this.now();
|
|
1144
|
+
this.sleepUntil = 0;
|
|
1145
|
+
this.sleepNote = undefined;
|
|
1146
|
+
return wasAsleep;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Current sleep state, or null if awake. */
|
|
1150
|
+
getSleepState(): { until: number; remainingMs: number; note?: string; suppressed: number } | null {
|
|
1151
|
+
const remainingMs = this.sleepUntil - this.now();
|
|
1152
|
+
if (remainingMs <= 0) return null;
|
|
1153
|
+
return { until: this.sleepUntil, remainingMs, note: this.sleepNote, suppressed: this.sleepSuppressed };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Author id from an event's metadata, across the discord ingest shapes. */
|
|
1157
|
+
private extractAuthorId(metadata?: Record<string, unknown>): string | null {
|
|
1158
|
+
if (!metadata) return null;
|
|
1159
|
+
const author = metadata.author as { id?: unknown } | undefined;
|
|
1160
|
+
if (author && author.id != null) return String(author.id);
|
|
1161
|
+
if (metadata.authorId != null) return String(metadata.authorId);
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** Load the privileged-users file (bare array or { userIds: [...] }). */
|
|
1166
|
+
private loadPrivileged(): void {
|
|
1167
|
+
if (!this.privilegedUsersPath) return;
|
|
1168
|
+
try {
|
|
1169
|
+
if (!existsSync(this.privilegedUsersPath)) {
|
|
1170
|
+
this.privilegedUserIds = new Set();
|
|
1171
|
+
this.privilegedMtime = 0;
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs;
|
|
1175
|
+
const raw = JSON.parse(readFileSync(this.privilegedUsersPath, 'utf8'));
|
|
1176
|
+
const ids = Array.isArray(raw) ? raw : Array.isArray(raw?.userIds) ? raw.userIds : [];
|
|
1177
|
+
this.privilegedUserIds = new Set(ids.map((x: unknown) => String(x)));
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
this.configErrors.push(
|
|
1180
|
+
`Failed to load privileged-users file: ${err instanceof Error ? err.message : String(err)}`,
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Throttled mtime-based reload of the privileged-users file. */
|
|
1186
|
+
private reloadPrivilegedIfChanged(): void {
|
|
1187
|
+
if (!this.privilegedUsersPath) return;
|
|
1188
|
+
const now = this.now();
|
|
1189
|
+
if (now - this.lastPrivilegedCheck < RELOAD_THROTTLE_MS) return;
|
|
1190
|
+
this.lastPrivilegedCheck = now;
|
|
1191
|
+
try {
|
|
1192
|
+
if (!existsSync(this.privilegedUsersPath)) {
|
|
1193
|
+
if (this.privilegedUserIds.size > 0) this.privilegedUserIds = new Set();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const mtime = statSync(this.privilegedUsersPath).mtimeMs;
|
|
1197
|
+
if (mtime !== this.privilegedMtime) this.loadPrivileged();
|
|
1198
|
+
} catch {
|
|
1199
|
+
/* leave the last-known set in place on a transient stat/read error */
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
930
1203
|
getToolDefinition(): ToolDefinition {
|
|
931
1204
|
return {
|
|
932
|
-
name: '
|
|
1205
|
+
name: 'gate_status',
|
|
933
1206
|
description: 'Show the current EventGate configuration, per-policy match counts, debounce state, and any config errors.',
|
|
934
1207
|
inputSchema: {
|
|
935
1208
|
type: 'object',
|
|
@@ -976,11 +1249,25 @@ export class EventGate {
|
|
|
976
1249
|
const byEventType: Record<string, { triggered: number; skipped: number }> = {};
|
|
977
1250
|
for (const [k, v] of this.defaultByEventType) byEventType[k] = { ...v };
|
|
978
1251
|
|
|
1252
|
+
const sleepState = this.getSleepState();
|
|
1253
|
+
|
|
979
1254
|
return {
|
|
1255
|
+
...(sleepState
|
|
1256
|
+
? {
|
|
1257
|
+
sleep: {
|
|
1258
|
+
until: sleepState.until,
|
|
1259
|
+
remainingMs: sleepState.remainingMs,
|
|
1260
|
+
note: sleepState.note,
|
|
1261
|
+
suppressed: sleepState.suppressed,
|
|
1262
|
+
privilegedCount: this.privilegedUserIds.size,
|
|
1263
|
+
},
|
|
1264
|
+
}
|
|
1265
|
+
: {}),
|
|
980
1266
|
configPath: this.configPath,
|
|
981
1267
|
configSource: this.configSource,
|
|
982
1268
|
lastReloadTimestamp: this.lastReloadTimestamp,
|
|
983
1269
|
default: this.config.default ?? 'always',
|
|
1270
|
+
script: this.gateScript.status(),
|
|
984
1271
|
policies,
|
|
985
1272
|
errors: [...this.configErrors],
|
|
986
1273
|
totalEvaluations: this.totalEvaluations,
|
|
@@ -997,6 +1284,7 @@ export class EventGate {
|
|
|
997
1284
|
// =========================================================================
|
|
998
1285
|
|
|
999
1286
|
dispose(): void {
|
|
1287
|
+
this.gateScript.dispose();
|
|
1000
1288
|
for (const state of this.debounceTimers.values()) {
|
|
1001
1289
|
clearTimeout(state.timer);
|
|
1002
1290
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker that runs the agent-authored `gate.js` decision function.
|
|
3
|
+
*
|
|
4
|
+
* The agent is trusted (it already has shell + filesystem), so this is NOT a
|
|
5
|
+
* security sandbox — it's a *robustness* boundary: running the script on a
|
|
6
|
+
* separate thread lets the main thread time it out (Atomics.wait) so an
|
|
7
|
+
* accidental infinite loop degrades to "fall through to declarative policies"
|
|
8
|
+
* instead of freezing the agent's own event loop.
|
|
9
|
+
*
|
|
10
|
+
* Protocol:
|
|
11
|
+
* - on startup we import `gate.js` and post `{ type: 'ready', error? }`.
|
|
12
|
+
* - per event the parent posts `{ type: 'eval', event }`; we run the script
|
|
13
|
+
* and write the JSON result into the shared buffer, flip the control word,
|
|
14
|
+
* and notify. The parent is blocked in Atomics.wait until then (or times out).
|
|
15
|
+
*
|
|
16
|
+
* `gate.js` shape (ESM): `export default (event) => behavior | null` where
|
|
17
|
+
* behavior is a GateBehavior ('always' | 'defer' | { debounce } | …) and null
|
|
18
|
+
* means "let the declarative gate.json policies decide". Sync or async.
|
|
19
|
+
*/
|
|
20
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
|
|
23
|
+
interface WorkerData {
|
|
24
|
+
scriptPath: string;
|
|
25
|
+
sab: SharedArrayBuffer;
|
|
26
|
+
}
|
|
27
|
+
const { scriptPath, sab } = workerData as WorkerData;
|
|
28
|
+
const ctrl = new Int32Array(sab, 0, 2); // [0]=status (0 pending, 1 done), [1]=result byte length
|
|
29
|
+
const data = new Uint8Array(sab, 8);
|
|
30
|
+
const enc = new TextEncoder();
|
|
31
|
+
|
|
32
|
+
type DecideFn = (event: unknown) => unknown;
|
|
33
|
+
let fn: DecideFn | null = null;
|
|
34
|
+
|
|
35
|
+
// Load eagerly and announce readiness, so the parent doesn't time out (and
|
|
36
|
+
// pointlessly respawn) while a cold dynamic import is still in flight.
|
|
37
|
+
void (async () => {
|
|
38
|
+
let error: string | null = null;
|
|
39
|
+
try {
|
|
40
|
+
const mod = (await import(pathToFileURL(scriptPath).href)) as Record<string, unknown>;
|
|
41
|
+
const f = (mod.default ?? mod.decide ?? mod.gate) as unknown;
|
|
42
|
+
if (typeof f !== 'function') {
|
|
43
|
+
error = 'gate.js must export a default function: (event) => behavior | null';
|
|
44
|
+
} else {
|
|
45
|
+
fn = f as DecideFn;
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
error = (e as Error)?.message ?? String(e);
|
|
49
|
+
}
|
|
50
|
+
parentPort?.postMessage({ type: 'ready', error });
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
function writeResult(obj: unknown): void {
|
|
54
|
+
const bytes = enc.encode(JSON.stringify(obj));
|
|
55
|
+
const n = Math.min(bytes.length, data.length);
|
|
56
|
+
data.set(bytes.subarray(0, n));
|
|
57
|
+
Atomics.store(ctrl, 1, n);
|
|
58
|
+
Atomics.store(ctrl, 0, 1);
|
|
59
|
+
Atomics.notify(ctrl, 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
parentPort?.on('message', (msg: { type?: string; event?: unknown }) => {
|
|
63
|
+
if (msg?.type !== 'eval') return;
|
|
64
|
+
void (async () => {
|
|
65
|
+
if (!fn) return writeResult({ behavior: null }); // load failed → fall through
|
|
66
|
+
try {
|
|
67
|
+
const r = await fn(msg.event); // sync OR async decision functions
|
|
68
|
+
writeResult({ behavior: r ?? null });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
writeResult({ error: (e as Error)?.message ?? String(e) });
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
});
|