Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/feat-gate-message-quiet-period.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Opt-in gate `messageQuietPeriod: { quietMs, maxWaitMs? }`: MCPL message
wakes are held until the sender goes quiet (default hard cap 30 s), so a
caption + attachments or a multi-part message wakes the agent once instead
of once per part. Agents already inferring are not held. Non-message wakes
(timers, tool results) are unaffected.
1 change: 1 addition & 0 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,7 @@ export class AgentFramework {
configPath,
initialConfig: config.gate.config,
privilegedUsersPath: config.gate.privilegedUsersPath,
messageQuietPeriod: config.gate.messageQuietPeriod,
emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined),
requestInference: (agentName, reason, source, provenance) => {
Expand Down
58 changes: 58 additions & 0 deletions src/gate/event-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,14 @@ export class EventGate {

// Debounce state
private debounceTimers = new Map<string, DebounceState>();
// Message quiet period (GateOptions.messageQuietPeriod): a message-driven
// wake is held until no further message has arrived for quietMs (capped at
// maxWaitMs from the first held one), then fires once.
private readonly quietMs: number;
private readonly quietMaxWaitMs: number;
private quietTimer: ReturnType<typeof setTimeout> | null = null;
private quietEvents: PendingEvent[] = [];
private quietFirstAt = 0;

// Rate-limit state — policy name → key → bucket.
private rateLimitBuckets = new Map<string, Map<string, RateBucket>>();
Expand Down Expand Up @@ -608,7 +616,11 @@ export class EventGate {
now?: () => number;
/** Per-event timeout (ms) for the optional gate.js script. Default 50. */
scriptTimeoutMs?: number;
/** See GateOptions.messageQuietPeriod. */
messageQuietPeriod?: { quietMs: number; maxWaitMs?: number };
}) {
this.quietMs = Math.max(0, opts.messageQuietPeriod?.quietMs ?? 0);
this.quietMaxWaitMs = Math.max(this.quietMs, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hard cap exceeds configuration If a caller sets { quietMs: 10000, maxWaitMs: 2000 }, this clamp raises the configured hard cap to 10 seconds. The first message can therefore wait 10 seconds instead of the documented maximum of two.

Suggested change
this.quietMaxWaitMs = Math.max(this.quietMs, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);
this.quietMaxWaitMs = Math.max(0, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 623

Comment:
**Hard cap exceeds configuration** If a caller sets `{ quietMs: 10000, maxWaitMs: 2000 }`, this clamp raises the configured hard cap to 10 seconds. The first message can therefore wait 10 seconds instead of the documented maximum of two.

```suggestion
    this.quietMaxWaitMs = Math.max(0, opts.messageQuietPeriod?.maxWaitMs ?? 30_000);
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

this.configPath = opts.configPath;
this.privilegedUsersPath = opts.privilegedUsersPath;
this.emitTrace = opts.emitTrace;
Expand Down Expand Up @@ -1067,6 +1079,14 @@ export class EventGate {
this.defaultByEventType.set(info.eventType, bucket);
}
if (observed.length > 0) decision.observed = observed;
if (
decision.trigger &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Filtered parts shorten quiet window With a rate-limited policy, a caption can start the hold while subsequent image messages are stored but denied a trigger. Because only triggering messages reset this timer, the agent can wake while those later parts of the same burst are still arriving.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1083

Comment:
**Filtered parts shorten quiet window** With a rate-limited policy, a caption can start the hold while subsequent image messages are stored but denied a trigger. Because only triggering messages reset this timer, the agent can wake while those later parts of the same burst are still arriving.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

this.quietMs > 0 &&
(info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Heartbeats are delayed mcpl:push-event can carry a heartbeat rather than a message; the gate itself models heartbeats with this event type. When its policy approves such an event, this condition holds its wake for quietMs, contrary to the promise that heartbeats remain immediate.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1085

Comment:
**Heartbeats are delayed** `mcpl:push-event` can carry a heartbeat rather than a message; the gate itself models heartbeats with this event type. When its policy approves such an event, this condition holds its wake for `quietMs`, contrary to the promise that heartbeats remain immediate.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

) {
this.holdForQuietPeriod(info);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wake precedes message storage When an earlier queued event takes longer than quietMs to process, this timer can expire before the framework processes and stores the incoming message. The resulting inference can begin without the caption or attachments it was meant to wait for, producing a partial reply.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1087

Comment:
**Wake precedes message storage** When an earlier queued event takes longer than `quietMs` to process, this timer can expire before the framework processes and stores the incoming message. The resulting inference can begin without the caption or attachments it was meant to wait for, producing a partial reply.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

decision = { ...decision, trigger: false, quietHeld: true };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Quiet hold bypasses tune-out For an addressed message in a tuned-out channel, changing the approved gate verdict to trigger: false makes tune-out treat the message as suppressed. It misses the acknowledgement and subconscious wake, but the timer later wakes the resident directly even though the message was diverted from its compiled view.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1088

Comment:
**Quiet hold bypasses tune-out** For an addressed message in a tuned-out channel, changing the approved gate verdict to `trigger: false` makes tune-out treat the message as suppressed. It misses the acknowledgement and subconscious wake, but the timer later wakes the resident directly even though the message was diverted from its compiled view.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

this.emitTrace({
type: 'gate:decision',
Expand Down Expand Up @@ -1432,6 +1452,44 @@ export class EventGate {
// Debounce
// =========================================================================

/** Hold a message-driven wake until the senders go quiet. The message
* itself is already in context (stored at arrival); only the WAKE waits,
* so a caption and its images, or a reply split into several Discord
* messages, reach the agent as one turn instead of several. */
private holdForQuietPeriod(info: GateEventInfo): void {
const now = this.now();
if (this.quietEvents.length === 0) this.quietFirstAt = now;
this.quietEvents.push({
policyName: 'quiet-period',
content: '',
eventType: info.eventType,
timestamp: now,
inContext: true,
channelId: info.channelId || undefined,
authorId: this.extractAuthorId(info.metadata) ?? undefined,
serverId: info.serverId || undefined,
addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
});
if (this.quietTimer) clearTimeout(this.quietTimer);
const wait = Math.max(0, Math.min(this.quietMs, this.quietFirstAt + this.quietMaxWaitMs - now));
this.quietTimer = setTimeout(() => this.fireQuietPeriod(), wait);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Timer survives gate disposal If the framework stops during a quiet hold, EventGate.dispose() does not clear this timer. It can later enqueue an inference request into the stopped framework, leaving a stale wake behind.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1475

Comment:
**Timer survives gate disposal** If the framework stops during a quiet hold, `EventGate.dispose()` does not clear this timer. It can later enqueue an inference request into the stopped framework, leaving a stale wake behind.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

(this.quietTimer as { unref?: () => void }).unref?.();
}

private fireQuietPeriod(): void {
this.quietTimer = null;
const events = this.quietEvents;
this.quietEvents = [];
if (events.length === 0) return;
const provenance = wakeProvenance(events);
for (const agentName of this.getAgentNamesFn()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Quiet wake bypasses fork routing When a message belongs to a channel-bound conversation fork, the ordinary path stores it for and wakes only that fork. This timer instead wakes every non-subconscious agent, including unrelated forks and the dormant template, even though their contexts did not receive the message.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1485

Comment:
**Quiet wake bypasses fork routing** When a message belongs to a channel-bound conversation fork, the ordinary path stores it for and wakes only that fork. This timer instead wakes every non-subconscious agent, including unrelated forks and the dormant template, even though their contexts did not receive the message.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

// An agent already mid-turn received these messages as mid-turn input;
// waking it again afterwards would spend a redundant turn.
if (this.inferring.has(agentName)) continue;
Comment on lines +1486 to +1488

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mid-turn messages lose wakes A message received during a turn can remain deferred until that turn ends. If this timer fires first, the inferring check discards its wake; teardown then stores the message without requesting another turn, so it waits for an unrelated wake. If the message was instead injected at a tool boundary and the turn ends before the timer fires, this check permits a redundant turn.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1486-1488

Comment:
**Mid-turn messages lose wakes** A message received during a turn can remain deferred until that turn ends. If this timer fires first, the `inferring` check discards its wake; teardown then stores the message without requesting another turn, so it waits for an unrelated wake. If the message was instead injected at a tool boundary and the turn ends before the timer fires, this check permits a redundant turn.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

this.requestInferenceFn(agentName, 'gate:quiet-period', 'gate', provenance);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Quiet wake loses reply channel For a held Discord DM or messages arriving across channels, this request carries the chosen channel only as wake telemetry, not as the turn's routing channel. The trunk turn therefore falls back to the global channel, so its reply can go to the wrong room or have no usable destination.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1489

Comment:
**Quiet wake loses reply channel** For a held Discord DM or messages arriving across channels, this request carries the chosen channel only as wake telemetry, not as the turn's routing channel. The trunk turn therefore falls back to the global channel, so its reply can go to the wrong room or have no usable destination.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1488 to +1489

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Quiet wake interrupts sleep If the agent calls sleep after a message is held but before this timer expires, the held timer remains active. Its callback requests an inference without checking the sleep window, waking the agent for an external message while it is supposed to remain asleep.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1488-1489

Comment:
**Quiet wake interrupts sleep** If the agent calls `sleep` after a message is held but before this timer expires, the held timer remains active. Its callback requests an inference without checking the sleep window, waking the agent for an external message while it is supposed to remain asleep.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
}

private handleDebounce(policy: GatePolicy, info: GateEventInfo): void {
const debounceMs = (policy.behavior as { debounce: number }).debounce;

Expand Down
11 changes: 11 additions & 0 deletions src/gate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ export interface GateOptions {
* missing, no one bypasses sleep.
*/
privilegedUsersPath?: string;
/**
* Hold message-driven wakes (MCPL channel-incoming / push-event that the
* policies would trigger) until no further message has arrived for
* `quietMs`, capped at `maxWaitMs` (default 30000) from the first held
* message; then wake once. Messages still enter context at arrival. Other
* wake sources (scheduled, tool results, heartbeat) are unaffected.
* Default off.
*/
messageQuietPeriod?: { quietMs: number; maxWaitMs?: number };
}

// ---------------------------------------------------------------------------
Expand All @@ -178,6 +187,8 @@ export interface GateDecision {
policyName: string | null;
/** The behavior that was applied. */
behavior: GateBehavior;
/** The trigger was converted into a held quiet-period wake. */
quietHeld?: boolean;
/**
* Names of `passthrough` policies that matched and counted this event but
* did not fire, so evaluation continued past them. Absent when none did.
Expand Down
86 changes: 86 additions & 0 deletions test/event-gate-quiet-period.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* GateOptions.messageQuietPeriod: a burst of messages (a caption and its
* images, a reply split across several Discord messages) wakes the agent once,
* after the senders go quiet — not once per message.
*/
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { EventGate } from '../src/gate/event-gate.js';
import type { GateEventInfo } from '../src/gate/types.js';

const TMP = join(import.meta.dirname, '../.test-tmp-gate-quiet');

function makeGate(quiet?: { quietMs: number; maxWaitMs?: number }) {
mkdirSync(TMP, { recursive: true });
const wakes: Array<{ reason: string; channelId?: string }> = [];
const messages: unknown[] = [];
const gate = new EventGate({
configPath: join(TMP, `gate-${Math.random().toString(36).slice(2)}.json`),
initialConfig: { default: 'always', policies: [] },
emitTrace: () => {},
addMessage: (...args) => { messages.push(args); return ''; },
requestInference: (_agent, reason, _source, provenance) => wakes.push({ reason, channelId: provenance?.channelId }),
getAgentNames: () => ['agent'],
...(quiet ? { messageQuietPeriod: quiet } : {}),
});
return { gate, wakes, messages };
}

const msg = (text: string, extra: Partial<GateEventInfo> = {}): GateEventInfo => ({
content: text,
eventType: 'mcpl:channel-incoming',
serverId: 'discord',
channelId: 'discord:g:salon',
metadata: { authorId: 'u1' },
...extra,
});
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

describe('message quiet period', () => {
afterEach(() => rmSync(TMP, { recursive: true, force: true }));

it('holds a burst and wakes once after the senders go quiet', async () => {
const { gate, wakes, messages } = makeGate({ quietMs: 60 });
assert.equal(gate.evaluate(msg('caption')).trigger, false);
await sleep(20);
assert.equal(gate.evaluate(msg('image 1')).trigger, false);
await sleep(20);
assert.equal(gate.evaluate(msg('image 2')).trigger, false);
assert.equal(wakes.length, 0, 'no wake while messages keep arriving');
await sleep(90);
assert.equal(wakes.length, 1, 'one wake for the whole burst');
assert.equal(wakes[0].reason, 'gate:quiet-period');
assert.equal(wakes[0].channelId, 'discord:g:salon');
assert.equal(messages.length, 0, 'no batch summary is added to context');
});

it('caps the hold at maxWaitMs under a continuous stream', async () => {
const { gate, wakes } = makeGate({ quietMs: 50, maxWaitMs: 120 });
const start = Date.now();
while (Date.now() - start < 200) {
gate.evaluate(msg('chatter'));
await sleep(25);
if (wakes.length > 0) break;
}
assert.equal(wakes.length, 1, 'woke despite continuous messages');
assert.ok(Date.now() - start < 190, 'within roughly the cap');
});

it('does not re-wake an agent that is already mid-turn when the hold ends', async () => {
const { gate, wakes } = makeGate({ quietMs: 40 });
gate.evaluate(msg('arrives during a turn'));
gate.onInferenceStarted('agent');
await sleep(70);
assert.equal(wakes.length, 0);
gate.onInferenceEnded('agent');
});

it('does not delay non-message wakes, and is off by default', () => {
const { gate } = makeGate({ quietMs: 60 });
assert.equal(gate.evaluate(msg('tick', { eventType: 'external-message' })).trigger, true);
const plain = makeGate();
assert.equal(plain.gate.evaluate(msg('hello')).trigger, true);
});
});
Loading