Skip to content
Closed
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ These are prescriptive rules not derivable from reading the code:

- **NEVER set `heartbeat.global` (or `fixedPod`) in `moltbot.json`.** openclaw v2026.3.7's `HeartbeatSchema` is `.strict()` and has no `global` key — emitting it fails config validation and crash-loops the gateway (`Unrecognized key: "global"`), taking the whole fleet offline (2026-06-28 incident, PR #502). The heartbeat runner already fires **once per agent** (`for (const agent of state.agents.values())`); there is no per-pod fan-out to suppress. A prior rule claimed `global:true` was required to avoid per-pod firing — that was true of an older openclaw and is now false + dangerous. `normalizeHeartbeat` in both provisioners must emit only `{every, prompt, target, session}`; the provisioner has a regression test asserting `global`/`fixedPod` never appear. **This rule is scoped to `moltbot.json` and says nothing about `AgentInstallation.config.heartbeat.global`, which is a different field on a different surface with the opposite meaning** — read only by `schedulerService.ts:848` (the entire backend footprint), where `global: true` *dedupes* an agent's per-pod schedules into one. Without it the backend enqueues one heartbeat **per (agent, instance, pod)** — so "there is no per-pod fan-out to suppress" is true of the gateway runner and false of the backend scheduler. Setting the Mongo field is supported; emitting the `moltbot.json` key is the thing that crash-loops the fleet. See AX audit entry 22.

- **`NO_REPLY` is only silent when it is the entire reply** — suppression is total-match, and nothing weaker. Appending it to normal content does NOT go silent, and (since PR #785) is no longer sent verbatim either: a **bare** sentinel token inside a substantive reply is treated as producer leakage and stripped, whitespace-preserving. A sentinel inside backticks or a code fence is a deliberate mention and survives — **backtick a sentinel to mention it.** Scope is agent-authored content only; the human path stays verbatim by design. Any new sentinel inherits both contracts at birth (total-match suppression + bare-stripped/backtick-preserved) plus a test for each. `AgentMessageService.sanitizeAgentContent`; tests in `backend/__tests__/unit/services/agentMessageService.chatNoise.test.js`.
- **`NO_REPLY` is silent when it is the entire reply, or when its first non-whitespace token is bare.** A leading bare sentinel suppresses the entire agent reply; mid-reply and trailing bare sentinels are producer leakage and are stripped while the remaining content posts. Backticked or code-fenced mentions in substantive replies survive — **backtick a sentinel to mention it.** In an `agent-dm`, either silent form also triggers the private `agent-dm-conclusion` entry for both peers, using the sender's preceding substantive message rather than the suppressed body. Scope is agent-authored content only; the human path stays verbatim by design. **Current runtime limit:** the backend enforces leading-sentinel suppression for direct API/MCP posts. The OpenClaw gateway strips that sentinel before the backend receives it, so it still strip-and-posts this case until its separate integration changes; do not treat the rule as runtime-uniform. Any new sentinel inherits all three contracts at birth (total-match suppression + leading-bare suppression + bare-stripped/backtick-preserved) plus a test for each. `AgentMessageService.sanitizeAgentContent`; tests in `backend/__tests__/unit/services/agentMessageService.chatNoise.test.js`.

- **OpenClaw config**: use global `messages.queue`, not `messages.queue.byChannel.commonly`.

Expand Down
20 changes: 20 additions & 0 deletions backend/__tests__/services/agentEnsembleService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,26 @@ describe('AgentEnsembleService', () => {
expect(updated.stats.totalMessages).toBe(initialMessages);
expect(updated.turnState.turnNumber).toBe(initialTurn + 1);
});

it('advances a leading-bare NO_REPLY turn without counting its suppressed body', async () => {
const state = await AgentEnsembleService.startDiscussion(testPod._id, {
createdBy: testUser._id,
});

const initialTurn = state.turnState.turnNumber;
const initialMessages = state.stats.totalMessages;

await AgentEnsembleService.processAgentResponse(state._id, {
agentType: state.turnState.currentAgent.agentType,
instanceId: state.turnState.currentAgent.instanceId,
content: 'NO_REPLY\nThis body is intentionally suppressed.',
messageId: 'm-leading-no-reply',
});

const updated = await AgentEnsembleState.findById(state._id);
expect(updated.stats.totalMessages).toBe(initialMessages);
expect(updated.turnState.turnNumber).toBe(initialTurn + 1);
});
});

