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: 1 addition & 1 deletion backend/services/agentMentionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const MENTION_LOOP_MAX = 3; // #508 dampener — >3 mentions to same bot/pod/win
// what a chat mention costs. Splitting the budget would also hand an
// alternating loop double the allowance for free — mention in chat, mention
// in a thread, repeat, each counter sitting at half the threshold forever.
const MENTION_EVENT_TYPES = ['chat.mention', 'thread.mention'] as const;
export const MENTION_EVENT_TYPES = ['chat.mention', 'thread.mention'] as const;

/**
* Mention Aliases
Expand Down
65 changes: 50 additions & 15 deletions cli/__tests__/enforcement.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
CASCADE_DEFAULTS,
CASCADE_ENV_VARS,
CLAIMABLE_EVENT_TYPES,
MENTION_EVENT_TYPES,
classifyTrigger,
createCascadeGovernor,
createClaimHandicap,
Expand Down Expand Up @@ -183,34 +184,61 @@ describe('cascade governor — addressed grace', () => {
expect(gov.admit('pod', 'agent', 'message.posted').addressed).toBe(false);
});

it('does not report a grace it never granted, at addressedGrace 0', () => {
it('does not report a grace it never granted to a legacy direct-address event, at addressedGrace 0', () => {
// The refusal log is the one line an operator reads to understand why a
// seat went quiet. Keyed on `addressed` alone it announced "(addressed
// grace also spent)" on a grace=0 seat — asserting a grace that does not
// exist, three screens under a boot line that says grace=0. The event is
// still addressed; nothing was granted for it.
// still addressed; nothing was granted for it. Agent-DM wakes use
// chat.mention, but the legacy dm.message vocabulary remains supported.
const gov = createCascadeGovernor({ cap: 1, addressedGrace: 0 });
const admission = gov.admit('pod', 'agent', 'chat.mention');
const admission = gov.admit('pod', 'agent', 'dm.message');
expect(admission.addressed).toBe(true);
expect(admission.graceApplied).toBe(false);
// And the limit really is the plain cap — the message was the only defect.
gov.record('pod', 'agent');
expect(gov.admit('pod', 'agent', 'chat.mention').allowed).toBe(false);
expect(gov.admit('pod', 'agent', 'dm.message').allowed).toBe(false);
});

it('is a grace, not an exemption — a mention echo still terminates', () => {
// The regression this guards: an unbounded pass for addressed events
// restores the A-mentions-B-mentions-A loop the governor exists to kill.
const gov = createCascadeGovernor({ cap: 3, addressedGrace: 2 });
burn(gov, 5, 'chat.mention');
it('exempts only kernel-dampened non-DM mentions; DM-backed mentions stay in the streak', () => {
const gov = createCascadeGovernor({ cap: 1, addressedGrace: 0 });
gov.record('pod', 'agent');

// The kernel's bot-to-bot dampener owns these two types. A named seat must
// stay reachable after broadcasts fill this local budget.
expect(gov.admit('pod', 'agent', 'chat.mention').allowed).toBe(true);
expect(gov.admit('pod', 'agent', 'thread.mention').allowed).toBe(true);

// Agent DMs also use chat.mention, but dmKind identifies the other
// producer. They must remain locally bounded; otherwise a type-only
// exemption opens a second unbounded bot-to-bot loop.
expect(gov.admit('pod', 'agent', 'chat.mention', { dmKind: 'agent-agent' }).allowed).toBe(false);

// dm.message is legacy direct-address vocabulary, not in the kernel
// mention budget, so it remains bounded locally. Broadcasts do too.
expect(gov.admit('pod', 'agent', 'dm.message').allowed).toBe(false);
expect(gov.admit('pod', 'agent', 'message.posted').allowed).toBe(false);
});

expect(gov.admit('pod', 'agent', 'chat.mention').allowed).toBe(false);
it('records a completed mention, so it spends broadcast liveness', () => {
const gov = createCascadeGovernor({ cap: 1, addressedGrace: 0 });
gov.record('pod', 'agent');

expect(gov.admit('pod', 'agent', 'chat.mention').allowed).toBe(true);
// Mirrors agent.js: exemption only changes admit(); a completed turn still
// reaches record(). The following broadcast remains capped at the new
// streak rather than acquiring an extra unmetered pass.
gov.record('pod', 'agent');
expect(gov.admit('pod', 'agent', 'message.posted')).toMatchObject({
allowed: false,
streak: 2,
});
});

it('a human turn still clears the streak for addressed and broadcast alike', () => {
const gov = createCascadeGovernor({ cap: 1, addressedGrace: 1 });
burn(gov, 2, 'chat.mention');
expect(gov.admit('pod', 'agent', 'chat.mention').allowed).toBe(false);
burn(gov, 2, 'dm.message');
expect(gov.admit('pod', 'agent', 'dm.message').allowed).toBe(false);

gov.record('pod', 'human');
expect(gov.admit('pod', 'agent', 'message.posted').allowed).toBe(true);
Expand Down Expand Up @@ -534,6 +562,13 @@ describe('peerHoldsFrame / ADDRESSED_EVENT_TYPES', () => {
});
});

describe('MENTION_EVENT_TYPES', () => {
test('contains only the two producer-dampened mention types, not DMs', () => {
expect([...MENTION_EVENT_TYPES]).toEqual(['chat.mention', 'thread.mention']);
expect(MENTION_EVENT_TYPES.has('dm.message')).toBe(false);
});
});

describe('CLAIMABLE_EVENT_TYPES', () => {
test('covers message-bearing wakes and excludes one-shot / private events', () => {
expect(CLAIMABLE_EVENT_TYPES.has('chat.mention')).toBe(true);
Expand Down Expand Up @@ -577,8 +612,8 @@ describe('resolveCascadeSettings', () => {
});

test('zero is honoured, not treated as absent', () => {
// The whole point of the grace knob: 0 restores pre-#973 behaviour without
// a revert. A `|| default` style resolver would silently ignore it.
// Legacy direct-address events remain on the configurable grace path. A
// `|| default` style resolver would silently ignore 0 and admit one more.
const settings = resolveCascadeSettings({
env: { [CASCADE_ENV_VARS.addressedGrace]: '0' }, warn: silent,
});
Expand All @@ -587,7 +622,7 @@ describe('resolveCascadeSettings', () => {
governor.record('pod', 'agent');
governor.record('pod', 'agent');
governor.record('pod', 'agent');
expect(governor.admit('pod', 'agent', 'chat.mention').allowed).toBe(false);
expect(governor.admit('pod', 'agent', 'dm.message').allowed).toBe(false);
});

test('a garbage override falls back and warns, exactly like a garbage env var', () => {
Expand Down
44 changes: 44 additions & 0 deletions cli/__tests__/mention-event-types.contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* The backend and published CLI cannot share a runtime import, but their loop
* budgets must start from the same event population. The wrapper then keeps
* DM-backed chat.mention events on its local bounded path via payload.dmKind;
* import both modules here instead of relying on two comments to keep the
* mirrored base lists aligned.
*/
import { execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { pathToFileURL } from 'url';

const here = dirname(fileURLToPath(import.meta.url));
const backendRoot = join(here, '..', '..', 'backend');
const backendPackage = join(backendRoot, 'package.json');
const backendService = join(backendRoot, 'services', 'agentMentionService.ts');
const cliEnforcement = pathToFileURL(join(here, '..', 'src', 'lib', 'enforcement.js')).href;

const loadMentionEventTypes = () => {
// Jest owns require.extensions and cannot execute backend TypeScript through
// ts-node in-process. A plain Node child has no such interception, so this
// imports the real backend service and the real CLI module rather than
// comparing a duplicated fixture or parsing source text.
const script = `
import { createRequire } from 'module';
const requireBackend = createRequire(${JSON.stringify(backendPackage)});
requireBackend('ts-node/register/transpile-only');
const { MENTION_EVENT_TYPES: backendTypes } = requireBackend(${JSON.stringify(backendService)});
const { MENTION_EVENT_TYPES: cliTypes } = await import(${JSON.stringify(cliEnforcement)});
console.log(JSON.stringify({ backend: [...backendTypes], cli: [...cliTypes] }));
`;
const output = execFileSync(process.execPath, ['--input-type=module', '--eval', script], {
encoding: 'utf8',
env: { ...process.env, TS_NODE_PROJECT: join(backendRoot, 'tsconfig.json') },
});
return JSON.parse(output.trim().split('\n').at(-1));
};

describe('mention dampener ↔ wrapper cascade contract', () => {
test('the wrapper starts its non-DM exemption from the kernel event types', () => {
const { backend, cli } = loadMentionEventTypes();
expect(cli).toEqual(backend);
});
});
79 changes: 58 additions & 21 deletions cli/__tests__/run-loop.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1598,22 +1598,20 @@ describe('performRun — ADR-018 enforcement', () => {
expect(post.mock.calls.some(([r]) => r.endsWith('/claim'))).toBe(false);
});

test('cascade cap: agent-triggered turns beyond the cap are declined without a spawn or a claim', async () => {
test('cascade cap: agent-DM chat.mentions beyond the cap are declined without a spawn or a claim', async () => {
const { post } = makeClient({
events: [
makeClaimEvent({ _id: 'evt-a' }),
makeClaimEvent({ _id: 'evt-b', payload: { content: 'again', messageId: 'msg-2' } }),
makeClaimEvent({ _id: 'evt-a', payload: { content: 'hello', messageId: 'msg-1', dmKind: 'agent-agent' } }),
makeClaimEvent({ _id: 'evt-b', payload: { content: 'again', messageId: 'msg-2', dmKind: 'agent-agent' } }),
],
// Both trigger messages are BOT-authored — this is a mention cascade.
// Both are agent-DM events, which use chat.mention but carry dmKind.
messages: [
{ _id: 'msg-1', isBot: true, self: false },
{ _id: 'msg-2', isBot: true, self: false },
],
});
const spawn = jest.fn(async () => ({ text: 'NO_REPLY' }));
// grace 0 pins the PURE cap contract. makeEvent defaults to chat.mention,
// which now carries an addressed grace — without this the test would be
// silently exercising the grace path instead of the cap it names.
// DM-backed chat.mentions stay locally bounded when grace is disabled.
const { stop } = run(
{ name: 'stub', detect: stubAdapter.detect, spawn },
{ cascadeCap: 1, cascadeAddressedGrace: 0 },
Expand Down Expand Up @@ -1659,8 +1657,8 @@ describe('performRun — ADR-018 enforcement', () => {
try {
const { post } = makeClient({
events: [
makeClaimEvent({ _id: 'evt-a' }),
makeClaimEvent({ _id: 'evt-b', payload: { content: 'again', messageId: 'msg-2' } }),
makeClaimEvent({ _id: 'evt-a', type: 'dm.message' }),
makeClaimEvent({ _id: 'evt-b', type: 'dm.message', payload: { content: 'again', messageId: 'msg-2' } }),
],
messages: [
{ _id: 'msg-1', isBot: true, self: false },
Expand Down Expand Up @@ -1699,18 +1697,14 @@ describe('performRun — ADR-018 enforcement', () => {
}
});

test('cascade cap: a directly-addressed seat gets a bounded grace, then is capped too', async () => {
// The failure this fixes, measured 2026-08-18: a seat took 28 consecutive
// cap refusals, five of them chat.mention. Peers named it and got silence.
//
// The grace is bounded on purpose. An unbounded pass for addressed events
// restores the A-mentions-B-mentions-A echo the governor exists to kill, so
// the third event below must still be declined.
test('cascade cap: a legacy direct-address event gets a bounded grace, then is capped too', async () => {
// Mention types are producer-dampened and exempted separately. Legacy
// direct-address vocabulary remains on the local bounded-grace path.
const { post } = makeClient({
events: [
makeClaimEvent({ _id: 'evt-a' }),
makeClaimEvent({ _id: 'evt-b', payload: { content: 'again', messageId: 'msg-2' } }),
makeClaimEvent({ _id: 'evt-c', payload: { content: 'and again', messageId: 'msg-3' } }),
makeClaimEvent({ _id: 'evt-a', type: 'dm.message' }),
makeClaimEvent({ _id: 'evt-b', type: 'dm.message', payload: { content: 'again', messageId: 'msg-2' } }),
makeClaimEvent({ _id: 'evt-c', type: 'dm.message', payload: { content: 'and again', messageId: 'msg-3' } }),
],
messages: [
{ _id: 'msg-1', isBot: true, self: false },
Expand Down Expand Up @@ -1748,6 +1742,49 @@ describe('performRun — ADR-018 enforcement', () => {
);
});

test('cascade: a kernel-dampened mention bypasses the cap but still spends broadcast liveness', async () => {
const { post } = makeClient({
events: [
// First broadcast fills the local cap without entering a claim race.
makeEvent({
_id: 'evt-broadcast-before',
type: 'message.posted',
payload: { content: 'ambient work', dmKind: 'agent-agent' },
}),
// The named bot-to-bot mention remains live even though the cap is
// full. Its completed turn must still record before the next wake.
makeClaimEvent({
_id: 'evt-mentioned',
payload: { content: '@my-stub please weigh in', messageId: 'msg-mentioned' },
}),
makeEvent({
_id: 'evt-broadcast-after',
type: 'message.posted',
payload: { content: 'ambient work again', dmKind: 'agent-agent' },
}),
],
messages: [{ _id: 'msg-mentioned', isBot: true, self: false }],
});
const spawn = jest.fn(async () => ({ text: 'NO_REPLY' }));
const { stop } = run(
{ name: 'stub', detect: stubAdapter.detect, spawn },
{ cascadeCap: 1, cascadeAddressedGrace: 0 },
);
await drainMicrotasks();
stop();

// The mention is the second spawn. Before the exemption it was refused at
// the cap; without the completion-time record the final broadcast would
// instead be admitted.
expect(spawn).toHaveBeenCalledTimes(2);
expect(post).toHaveBeenCalledWith(
'/api/agents/runtime/events/evt-broadcast-after/ack',
expect.objectContaining({
result: expect.objectContaining({ outcome: 'no_action', reason: 'cascade-cap' }),
}),
);
});

test('losing a claim on a HUMAN message still resets the cascade streak', async () => {
// Regression for the starvation bug: `record()` lives at the end of
// runTurn, and the claim stand-down returns before runTurn — so the seat
Expand Down Expand Up @@ -1803,8 +1840,8 @@ describe('performRun — ADR-018 enforcement', () => {
// human trying to explain a silent seat.
const { post } = makeClient({
events: [
makeClaimEvent({ _id: 'evt-a' }),
makeClaimEvent({ _id: 'evt-b', payload: { content: 'again', messageId: 'msg-2' } }),
makeClaimEvent({ _id: 'evt-a', type: 'dm.message' }),
makeClaimEvent({ _id: 'evt-b', type: 'dm.message', payload: { content: 'again', messageId: 'msg-2' } }),
],
messages: [
{ _id: 'msg-1', isBot: true, self: false },
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@commonlyai/cli",
"version": "0.1.13",
"version": "0.1.14",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ export const performRun = ({
// Recording a human trigger sets the streak to 0, so this call and the
// completion-time one are idempotent with each other.
if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
const admission = cascadeGovernor.admit(eventPodId, trigger, event.type, event.payload);
if (!admission.allowed) {
// Name the MESSAGE, not just the pod. Without this the refusal is silent at
// three ends, not two: the mentioning agent gets no signal its mention died,
Expand Down
38 changes: 32 additions & 6 deletions cli/src/lib/enforcement.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ export const classifyTrigger = (event, recentMessages) => {
// race is a free stand-down.
export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);

// These are the two event types the kernel's bot-to-bot mention dampener
// bounds as one shared budget. Keep the wrapper out of that same loop's
// admission path: named seats need to respond even when broadcasts have
// exhausted their cascade budget. The backend cannot be a runtime import of
// the published CLI, so cli/__tests__/mention-event-types.contract.test.mjs
// imports both modules and pins the two lists together.
//
// Agent-DM wakes also arrive as chat.mention, but carry payload.dmKind. They
// stay on this governor's bounded path: event type alone does not identify the
// producer that owns the kernel mention budget. dm.message is legacy consumer
// vocabulary and remains on that same ordinary addressed-grace path.
export const MENTION_EVENT_TYPES = new Set(['chat.mention', 'thread.mention']);

// ── cascade governor ────────────────────────────────────────────────────────

export const CASCADE_DEFAULTS = Object.freeze({
Expand Down Expand Up @@ -213,21 +226,34 @@ export const createCascadeGovernor = ({
};

return {
admit(podId, trigger, eventType) {
admit(podId, trigger, eventType, payload = {}) {
if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
const s = stateFor(podId);
const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
if (MENTION_EVENT_TYPES.has(eventType) && !payload?.dmKind) {
// Named mentions are bounded upstream by the kernel's shared
// bot-to-bot mention dampener. A DM emits chat.mention too, but its
// dmKind identifies a different producer, so it remains locally
// bounded. Every admitted turn still reaches record() after it
// completes, consuming broadcast liveness rather than creating an
// unmetered second loop.
return {
allowed: true,
streak: s.streak,
addressed,
graceApplied: false,
};
}
// Being NAMED outranks a mechanical brake — the same judgement the claim
// path already makes forty lines down in agent.js. Without this, a peer
// can @mention a capped seat and get silence, with no signal to either
// side that anything was suppressed. Observed 2026-08-18: one seat took
// 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
// and answered none of them.
//
// A GRACE, not an exemption: an unbounded pass would restore the exact
// A-mentions-B-mentions-A echo this governor exists to kill. Addressed
// turns still count toward the streak, so a mention loop terminates at
// cap + addressedGrace instead of never.
const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
// Legacy direct-address events retain a GRACE, not an exemption. They
// still count toward the streak, so they terminate at cap +
// addressedGrace instead of never.
const limit = addressed ? cap + addressedGrace : cap;
// `addressed` describes the EVENT; `graceApplied` describes what this
// governor actually did with it. They diverge whenever addressedGrace is
Expand Down
Loading