Skip to content
Merged
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
2 changes: 2 additions & 0 deletions changelog.d/sleep-wakes-only-for-the-hosts-heartbeat.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- While an agent sleeps, an event wakes it as its heartbeat only when it comes from the MCPL server configured under the id `heartbeat`. Any server's push or channel message whose metadata said `source: 'heartbeat'` woke a sleeping agent, as did a push whose `origin` gave the server id `heartbeat`. The `heartbeat` server's ticks and reminders still wake it, and so does a privileged author, as before.
- The event gate reads a push event's server id, feature set, event id and event type as the host has them. A push's `origin` that held fields of those names took their place, so a server could match a policy written for another server's id, or for channel messages. The origin's other fields reach the gate as before.
8 changes: 5 additions & 3 deletions src/gate/event-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,11 +1048,13 @@ export class EventGate {
// rouse the agent — its own heartbeat (an internal clock, not an outside
// interruption: each tick lets the agent reconsider and optionally `wake`)
// and a privileged user's ping. The event always enters context regardless
// (the gate only governs inference triggering).
// (the gate only governs inference triggering). The heartbeat is the
// server the host knows as `heartbeat`, the server id the silent-heartbeat
// marker requires too: a `source` in an event's metadata is the server's
// own word, which any server can give.
if (this.sleepUntil > this.now()) {
this.reloadPrivilegedIfChanged();
const isHeartbeat =
info.metadata?.source === 'heartbeat' || info.serverId === 'heartbeat';
const isHeartbeat = info.serverId === 'heartbeat';
const authorId = this.extractAuthorId(info.metadata);
const privileged = authorId !== null && this.privilegedUserIds.has(authorId);
if (!isHeartbeat && !privileged) {
Expand Down
5 changes: 4 additions & 1 deletion src/mcpl/push-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,15 @@ export class PushHandler {
.filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
.map((b) => b.text)
.join('\n');
// The host's own fields come after the server's origin, as a channel
// message's do, so an origin can't give the gate another server's id,
// feature set, event id or event type.
const metadata: Record<string, unknown> = {
...(params.origin ?? {}),
serverId,
featureSet: params.featureSet,
eventId: params.eventId,
eventType: 'mcpl:push-event',
...(params.origin ?? {}),
...(params.tags ? { tags: params.tags } : {}),
};
triggerInference = this.shouldTriggerInference(textContent, metadata);
Expand Down
25 changes: 25 additions & 0 deletions test/event-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface TraceEntry {
function makeGate(configPath: string, opts?: {
initialConfig?: GateConfig;
resolveRouteChannel?: (info: GateEventInfo) => string | undefined;
privilegedUsersPath?: string;
}) {
const traces: TraceEntry[] = [];
const messages: Array<{ participant: string; content: unknown; metadata?: unknown }> = [];
Expand All @@ -39,6 +40,7 @@ function makeGate(configPath: string, opts?: {
configPath,
initialConfig: opts?.initialConfig,
...(opts?.resolveRouteChannel ? { resolveRouteChannel: opts.resolveRouteChannel } : {}),
...(opts?.privilegedUsersPath ? { privilegedUsersPath: opts.privilegedUsersPath } : {}),
emitTrace: (e) => traces.push(e as TraceEntry),
addMessage: (p, c, m) => { messages.push({ participant: p, content: c, metadata: m }); return ''; },
requestInference: (a, r, s, p) => inferenceRequests.push({ agentName: a, reason: r, source: s, ...(p ?? {}) }),
Expand Down Expand Up @@ -1133,3 +1135,26 @@ describe('quiesce suppression', () => {
assert.strictEqual(gate.inferenceDiagnostics().bufferedEvents, 1);
});
});

describe('sleep', () => {
// While asleep, only the agent's own heartbeat and a privileged author
// rouse it. The heartbeat is the server the host knows as `heartbeat`: a
// `source` in the metadata is what the server says, and any server says it.
it('lets through only the server the host names heartbeat, and a privileged author', () => {
const privileged = tmpPath('privileged.json');
writeFileSync(privileged, JSON.stringify({ userIds: ['P1'] }));
const path = writeConfig('sleep.json', { policies: [], default: 'always' });
const { gate } = makeGate(path, { privilegedUsersPath: privileged });
gate.setSleep(3600, 'resting', 'agent');
try {
const roused = (info: Partial<GateEventInfo>) => gate.evaluate(event(info)).trigger;
assert.strictEqual(roused({ serverId: 'discord', metadata: {} }), false, 'an ordinary event');
assert.strictEqual(roused({ serverId: 'discord', metadata: { source: 'heartbeat' } }), false, "another server saying it's the heartbeat");
assert.strictEqual(roused({ serverId: 'heartbeat', metadata: { source: 'heartbeat' } }), true, 'the heartbeat');
assert.strictEqual(roused({ serverId: 'heartbeat', metadata: { source: 'reminder' } }), true, "the heartbeat server's reminder");
assert.strictEqual(roused({ serverId: 'discord', metadata: { author: { id: 'P1' } } }), true, 'a privileged author');
} finally {
gate.clearSleep();
}
});
});
53 changes: 53 additions & 0 deletions test/mcpl-gate-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { join } from 'node:path';

import { EventGate } from '../src/gate/event-gate.js';
import { ChannelRegistry } from '../src/mcpl/channel-registry.js';
import { PushHandler } from '../src/mcpl/push-handler.js';
import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js';
import type { GateConfig } from '../src/gate/types.js';
import type { ChannelsIncomingParams } from '../src/mcpl/types.js';
import type { ProcessEvent } from '../src/types/index.js';
Expand Down Expand Up @@ -87,6 +89,57 @@ describe('ChannelRegistry → shouldTriggerInference contract', () => {
});
});

// ---------------------------------------------------------------------------
// PushHandler: the host's own fields, whatever the server's origin says
// ---------------------------------------------------------------------------

function pushVia(shouldTriggerInference: (c: string, m: Record<string, unknown>) => boolean) {
const pushed: Array<{ triggerInference?: boolean }> = [];
const handler = new PushHandler({ validateInbound() {} } as unknown as FeatureSetManager, (event) => { pushed.push(event); }, () => {}, shouldTriggerInference);
let n = 0;
const push = async (serverId: string, origin: Record<string, unknown>) => {
await handler.handlePushEvent(serverId, {
featureSet: 'fs', eventId: `push-${++n}`, timestamp: '2026-10-20T00:00:00Z',
payload: { content: [{ type: 'text', text: 'hello' }] }, origin,
} as never, { respond: () => {}, respondError: () => {} } as never);
return pushed[pushed.length - 1]?.triggerInference;
};
return { push };
}

describe('PushHandler → shouldTriggerInference contract', () => {
it("gives the host's server id, feature set, event id and event type over the origin's, and the origin's other fields as they are", async () => {
const seen: Array<Record<string, unknown>> = [];
const { push } = pushVia((_content, metadata) => { seen.push(metadata); return true; });
await push('discord', {
source: 'discord', channelId: 'c1',
serverId: 'heartbeat', featureSet: 'other', eventId: 'other', eventType: 'mcpl:channel-incoming',
});
assert.strictEqual(seen.length, 1);
assert.strictEqual(seen[0].serverId, 'discord');
assert.strictEqual(seen[0].featureSet, 'fs');
assert.strictEqual(seen[0].eventId, 'push-1');
assert.strictEqual(seen[0].eventType, 'mcpl:push-event');
assert.strictEqual(seen[0].source, 'discord');
assert.strictEqual(seen[0].channelId, 'c1');
});

it('wakes a sleeping agent only for the server the host names heartbeat, whatever the origin says', async () => {
const gate = makeGate({ policies: [], default: 'always' });
gate.setSleep(3600, 'resting', 'agent');
try {
const { push } = pushVia(gate.asShouldTriggerCallback());
assert.strictEqual(await push('discord', {}), false, 'an ordinary push');
assert.strictEqual(await push('discord', { source: 'heartbeat' }), false, "an origin saying it's the heartbeat");
assert.strictEqual(await push('discord', { serverId: 'heartbeat' }), false, "an origin giving the heartbeat's server id");
assert.strictEqual(await push('heartbeat', { source: 'heartbeat', reason: 'schedule' }), true, 'the heartbeat');
} finally {
gate.clearSleep();
gate.dispose();
}
});
});

// ---------------------------------------------------------------------------
// Roundtrip: a recipe-shaped channel policy fires for a channel-incoming event
// ---------------------------------------------------------------------------
Expand Down
Loading