Repository navigation
feat(gate): messageQuietPeriod — one wake per message burst #179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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>>(); | ||
|
|
@@ -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); | ||
| this.configPath = opts.configPath; | ||
| this.privilegedUsersPath = opts.privilegedUsersPath; | ||
| this.emitTrace = opts.emitTrace; | ||
|
|
@@ -1067,6 +1079,14 @@ export class EventGate { | |
| this.defaultByEventType.set(info.eventType, bucket); | ||
| } | ||
| if (observed.length > 0) decision.observed = observed; | ||
| if ( | ||
| decision.trigger && | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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 }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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', | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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()) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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; | ||
|
|
||
|
|
||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
{ 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.Prompt To Fix With AI