@animalabs/agent-framework 0.4.0 → 0.5.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/gate/event-gate.d.ts +27 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +286 -22
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +65 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/test/event-gate-rate-limit.test.d.ts +2 -0
- package/dist/test/event-gate-rate-limit.test.d.ts.map +1 -0
- package/dist/test/event-gate-rate-limit.test.js +627 -0
- package/dist/test/event-gate-rate-limit.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/gate/event-gate.ts +309 -22
- package/src/gate/types.ts +66 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animalabs/agent-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/src/index.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@animalabs/chronicle": "^0.1.0",
|
|
42
|
-
"@animalabs/context-manager": "^0.
|
|
42
|
+
"@animalabs/context-manager": "^0.3.0",
|
|
43
43
|
"@animalabs/membrane": "^0.5.43",
|
|
44
44
|
"chokidar": "^4.0.3",
|
|
45
45
|
"discord.js": "^14.25.1",
|
package/src/gate/event-gate.ts
CHANGED
|
@@ -71,6 +71,17 @@ interface DebounceState {
|
|
|
71
71
|
events: PendingEvent[];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Token bucket for one (policy, key) pair under a rate_limit behavior. */
|
|
75
|
+
interface RateBucket {
|
|
76
|
+
/** Tokens currently available. */
|
|
77
|
+
tokens: number;
|
|
78
|
+
/** Last epoch-ms timestamp the bucket was refilled. */
|
|
79
|
+
lastRefill: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Synthetic key used when a policy has no `keyBy` or the field is missing. */
|
|
83
|
+
const SHARED_BUCKET_KEY = '__shared__';
|
|
84
|
+
|
|
74
85
|
interface CompiledPolicy {
|
|
75
86
|
policy: GatePolicy;
|
|
76
87
|
filterRegex?: RegExp;
|
|
@@ -107,6 +118,46 @@ function compileGlob(pattern: string): RegExp {
|
|
|
107
118
|
return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$');
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Truthiness for `metadataTrue` matching. Stricter than JS `Boolean(x)`:
|
|
123
|
+
* empty strings, empty arrays, and empty objects all count as false, so
|
|
124
|
+
* `metadataTrue: ["mentionIds"]` doesn't match every event that happens
|
|
125
|
+
* to carry an empty `mentionIds: []` array.
|
|
126
|
+
*/
|
|
127
|
+
function isMetadataTruthy(value: unknown): boolean {
|
|
128
|
+
if (value === undefined || value === null) return false;
|
|
129
|
+
if (typeof value === 'string') return value.length > 0;
|
|
130
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
131
|
+
if (typeof value === 'object') return Object.keys(value).length > 0;
|
|
132
|
+
return Boolean(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Compact, log-friendly serialization of a GateBehavior. */
|
|
136
|
+
function formatBehavior(b: import('./types.js').GateBehavior): string {
|
|
137
|
+
if (typeof b === 'string') return b;
|
|
138
|
+
if ('debounce' in b) return `debounce:${b.debounce}`;
|
|
139
|
+
if ('rate_limit' in b) {
|
|
140
|
+
const k = b.rate_limit.keyBy ? `,keyBy:${b.rate_limit.keyBy}` : '';
|
|
141
|
+
return `rate_limit:${b.rate_limit.tokens}/${b.rate_limit.refillIntervalMs}ms${k}`;
|
|
142
|
+
}
|
|
143
|
+
if ('passive_sample' in b) {
|
|
144
|
+
const k = b.passive_sample.keyBy ? `,keyBy:${b.passive_sample.keyBy}` : '';
|
|
145
|
+
return `passive_sample:${b.passive_sample.every}${k}`;
|
|
146
|
+
}
|
|
147
|
+
return 'unknown';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stable fingerprint for a GateBehavior, used during reload to detect
|
|
152
|
+
* config changes that invalidate per-policy state. JSON.stringify is
|
|
153
|
+
* deterministic for our small, flat behavior shapes — keys appear in
|
|
154
|
+
* declaration order — so this is enough to spot any change in tokens,
|
|
155
|
+
* refillIntervalMs, keyBy, every, or debounce duration.
|
|
156
|
+
*/
|
|
157
|
+
function behaviorFingerprint(b: import('./types.js').GateBehavior): string {
|
|
158
|
+
return typeof b === 'string' ? b : JSON.stringify(b);
|
|
159
|
+
}
|
|
160
|
+
|
|
110
161
|
// ---------------------------------------------------------------------------
|
|
111
162
|
// Config validation
|
|
112
163
|
// ---------------------------------------------------------------------------
|
|
@@ -150,7 +201,10 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
150
201
|
behavior = obj.behavior;
|
|
151
202
|
} else if (obj.behavior && typeof obj.behavior === 'object') {
|
|
152
203
|
const b = obj.behavior as Record<string, unknown>;
|
|
153
|
-
if (
|
|
204
|
+
if ('debounce' in b) {
|
|
205
|
+
if (typeof b.debounce !== 'number' || b.debounce <= 0) {
|
|
206
|
+
throw new Error(`Policy "${obj.name}": debounce must be a positive number`);
|
|
207
|
+
}
|
|
154
208
|
if (b.debounce < MIN_DEBOUNCE_MS) {
|
|
155
209
|
throw new Error(`Policy "${obj.name}": debounce must be >= ${MIN_DEBOUNCE_MS}ms, got ${b.debounce}`);
|
|
156
210
|
}
|
|
@@ -158,8 +212,42 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
158
212
|
throw new Error(`Policy "${obj.name}": debounce must be <= ${MAX_DEBOUNCE_MS}ms, got ${b.debounce}`);
|
|
159
213
|
}
|
|
160
214
|
behavior = { debounce: b.debounce };
|
|
215
|
+
} else if ('rate_limit' in b) {
|
|
216
|
+
const rl = b.rate_limit;
|
|
217
|
+
if (!rl || typeof rl !== 'object') {
|
|
218
|
+
throw new Error(`Policy "${obj.name}": rate_limit must be an object`);
|
|
219
|
+
}
|
|
220
|
+
const conf = rl as Record<string, unknown>;
|
|
221
|
+
if (typeof conf.tokens !== 'number' || !Number.isFinite(conf.tokens) || conf.tokens <= 0) {
|
|
222
|
+
throw new Error(`Policy "${obj.name}": rate_limit.tokens must be a positive number`);
|
|
223
|
+
}
|
|
224
|
+
if (typeof conf.refillIntervalMs !== 'number' || !Number.isFinite(conf.refillIntervalMs) || conf.refillIntervalMs <= 0) {
|
|
225
|
+
throw new Error(`Policy "${obj.name}": rate_limit.refillIntervalMs must be a positive number`);
|
|
226
|
+
}
|
|
227
|
+
const out: { tokens: number; refillIntervalMs: number; keyBy?: string } = {
|
|
228
|
+
tokens: conf.tokens,
|
|
229
|
+
refillIntervalMs: conf.refillIntervalMs,
|
|
230
|
+
};
|
|
231
|
+
if (typeof conf.keyBy === 'string' && conf.keyBy.length > 0) {
|
|
232
|
+
out.keyBy = conf.keyBy;
|
|
233
|
+
}
|
|
234
|
+
behavior = { rate_limit: out };
|
|
235
|
+
} else if ('passive_sample' in b) {
|
|
236
|
+
const ps = b.passive_sample;
|
|
237
|
+
if (!ps || typeof ps !== 'object') {
|
|
238
|
+
throw new Error(`Policy "${obj.name}": passive_sample must be an object`);
|
|
239
|
+
}
|
|
240
|
+
const conf = ps as Record<string, unknown>;
|
|
241
|
+
if (typeof conf.every !== 'number' || !Number.isInteger(conf.every) || conf.every <= 0) {
|
|
242
|
+
throw new Error(`Policy "${obj.name}": passive_sample.every must be a positive integer`);
|
|
243
|
+
}
|
|
244
|
+
const out: { every: number; keyBy?: string } = { every: conf.every };
|
|
245
|
+
if (typeof conf.keyBy === 'string' && conf.keyBy.length > 0) {
|
|
246
|
+
out.keyBy = conf.keyBy;
|
|
247
|
+
}
|
|
248
|
+
behavior = { passive_sample: out };
|
|
161
249
|
} else {
|
|
162
|
-
throw new Error(`Policy "${obj.name}": debounce
|
|
250
|
+
throw new Error(`Policy "${obj.name}": unknown behavior — expected always | skip | { debounce } | { rate_limit } | { passive_sample }`);
|
|
163
251
|
}
|
|
164
252
|
} else {
|
|
165
253
|
behavior = 'always';
|
|
@@ -174,6 +262,10 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
174
262
|
if (typeof m.channel === 'string') match.channel = m.channel;
|
|
175
263
|
if (typeof m.mount === 'string') match.mount = m.mount;
|
|
176
264
|
if (typeof m.pathGlob === 'string') match.pathGlob = m.pathGlob;
|
|
265
|
+
if (Array.isArray(m.metadataTrue)) {
|
|
266
|
+
const fields = m.metadataTrue.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
|
267
|
+
if (fields.length > 0) match.metadataTrue = fields;
|
|
268
|
+
}
|
|
177
269
|
if (m.filter && typeof m.filter === 'object') {
|
|
178
270
|
const f = m.filter as Record<string, unknown>;
|
|
179
271
|
if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
|
|
@@ -187,7 +279,17 @@ function validatePolicy(raw: unknown): GatePolicy {
|
|
|
187
279
|
}
|
|
188
280
|
}
|
|
189
281
|
|
|
190
|
-
|
|
282
|
+
// Validate resets (optional list of policy names; unknown names are ignored
|
|
283
|
+
// at runtime so reload races don't blow up).
|
|
284
|
+
let resets: string[] | undefined;
|
|
285
|
+
if (Array.isArray(obj.resets)) {
|
|
286
|
+
const names = obj.resets.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
|
287
|
+
if (names.length > 0) resets = names;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const policy: GatePolicy = { name: obj.name, match, behavior };
|
|
291
|
+
if (resets) policy.resets = resets;
|
|
292
|
+
return policy;
|
|
191
293
|
}
|
|
192
294
|
|
|
193
295
|
// ---------------------------------------------------------------------------
|
|
@@ -207,6 +309,14 @@ export class EventGate {
|
|
|
207
309
|
// Debounce state
|
|
208
310
|
private debounceTimers = new Map<string, DebounceState>();
|
|
209
311
|
|
|
312
|
+
// Rate-limit state — policy name → key → bucket.
|
|
313
|
+
private rateLimitBuckets = new Map<string, Map<string, RateBucket>>();
|
|
314
|
+
// Passive-sample state — policy name → key → counter (count since last fire).
|
|
315
|
+
private passiveSampleCounters = new Map<string, Map<string, number>>();
|
|
316
|
+
// Per-policy denial / fire counters for status reporting.
|
|
317
|
+
private rateLimitDenied = new Map<string, number>();
|
|
318
|
+
private passiveSampleFires = new Map<string, number>();
|
|
319
|
+
|
|
210
320
|
// Inference buffering
|
|
211
321
|
private inferring = new Set<string>();
|
|
212
322
|
private inferenceBuffer: PendingEvent[] = [];
|
|
@@ -225,6 +335,9 @@ export class EventGate {
|
|
|
225
335
|
private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
|
|
226
336
|
private requestInferenceFn: (agentName: string, reason: string, source: string) => void;
|
|
227
337
|
private getAgentNamesFn: () => string[];
|
|
338
|
+
/** Clock injection — keeps the new rate_limit / passive_sample paths
|
|
339
|
+
* testable without monkey-patching Date.now globally. */
|
|
340
|
+
private now: () => number;
|
|
228
341
|
|
|
229
342
|
constructor(opts: {
|
|
230
343
|
configPath: string;
|
|
@@ -233,12 +346,15 @@ export class EventGate {
|
|
|
233
346
|
addMessage: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
|
|
234
347
|
requestInference: (agentName: string, reason: string, source: string) => void;
|
|
235
348
|
getAgentNames: () => string[];
|
|
349
|
+
/** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
|
|
350
|
+
now?: () => number;
|
|
236
351
|
}) {
|
|
237
352
|
this.configPath = opts.configPath;
|
|
238
353
|
this.emitTrace = opts.emitTrace;
|
|
239
354
|
this.addMessageFn = opts.addMessage;
|
|
240
355
|
this.requestInferenceFn = opts.requestInference;
|
|
241
356
|
this.getAgentNamesFn = opts.getAgentNames;
|
|
357
|
+
this.now = opts.now ?? (() => Date.now());
|
|
242
358
|
|
|
243
359
|
// Seed or reconcile the on-disk config.
|
|
244
360
|
//
|
|
@@ -350,22 +466,36 @@ export class EventGate {
|
|
|
350
466
|
const newConfig = this.loadConfig();
|
|
351
467
|
if (!newConfig) return; // Parse error — keep previous config
|
|
352
468
|
|
|
353
|
-
//
|
|
469
|
+
// Capture old policies' behavior fingerprints before any state
|
|
470
|
+
// mutation — needed to detect kept policies whose parameters changed.
|
|
471
|
+
const oldBehaviorByName = new Map<string, string>();
|
|
472
|
+
for (const p of this.config.policies) {
|
|
473
|
+
oldBehaviorByName.set(p.name, behaviorFingerprint(p.behavior));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Clean up state for REMOVED policies (gone from new config entirely).
|
|
354
477
|
const newPolicyNames = new Set(newConfig.policies.map(p => p.name));
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.debounceTimers.delete(name);
|
|
478
|
+
const removedPolicies: string[] = [];
|
|
479
|
+
for (const oldName of oldBehaviorByName.keys()) {
|
|
480
|
+
if (!newPolicyNames.has(oldName)) {
|
|
481
|
+
this.clearPolicyState(oldName, { deliverPendingDebounce: true });
|
|
482
|
+
this.stats.delete(oldName);
|
|
483
|
+
removedPolicies.push(oldName);
|
|
362
484
|
}
|
|
363
485
|
}
|
|
364
486
|
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
487
|
+
// Clean up state for KEPT policies whose behavior parameters changed.
|
|
488
|
+
// Without this, a tokens 100→5 edit (or keyBy switch, or every 10→3,
|
|
489
|
+
// etc.) wouldn't take effect until existing buckets/counters drained.
|
|
490
|
+
// Stats are preserved — matchCount is historical, not live state.
|
|
491
|
+
const stateClearedPolicies: string[] = [];
|
|
492
|
+
for (const newPolicy of newConfig.policies) {
|
|
493
|
+
const oldHash = oldBehaviorByName.get(newPolicy.name);
|
|
494
|
+
if (oldHash === undefined) continue; // new policy, no state to clear
|
|
495
|
+
const newHash = behaviorFingerprint(newPolicy.behavior);
|
|
496
|
+
if (oldHash !== newHash) {
|
|
497
|
+
this.clearPolicyState(newPolicy.name, { deliverPendingDebounce: true });
|
|
498
|
+
stateClearedPolicies.push(newPolicy.name);
|
|
369
499
|
}
|
|
370
500
|
}
|
|
371
501
|
|
|
@@ -377,6 +507,9 @@ export class EventGate {
|
|
|
377
507
|
type: 'gate:config-reloaded',
|
|
378
508
|
configPath: this.configPath,
|
|
379
509
|
policyCount: newConfig.policies.length,
|
|
510
|
+
removedPolicies: removedPolicies.length > 0 ? removedPolicies : undefined,
|
|
511
|
+
stateClearedPolicies:
|
|
512
|
+
stateClearedPolicies.length > 0 ? stateClearedPolicies : undefined,
|
|
380
513
|
timestamp: now,
|
|
381
514
|
});
|
|
382
515
|
} catch {
|
|
@@ -445,6 +578,15 @@ export class EventGate {
|
|
|
445
578
|
}
|
|
446
579
|
}
|
|
447
580
|
|
|
581
|
+
// Metadata-truthy check — match if ANY listed field is truthy.
|
|
582
|
+
// Uses isMetadataTruthy so empty arrays / strings / objects are treated
|
|
583
|
+
// as falsy (matches user expectation, not raw JS Boolean coercion).
|
|
584
|
+
if (match.metadataTrue && match.metadataTrue.length > 0) {
|
|
585
|
+
const md = info.metadata ?? {};
|
|
586
|
+
const anyTrue = match.metadataTrue.some(field => isMetadataTruthy(md[field]));
|
|
587
|
+
if (!anyTrue) return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
448
590
|
return true;
|
|
449
591
|
}
|
|
450
592
|
|
|
@@ -479,8 +621,8 @@ export class EventGate {
|
|
|
479
621
|
channelId: info.channelId || undefined,
|
|
480
622
|
matchedPolicy: decision.policyName,
|
|
481
623
|
trigger: decision.trigger,
|
|
482
|
-
behavior:
|
|
483
|
-
timestamp:
|
|
624
|
+
behavior: formatBehavior(decision.behavior),
|
|
625
|
+
timestamp: this.now(),
|
|
484
626
|
});
|
|
485
627
|
|
|
486
628
|
return decision;
|
|
@@ -495,17 +637,17 @@ export class EventGate {
|
|
|
495
637
|
// Record stats
|
|
496
638
|
const policyStats = this.stats.get(policy.name) ?? { matchCount: 0, lastMatchTimestamp: null };
|
|
497
639
|
policyStats.matchCount++;
|
|
498
|
-
policyStats.lastMatchTimestamp =
|
|
640
|
+
policyStats.lastMatchTimestamp = this.now();
|
|
499
641
|
this.stats.set(policy.name, policyStats);
|
|
500
642
|
|
|
501
643
|
// Legacy trace kept for backward compatibility with existing consumers.
|
|
502
644
|
this.emitTrace({
|
|
503
645
|
type: 'gate:policy-matched',
|
|
504
646
|
policyName: policy.name,
|
|
505
|
-
behavior:
|
|
647
|
+
behavior: formatBehavior(policy.behavior),
|
|
506
648
|
eventType: info.eventType,
|
|
507
649
|
source: info.serverId || undefined,
|
|
508
|
-
timestamp:
|
|
650
|
+
timestamp: this.now(),
|
|
509
651
|
});
|
|
510
652
|
|
|
511
653
|
if (policy.behavior === 'skip') {
|
|
@@ -513,14 +655,143 @@ export class EventGate {
|
|
|
513
655
|
}
|
|
514
656
|
|
|
515
657
|
if (policy.behavior === 'always') {
|
|
658
|
+
this.applyResets(policy);
|
|
516
659
|
return { trigger: true, policyName: policy.name, behavior: 'always' };
|
|
517
660
|
}
|
|
518
661
|
|
|
519
|
-
|
|
520
|
-
|
|
662
|
+
if (typeof policy.behavior === 'object') {
|
|
663
|
+
if ('debounce' in policy.behavior) {
|
|
664
|
+
this.handleDebounce(policy, info);
|
|
665
|
+
return { trigger: false, policyName: policy.name, behavior: policy.behavior };
|
|
666
|
+
}
|
|
667
|
+
if ('rate_limit' in policy.behavior) {
|
|
668
|
+
return this.applyRateLimit(policy, info, policy.behavior.rate_limit);
|
|
669
|
+
}
|
|
670
|
+
if ('passive_sample' in policy.behavior) {
|
|
671
|
+
return this.applyPassiveSample(policy, info, policy.behavior.passive_sample);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Unknown behavior shape — should be unreachable thanks to validation,
|
|
676
|
+
// but be defensive: treat as skip rather than crash on a bad config.
|
|
677
|
+
return { trigger: false, policyName: policy.name, behavior: 'skip' };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// =========================================================================
|
|
681
|
+
// rate_limit + passive_sample + resets
|
|
682
|
+
// =========================================================================
|
|
683
|
+
|
|
684
|
+
/** Resolve the partition key for keyBy-style behaviors. */
|
|
685
|
+
private resolveKey(metadata: Record<string, unknown> | undefined, keyBy: string | undefined): string {
|
|
686
|
+
if (!keyBy) return SHARED_BUCKET_KEY;
|
|
687
|
+
const value = metadata?.[keyBy];
|
|
688
|
+
if (value === undefined || value === null || value === '') return SHARED_BUCKET_KEY;
|
|
689
|
+
return String(value);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private applyRateLimit(
|
|
693
|
+
policy: GatePolicy,
|
|
694
|
+
info: GateEventInfo,
|
|
695
|
+
config: { tokens: number; refillIntervalMs: number; keyBy?: string },
|
|
696
|
+
): GateDecision {
|
|
697
|
+
const key = this.resolveKey(info.metadata, config.keyBy);
|
|
698
|
+
let buckets = this.rateLimitBuckets.get(policy.name);
|
|
699
|
+
if (!buckets) {
|
|
700
|
+
buckets = new Map();
|
|
701
|
+
this.rateLimitBuckets.set(policy.name, buckets);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const now = this.now();
|
|
705
|
+
let bucket = buckets.get(key);
|
|
706
|
+
if (!bucket) {
|
|
707
|
+
bucket = { tokens: config.tokens, lastRefill: now };
|
|
708
|
+
buckets.set(key, bucket);
|
|
709
|
+
} else if (bucket.tokens < config.tokens) {
|
|
710
|
+
const elapsed = now - bucket.lastRefill;
|
|
711
|
+
if (elapsed > 0) {
|
|
712
|
+
const tokensToAdd = Math.floor(elapsed / config.refillIntervalMs);
|
|
713
|
+
if (tokensToAdd > 0) {
|
|
714
|
+
bucket.tokens = Math.min(config.tokens, bucket.tokens + tokensToAdd);
|
|
715
|
+
bucket.lastRefill += tokensToAdd * config.refillIntervalMs;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
// Bucket already full — keep the refill clock fresh so the next
|
|
720
|
+
// drain starts from "now" rather than ancient history.
|
|
721
|
+
bucket.lastRefill = now;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (bucket.tokens > 0) {
|
|
725
|
+
bucket.tokens -= 1;
|
|
726
|
+
this.applyResets(policy);
|
|
727
|
+
return { trigger: true, policyName: policy.name, behavior: policy.behavior };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
this.rateLimitDenied.set(policy.name, (this.rateLimitDenied.get(policy.name) ?? 0) + 1);
|
|
731
|
+
return { trigger: false, policyName: policy.name, behavior: policy.behavior };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private applyPassiveSample(
|
|
735
|
+
policy: GatePolicy,
|
|
736
|
+
info: GateEventInfo,
|
|
737
|
+
config: { every: number; keyBy?: string },
|
|
738
|
+
): GateDecision {
|
|
739
|
+
const key = this.resolveKey(info.metadata, config.keyBy);
|
|
740
|
+
let counters = this.passiveSampleCounters.get(policy.name);
|
|
741
|
+
if (!counters) {
|
|
742
|
+
counters = new Map();
|
|
743
|
+
this.passiveSampleCounters.set(policy.name, counters);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const next = (counters.get(key) ?? 0) + 1;
|
|
747
|
+
if (next >= config.every) {
|
|
748
|
+
counters.set(key, 0);
|
|
749
|
+
this.passiveSampleFires.set(policy.name, (this.passiveSampleFires.get(policy.name) ?? 0) + 1);
|
|
750
|
+
this.applyResets(policy);
|
|
751
|
+
return { trigger: true, policyName: policy.name, behavior: policy.behavior };
|
|
752
|
+
}
|
|
753
|
+
counters.set(key, next);
|
|
521
754
|
return { trigger: false, policyName: policy.name, behavior: policy.behavior };
|
|
522
755
|
}
|
|
523
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Clear bucket / counter state for the policies named in `policy.resets`.
|
|
759
|
+
* Called when a policy fires (decision.trigger === true). Unknown names
|
|
760
|
+
* are silently ignored — gate.json reload could remove them.
|
|
761
|
+
*/
|
|
762
|
+
private applyResets(policy: GatePolicy): void {
|
|
763
|
+
if (!policy.resets || policy.resets.length === 0) return;
|
|
764
|
+
for (const target of policy.resets) {
|
|
765
|
+
this.rateLimitBuckets.delete(target);
|
|
766
|
+
this.passiveSampleCounters.delete(target);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Clear ALL per-policy state (debounce timer, rate-limit buckets,
|
|
772
|
+
* passive-sample counters, denial / fire counters) for one policy.
|
|
773
|
+
* Used by reloadIfChanged when a policy is removed or its behavior
|
|
774
|
+
* params change. Stats (matchCount, lastMatchTimestamp) are preserved
|
|
775
|
+
* — those are historical and shouldn't reset on a config edit.
|
|
776
|
+
*/
|
|
777
|
+
private clearPolicyState(
|
|
778
|
+
name: string,
|
|
779
|
+
options: { deliverPendingDebounce: boolean },
|
|
780
|
+
): void {
|
|
781
|
+
const debounce = this.debounceTimers.get(name);
|
|
782
|
+
if (debounce) {
|
|
783
|
+
clearTimeout(debounce.timer);
|
|
784
|
+
if (options.deliverPendingDebounce && debounce.events.length > 0) {
|
|
785
|
+
this.deliverEvents(debounce.events);
|
|
786
|
+
}
|
|
787
|
+
this.debounceTimers.delete(name);
|
|
788
|
+
}
|
|
789
|
+
this.rateLimitBuckets.delete(name);
|
|
790
|
+
this.passiveSampleCounters.delete(name);
|
|
791
|
+
this.rateLimitDenied.delete(name);
|
|
792
|
+
this.passiveSampleFires.delete(name);
|
|
793
|
+
}
|
|
794
|
+
|
|
524
795
|
// =========================================================================
|
|
525
796
|
// shouldTriggerInference callback adapter
|
|
526
797
|
// =========================================================================
|
|
@@ -687,6 +958,18 @@ export class EventGate {
|
|
|
687
958
|
nextDeliveryMs: null, // Timer internals not easily inspectable
|
|
688
959
|
};
|
|
689
960
|
}
|
|
961
|
+
if (typeof p.behavior === 'object' && 'rate_limit' in p.behavior) {
|
|
962
|
+
result.rateLimitState = {
|
|
963
|
+
bucketCount: this.rateLimitBuckets.get(p.name)?.size ?? 0,
|
|
964
|
+
deniedCount: this.rateLimitDenied.get(p.name) ?? 0,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
if (typeof p.behavior === 'object' && 'passive_sample' in p.behavior) {
|
|
968
|
+
result.passiveSampleState = {
|
|
969
|
+
counterCount: this.passiveSampleCounters.get(p.name)?.size ?? 0,
|
|
970
|
+
fireCount: this.passiveSampleFires.get(p.name) ?? 0,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
690
973
|
return result;
|
|
691
974
|
});
|
|
692
975
|
|
|
@@ -718,6 +1001,10 @@ export class EventGate {
|
|
|
718
1001
|
clearTimeout(state.timer);
|
|
719
1002
|
}
|
|
720
1003
|
this.debounceTimers.clear();
|
|
1004
|
+
this.rateLimitBuckets.clear();
|
|
1005
|
+
this.passiveSampleCounters.clear();
|
|
1006
|
+
this.rateLimitDenied.clear();
|
|
1007
|
+
this.passiveSampleFires.clear();
|
|
721
1008
|
this.inferenceBuffer = [];
|
|
722
1009
|
}
|
|
723
1010
|
}
|
package/src/gate/types.ts
CHANGED
|
@@ -10,11 +10,52 @@
|
|
|
10
10
|
// Gate behaviors
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Token-bucket configuration for the `rate_limit` behavior.
|
|
15
|
+
*
|
|
16
|
+
* Each matching event consumes one token; events that arrive with an empty
|
|
17
|
+
* bucket are denied (decision: trigger=false). Tokens regenerate at one per
|
|
18
|
+
* `refillIntervalMs`, capped at `tokens`. Buckets are partitioned by the
|
|
19
|
+
* value of `keyBy` resolved against event metadata — so a per-user limit
|
|
20
|
+
* uses `keyBy: "senderId"`, a per-channel limit uses `keyBy: "channelId"`,
|
|
21
|
+
* etc. With no `keyBy` (or when the resolved value is missing), one shared
|
|
22
|
+
* bucket is used for the policy.
|
|
23
|
+
*/
|
|
24
|
+
export interface RateLimitConfig {
|
|
25
|
+
/** Max bucket capacity, also the initial fill. Must be > 0. */
|
|
26
|
+
tokens: number;
|
|
27
|
+
/** Milliseconds between token regenerations. Must be > 0. */
|
|
28
|
+
refillIntervalMs: number;
|
|
29
|
+
/**
|
|
30
|
+
* Metadata field name to partition buckets by. If absent or the metadata
|
|
31
|
+
* field is missing on a given event, all events share one bucket.
|
|
32
|
+
*/
|
|
33
|
+
keyBy?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Counter configuration for the `passive_sample` behavior.
|
|
38
|
+
*
|
|
39
|
+
* Increments a counter on every match. When the counter reaches `every`,
|
|
40
|
+
* the policy fires (trigger=true) and the counter resets to zero. Useful
|
|
41
|
+
* for "decide whether to act every Nth observation" — chat passive
|
|
42
|
+
* sampling, periodic analysis of sensor streams, etc. Counters are
|
|
43
|
+
* partitioned by `keyBy` the same way `rate_limit` buckets are.
|
|
44
|
+
*/
|
|
45
|
+
export interface PassiveSampleConfig {
|
|
46
|
+
/** Trigger every N-th match. Must be > 0. */
|
|
47
|
+
every: number;
|
|
48
|
+
/** Optional metadata field name for separate per-key counters. */
|
|
49
|
+
keyBy?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
13
52
|
/** How a matching policy affects inference triggering. */
|
|
14
53
|
export type GateBehavior =
|
|
15
54
|
| 'always' // Trigger inference immediately
|
|
16
55
|
| 'skip' // Don't trigger inference (event still enters context)
|
|
17
|
-
| { debounce: number }
|
|
56
|
+
| { debounce: number } // Batch events per-policy, deliver after delay (ms)
|
|
57
|
+
| { rate_limit: RateLimitConfig } // Token bucket per `keyBy`; deny when empty
|
|
58
|
+
| { passive_sample: PassiveSampleConfig }; // Fire every Nth match
|
|
18
59
|
|
|
19
60
|
// ---------------------------------------------------------------------------
|
|
20
61
|
// Policy match criteria
|
|
@@ -40,6 +81,13 @@ export interface GatePolicyMatch {
|
|
|
40
81
|
* Supports `*` wildcard; not full gitignore syntax.
|
|
41
82
|
*/
|
|
42
83
|
pathGlob?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Match when ANY listed metadata field is truthy (Boolean(metadata[name])).
|
|
86
|
+
* Useful for OR-ing flag-style metadata, e.g.:
|
|
87
|
+
* metadataTrue: ["isMention", "isPrivate", "isReplyToBot"]
|
|
88
|
+
* matches if any of those is set. Empty/omitted = no metadata constraint.
|
|
89
|
+
*/
|
|
90
|
+
metadataTrue?: string[];
|
|
43
91
|
}
|
|
44
92
|
|
|
45
93
|
// ---------------------------------------------------------------------------
|
|
@@ -51,6 +99,13 @@ export interface GatePolicy {
|
|
|
51
99
|
name: string;
|
|
52
100
|
match: GatePolicyMatch;
|
|
53
101
|
behavior: GateBehavior;
|
|
102
|
+
/**
|
|
103
|
+
* Names of other policies whose state to clear when THIS policy fires
|
|
104
|
+
* (decision.trigger === true). Lets you express interactions like "a
|
|
105
|
+
* direct invocation should reset the passive-sample counter", without
|
|
106
|
+
* coupling the behaviors to each other in code. Unknown names are ignored.
|
|
107
|
+
*/
|
|
108
|
+
resets?: string[];
|
|
54
109
|
}
|
|
55
110
|
|
|
56
111
|
// ---------------------------------------------------------------------------
|
|
@@ -113,6 +168,16 @@ export interface GatePolicyStats {
|
|
|
113
168
|
pendingCount: number;
|
|
114
169
|
nextDeliveryMs: number | null;
|
|
115
170
|
};
|
|
171
|
+
/** Present for `rate_limit` behaviors. */
|
|
172
|
+
rateLimitState?: {
|
|
173
|
+
bucketCount: number;
|
|
174
|
+
deniedCount: number;
|
|
175
|
+
};
|
|
176
|
+
/** Present for `passive_sample` behaviors. */
|
|
177
|
+
passiveSampleState?: {
|
|
178
|
+
counterCount: number;
|
|
179
|
+
fireCount: number;
|
|
180
|
+
};
|
|
116
181
|
}
|
|
117
182
|
|
|
118
183
|
/** Full gate status returned by gate:status tool. */
|