diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 21492de21..4653e7775 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -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 diff --git a/cli/__tests__/enforcement.test.mjs b/cli/__tests__/enforcement.test.mjs index 5493bf144..dc0c89ff2 100644 --- a/cli/__tests__/enforcement.test.mjs +++ b/cli/__tests__/enforcement.test.mjs @@ -18,6 +18,7 @@ import { CASCADE_DEFAULTS, CASCADE_ENV_VARS, CLAIMABLE_EVENT_TYPES, + MENTION_EVENT_TYPES, classifyTrigger, createCascadeGovernor, createClaimHandicap, @@ -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); @@ -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); @@ -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, }); @@ -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', () => { diff --git a/cli/__tests__/mention-event-types.contract.test.mjs b/cli/__tests__/mention-event-types.contract.test.mjs new file mode 100644 index 000000000..66107aeec --- /dev/null +++ b/cli/__tests__/mention-event-types.contract.test.mjs @@ -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); + }); +}); diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 58cb2fdfb..8ccce1453 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 @@ -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 }, diff --git a/cli/package.json b/cli/package.json index f92dbd749..9b663a856 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 901d56dc0..224243870 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -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, diff --git a/cli/src/lib/enforcement.js b/cli/src/lib/enforcement.js index a6f73ec6b..a916f44a2 100644 --- a/cli/src/lib/enforcement.js +++ b/cli/src/lib/enforcement.js @@ -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({ @@ -213,9 +226,24 @@ 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 @@ -223,11 +251,9 @@ export const createCascadeGovernor = ({ // 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