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.
slimepriestess
left a comment
There was a problem hiding this comment.
Hi Ian, thanks for this one, and sorry it sat so long. The measurement in the commit message (split parts 0–2 s apart, caption plus forward within ~10 s) is exactly the grounding we like, and the burst problem is real. Main still has nothing that does this. Per-policy debounce is the closest, but it needs a policy match, writes a summary message, and has no cap. So this isn't superseded. Two things need changing first, plus a few smaller notes.
1. A hold that ends mid-turn drops the wake (blocking). fireQuietPeriod skips agents in inferring (event-gate.ts:1486–1488, with the comment that they received the messages as mid-turn input). But mid-turn messages are only injected at tool-round boundaries, so one that arrives during the last round is stored at turn end and never read by a turn (we read that part on main). The gate half we ran: hold a message, start a turn, let the hold expire, end the turn, and the probe gets 0 wakes, ever. The same timeline through a debounce policy wakes once after the turn ends, via bufferForInference. So an agent can silently miss a message, and nothing is logged. Routing inferring agents through that same buffer, plus a test that checks for the wake after onInferenceEnded, would close it. (Test 3 currently pins the drop: it asserts 0 wakes and stops there.)
2. The rebase needs one line beyond the conflicts. Since #200 (2de01db), batched wakes carry routeChannelId, so a DM's reply goes back to the DM (main event-gate.ts:1512). The two textual conflicts in event-gate.ts resolve as keep-both, and our first reader's trial merge builds and passes your tests. But holdForQuietPeriod doesn't set routeChannelId: this.routeChannelFor(info), so a held DM's reply would fall back to the most-recent-inbound channel, which is the 9/30 misroute. A test that a held addressed message's wake carries a route would pin it.
Smaller:
- The single timer is global, so chatter in one channel can hold a DM in another for up to
maxWaitMs. Keying per channel, or capping addressed messages atquietMs, might be worth it. dispose()doesn't clearquietTimer/quietEvents, so a pending hold can fire after stop.- Nothing pins the
framework.tsline that passes the option through: deleting it leaves all four tests green (our first reader ran that one). - With conversation routing or tune-out on, the held wake fans out to every agent, and tune-out sees the gate as having said no (read, not run). It's worth saying in the description whether those combinations are intended.
- The changelog says "Agents already inferring are not held", but they are held and then skipped. Worth rewording once 1 is decided.
Happy to re-read once it's rebased.
|
@ian-de-marcellus @slimepriestess If Ian is short on time, we'd be glad to carry this over the line: keep his commit as the base, rebase onto current main, and address the two blocking points and the notes — routing the quiet-period wake through the debounce buffer so an agent mid-turn gets its wake after the turn, and carrying |
|
Thanks, Darsi. As the reviewer of the 10/20 changes request, that's the shape we asked for: the quiet-period wake for an agent mid-turn going through the If it does come to a follow-up, we'll read it fresh at its own head. One thing that may save a round: Greptile's 9/28 summary above lists several cases our review didn't cover, and we haven't verified them:
Each is worth a look, or a line in the description saying it's out of scope. |
|
Opened #856 with Ian's commit as the base and one commit answering the Oct 20 review and Greptile's cases; each point is listed there with its test. Ian, it stays yours to take back. |
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