@animalabs/agent-framework 0.4.1 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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",
package/src/agent.ts CHANGED
@@ -30,7 +30,13 @@ export class Agent {
30
30
  readonly allowedTools: 'all' | string[];
31
31
  readonly triggerSources: 'all' | string[];
32
32
  readonly maxTokens: number;
33
- readonly temperature: number;
33
+ /**
34
+ * Sampling temperature. Optional — if undefined, the parameter is omitted
35
+ * from the inference request entirely. Required for newer Anthropic models
36
+ * (claude-opus-4-7+) that have deprecated the `temperature` parameter and
37
+ * return HTTP 400 if it's set, even to 1.
38
+ */
39
+ readonly temperature: number | undefined;
34
40
 
35
41
  private _state: AgentState = { status: 'idle' };
36
42
  private _inferenceStartedAt = 0;
@@ -51,7 +57,7 @@ export class Agent {
51
57
  this.allowedTools = config.allowedTools ?? 'all';
52
58
  this.triggerSources = config.triggerSources ?? 'all';
53
59
  this.maxTokens = config.maxTokens ?? 4096;
54
- this.temperature = config.temperature ?? 1;
60
+ this.temperature = config.temperature;
55
61
  this.maxStreamTokens = config.maxStreamTokens ?? 150_000;
56
62
  this.contextManager = contextManager;
57
63
  this.membrane = membrane;
@@ -180,7 +186,7 @@ export class Agent {
180
186
  config: {
181
187
  model: this.model,
182
188
  maxTokens: this.maxTokens,
183
- temperature: this.temperature,
189
+ ...(this.temperature !== undefined && { temperature: this.temperature }),
184
190
  },
185
191
  tools: tools.length > 0 ? tools : undefined,
186
192
  assistantParticipant: this.name,
@@ -310,7 +316,7 @@ export class Agent {
310
316
  config: {
311
317
  model: this.model,
312
318
  maxTokens: this.maxTokens,
313
- temperature: this.temperature,
319
+ ...(this.temperature !== undefined && { temperature: this.temperature }),
314
320
  },
315
321
  tools: availableTools.length > 0 ? availableTools : undefined,
316
322
  promptCaching: true,
@@ -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 (typeof b.debounce === 'number' && b.debounce > 0) {
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 must be a positive number`);
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
- return { name: obj.name, match, behavior };
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
- // Clean up debounce timers for removed policies
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
- for (const [name, state] of this.debounceTimers) {
356
- if (!newPolicyNames.has(name)) {
357
- clearTimeout(state.timer);
358
- if (state.events.length > 0) {
359
- this.deliverEvents(state.events);
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
- // Reset stats for removed policies
366
- for (const name of this.stats.keys()) {
367
- if (!newPolicyNames.has(name)) {
368
- this.stats.delete(name);
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: typeof decision.behavior === 'object' ? `debounce:${decision.behavior.debounce}` : decision.behavior,
483
- timestamp: Date.now(),
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 = Date.now();
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: typeof policy.behavior === 'object' ? `debounce:${policy.behavior.debounce}` : policy.behavior,
647
+ behavior: formatBehavior(policy.behavior),
506
648
  eventType: info.eventType,
507
649
  source: info.serverId || undefined,
508
- timestamp: Date.now(),
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
- // Debounce
520
- this.handleDebounce(policy, info);
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 }; // Batch events per-policy, deliver after delay (ms)
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. */