Repository navigation
feat(gate): messageQuietPeriod — one wake per message burst - #179
ian-de-marcellus wants to merge 1 commit into
Conversation
GateOptions.messageQuietPeriod { quietMs, maxWaitMs? }: a message-driven wake
(MCPL channel-incoming / push-event the policies would trigger) is held until
no further message has arrived for quietMs, capped at maxWaitMs (default 30 s)
from the first held message, then fires once. Messages still enter context at
arrival and no batch summary is added; an agent already mid-turn when the hold
ends is not re-woken. Scheduled wakes, tool results and heartbeats are
unaffected. Default off.
Why: a caption and its forwarded images, or a reply Discord split into several
messages, woke residents once per message — and in rooms with several AIs each
part triggered replies from all the others. Measured gaps (4,758 messages):
split parts arrive 0-2 s apart; caption+forward within ~10 s.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
| this.quietMs > 0 && | ||
| (info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event') | ||
| ) { | ||
| this.holdForQuietPeriod(info); |
There was a problem hiding this 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.
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.| this.quietEvents = []; | ||
| if (events.length === 0) return; | ||
| const provenance = wakeProvenance(events); | ||
| for (const agentName of this.getAgentNamesFn()) { |
There was a problem hiding this 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.
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; | ||
| this.requestInferenceFn(agentName, 'gate:quiet-period', 'gate', provenance); |
There was a problem hiding this 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.
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.| (info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event') | ||
| ) { | ||
| this.holdForQuietPeriod(info); | ||
| decision = { ...decision, trigger: false, quietHeld: true }; |
There was a problem hiding this 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.
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.| if (this.inferring.has(agentName)) continue; | ||
| this.requestInferenceFn(agentName, 'gate:quiet-period', 'gate', provenance); |
There was a problem hiding this 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.
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.| // 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; |
There was a problem hiding this 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.
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.| } | ||
| if (observed.length > 0) decision.observed = observed; | ||
| if ( | ||
| decision.trigger && |
There was a problem hiding this 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.
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.| if ( | ||
| decision.trigger && | ||
| this.quietMs > 0 && | ||
| (info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event') |
There was a problem hiding this 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.
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.| 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); |
There was a problem hiding this 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.
| 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.| }); | ||
| 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); |
There was a problem hiding this 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.
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.
Problem. Discord delivers one logical message as several: a caption followed by forwarded images, or a long bot reply split into 2000-character parts arriving 0–2 s apart. Each part is a separate MCPL
channel-incomingevent, and each triggers a wake. The agent replies to the caption before the image arrives, then again. In a room with several agents, multi-part bot messages set off cascades of partial replies. Over four weeks of one host's traffic (~4.7k messages), 891 of 1,428 follow-up parts arrived within 2 s of the previous one.Fix. Opt-in
GateOptions.messageQuietPeriod: { quietMs, maxWaitMs? }. When a policy would trigger on a message-driven event (mcpl:channel-incoming/mcpl:push-event), the gate holds the wake until no further message has arrived forquietMs(capped atmaxWaitMs, default 30 s, from the first held message), then wakes once with the combined provenance. Messages still enter context at arrival; only the wake waits. Agents already mid-turn are not re-woken, since they received the messages as mid-turn input. Timers, tool results, and heartbeats are unaffected. It's implemented gate-wide rather than as a policy because gate policies are first-match-wins, so a debounce policy would displace existing rules.Tests.
test/event-gate-quiet-period.test.ts: a burst wakes once after the senders go quiet; the hold is capped atmaxWaitMsunder a continuous stream; an agent already mid-turn is not re-woken; non-message wakes are not delayed, and the option is off by default. Full suite: 958 pass / 0 fail.🤖 Generated with Claude Code