describe('Fix 2: Scheduled discussions', () => {
Expand Down
104 changes: 98 additions & 6 deletions backend/__tests__/unit/services/agentMessageService.chatNoise.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,39 @@ describe('sanitizeAgentContent — NO_REPLY suppression and sanitization', () =>
expect(AgentMessageService.sanitizeAgentContent('```text\nNO_REPLY\n```')).toBe('');
});

it('keeps substantive replies but strips bare producer-leakage tokens', () => {
expect(AgentMessageService.sanitizeAgentContent('Shipped the fix.\nNO_REPLY'))
.toBe('Shipped the fix.');
it('suppresses substantive replies that begin with a bare sentinel', () => {
// Sam ratified TASK-067 (2026-08-26): the first non-whitespace bare
// NO_REPLY token suppresses the entire reply, including its body.
expect(AgentMessageService.sanitizeAgentContent('NO_REPLY\nHere is the real answer.'))
.toBe('');
expect(AgentMessageService.sanitizeAgentContent('\n\t NO_REPLY.\nHere is the real answer.'))
.toBe('');
// Legacy gateways can concatenate duplicate silence blocks. A run is still
// a leading sentinel, not an ordinary word that should strip-and-post.
expect(AgentMessageService.sanitizeAgentContent('NO_REPLYNO_REPLY\nHere is the real answer.'))
.toBe('');
});

it('shares the silent-turn decision with non-posting consumers', () => {
expect(AgentMessageService.isSilentNoReply('NO_REPLY')).toBe(true);
expect(AgentMessageService.isSilentNoReply('NO_REPLY\nHere is the real answer.')).toBe(true);
expect(AgentMessageService.isSilentNoReply('Reply with NO_REPLY when done.')).toBe(false);
expect(AgentMessageService.isSilentNoReply('`NO_REPLY`\nHere is the real answer.')).toBe(false);
});

it('keeps leading code-formatted sentinel mentions intact', () => {
expect(AgentMessageService.sanitizeAgentContent('`NO_REPLY`\nHere is the real answer.'))
.toBe('`NO_REPLY`\nHere is the real answer.');
expect(AgentMessageService.sanitizeAgentContent(
'```text\nNO_REPLY\n```\nHere is the real answer.',
)).toBe('```text\nNO_REPLY\n```\nHere is the real answer.');
});

it('keeps mid-reply and trailing bare sentinels as strip-and-post', () => {
expect(AgentMessageService.sanitizeAgentContent('Reply with NO_REPLY when done.'))
.toBe('Reply with when done.');
expect(AgentMessageService.sanitizeAgentContent('Shipped the fix.\nNO_REPLY'))
.toBe('Shipped the fix.');
});

it('preserves code-formatted sentinel mentions', () => {
Expand Down Expand Up @@ -103,6 +131,10 @@ describe('AgentMessageService.sanitizeAgentContent — strip observability', ()
.map(([first]) => String(first))
.filter((line) => line.includes('stripped bare sentinel'));

const leadingSuppressionWarnings = () => warn.mock.calls
.map(([first]) => String(first))
.filter((line) => line.includes('suppressed substantive reply with leading bare sentinel'));

const OBSERVE = { agentName: 'openclaw', instanceId: 'nova', podId: 'pod123' };

it('warns when a bare sentinel is edited out of a substantive reply', () => {
Expand All @@ -115,13 +147,16 @@ describe('AgentMessageService.sanitizeAgentContent — strip observability', ()
expect(stripWarnings()).toHaveLength(1);
});

it('warns on a LEADING bare sentinel — the AX-43 leak shape', () => {
it('does not count a leading-bare suppression as a strip edit', () => {
// Sam ratified TASK-067 (2026-08-26). Leading-bare NO_REPLY is now a
// suppression with its own warning, not the strip-and-post edit #1252
// measures. Keeping the metrics separate prevents false strip rates.
const out = AgentMessageService.sanitizeAgentContent(
'NO_REPLY\nHere is the real answer.',
OBSERVE,
);
expect(out).not.toBe('');
expect(stripWarnings()).toHaveLength(1);
expect(out).toBe('');
expect(stripWarnings()).toHaveLength(0);
});

it('stays silent when the sentinel IS the whole reply — suppression, not an edit', () => {
Expand Down Expand Up @@ -191,4 +226,61 @@ describe('AgentMessageService.sanitizeAgentContent — strip observability', ()
expect(line).toContain('instance=nova');
expect(line).toContain('pod=pod123');
});

it('logs the identity and original excerpt when suppressing a leading bare sentinel', () => {
const input = 'NO_REPLY\nHere is the real answer.';
expect(AgentMessageService.sanitizeAgentContent(input, OBSERVE)).toBe('');
expect(leadingSuppressionWarnings()).toHaveLength(1);
const [line] = leadingSuppressionWarnings();
expect(line).toContain('agent=openclaw');
expect(line).toContain('instance=nova');
expect(line).toContain('pod=pod123');
expect(line).toContain(input);
});

it('retains a substantial audit excerpt for a suppressed authored turn', () => {
// The legacy 120-character cap suits reproducible runtime diagnostics. A
// direct API/MCP/CLI post suppressed here has no persisted turn body, so
// retain materially more of it for the audit record.
const body = 'x'.repeat(2048);
const input = `NO_REPLY\n${body}`;
expect(AgentMessageService.sanitizeAgentContent(input, OBSERVE)).toBe('');
const [line] = leadingSuppressionWarnings();
expect(line).toContain(body);
});
});

describe('AgentMessageService.postMessage — silent agent-DM conclusion hook', () => {
let warn;

beforeEach(() => {
warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warn.mockRestore();
jest.restoreAllMocks();
});

it('starts the conclusion-memory flow for a leading bare sentinel', async () => {
// TASK-067's ratified suppression is a silence, so ADR-012 §4 records
// the existing prior-turn takeaway for both peers in an agent-DM. The
// suppressed body itself is intentionally never persisted.
const conclusion = jest.spyOn(AgentMessageService, 'maybeRecordAgentDmConclusion')
.mockResolvedValue(undefined);

const result = await AgentMessageService.postMessage({
agentName: 'nova',
instanceId: 'default',
podId: '507f1f77bcf86cd799439011',
content: 'NO_REPLY\nThis body is intentionally suppressed.',
});

expect(result).toEqual({ success: true, skipped: true, reason: 'silent_or_empty' });
expect(conclusion).toHaveBeenCalledWith({
podId: '507f1f77bcf86cd799439011',
senderAgentName: 'nova',
senderInstanceId: 'default',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// @ts-nocheck
// TASK-067: the conclusion-memory scan must use the same leading-bare
// NO_REPLY semantics as postMessage. This test keeps that cross-module
// contract executable without standing up Postgres.

const mockPool = { query: jest.fn() };

jest.mock('../../../config/db-pg', () => ({ pool: mockPool }));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/User', () => ({
find: jest.fn(),
findOne: jest.fn(),
}));
jest.mock('../../../services/agentMemoryService', () => ({
appendSystemExchange: jest.fn().mockResolvedValue({ revision: 1 }),
truncateTakeaway: (value) => String(value).slice(0, 280),
}));

const Pod = require('../../../models/Pod');
const User = require('../../../models/User');
const { appendSystemExchange } = require('../../../services/agentMemoryService');
const { recordAgentDmConclusion } = require('../../../services/systemExchangeTriggers');

const POD_ID = '507f1f77bcf86cd799439011';
const NOVA_USER_ID = '507f191e810c19729de860ea';
const PIXEL_USER_ID = '507f191e810c19729de860eb';

function queryResult(value) {
return {
select: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue(value),
};
}

describe('recordAgentDmConclusion — leading-bare NO_REPLY history', () => {
beforeEach(() => {
jest.clearAllMocks();
Pod.findById
.mockReturnValueOnce(queryResult({ type: 'agent-dm' }))
.mockReturnValueOnce(queryResult({
type: 'agent-dm',
name: 'Nova and Pixel',
members: [NOVA_USER_ID, PIXEL_USER_ID],
}));
User.find.mockReturnValue(queryResult([
{ username: 'nova', botMetadata: { agentName: 'openclaw', instanceId: 'nova' } },
{ username: 'pixel', botMetadata: { agentName: 'openclaw', instanceId: 'pixel' } },
]));
User.findOne.mockReturnValue(queryResult({ _id: NOVA_USER_ID }));
});

it('skips a stored leading-bare reply before recording the sender’s prior substantive takeaway', async () => {
mockPool.query.mockResolvedValue({
rows: [
{ content: 'NO_REPLY\nThis suppressed body must not become the takeaway.' },
{ content: 'The auth patch is ready for the final gate.' },
],
});

await recordAgentDmConclusion({
podId: POD_ID,
senderAgentName: 'openclaw',
senderInstanceId: 'nova',
ts: new Date('2026-08-26T08:30:00Z'),
});

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE pod_id = $1 AND user_id = $2'),
[POD_ID, NOVA_USER_ID],
);
expect(appendSystemExchange).toHaveBeenCalledWith(expect.objectContaining({
agentName: 'openclaw',
instanceId: 'nova',
takeaway: 'The auth patch is ready for the final gate.',
}));
expect(appendSystemExchange).toHaveBeenCalledWith(expect.objectContaining({
agentName: 'openclaw',
instanceId: 'pixel',
takeaway: '@nova: The auth patch is ready for the final gate.',
}));
});
});
5 changes: 3 additions & 2 deletions backend/services/agentEnsembleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const AgentEventService = require('./agentEventService');
// eslint-disable-next-line global-require
const AgentIdentityService = require('./agentIdentityService');
// eslint-disable-next-line global-require
const AgentMessageService = require('./agentMessageService');
// eslint-disable-next-line global-require
const AgentProfile = require('../models/AgentProfile');
// eslint-disable-next-line global-require
const Pod = require('../models/Pod');
Expand Down Expand Up @@ -335,8 +337,7 @@ class AgentEnsembleService {
return state;
}

const normalizedContent = (response.content || '').trim();
const isNoReply = normalizedContent === 'NO_REPLY';
const isNoReply = AgentMessageService.isSilentNoReply(response.content);

state.stats.lastActivityAt = new Date();
turnState.waitingForResponse = false;
Expand Down
Loading
Loading