diff --git a/CLAUDE.md b/CLAUDE.md index 989a2f0ec..43eca5a77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/backend/__tests__/services/agentEnsembleService.test.js b/backend/__tests__/services/agentEnsembleService.test.js index 4f4e9ac91..51baee74c 100644 --- a/backend/__tests__/services/agentEnsembleService.test.js +++ b/backend/__tests__/services/agentEnsembleService.test.js @@ -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', () => { diff --git a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index bbbf32d98..14dfe28f9 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', + }); + }); }); diff --git a/backend/__tests__/unit/services/systemExchangeTriggers.leadingNoReply.test.ts b/backend/__tests__/unit/services/systemExchangeTriggers.leadingNoReply.test.ts new file mode 100644 index 000000000..7322aa3c0 --- /dev/null +++ b/backend/__tests__/unit/services/systemExchangeTriggers.leadingNoReply.test.ts @@ -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.', + })); + }); +}); diff --git a/backend/services/agentEnsembleService.ts b/backend/services/agentEnsembleService.ts index c258d2c2a..5d0062e1b 100644 --- a/backend/services/agentEnsembleService.ts +++ b/backend/services/agentEnsembleService.ts @@ -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'); @@ -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; diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 48947f17d..1b3714948 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -53,6 +53,17 @@ const RECENT_ATTACH_WINDOW_MS = 5 * 60 * 1000; // pattern it feeds, which matches at the start of a statement. const ATTACH_CLAIM_SCAN_LIMIT = 2000; +// A leading bare NO_REPLY suppresses an authored reply before it is stored. +// Native runtime turns retain their untruncated text in AgentRun before this +// service is called. Other HTTP-posting clients (direct API, MCP, CLI wrappers, +// and cloud-codex) have no equivalent durable turn record, so this audit line +// is their only copy of a suppressed turn. Keep a useful bounded excerpt for +// that population rather than reusing the 120-character cap for reproducible +// runtime-failure diagnostics. OpenClaw currently strips the sentinel upstream, +// so its posts do not reach this branch; if that gateway adopts this rule, it +// joins the same no-record population. +const LEADING_NO_REPLY_LOG_EXCERPT_LIMIT = 4096; + // Runtime artifacts are exact values, not a language heuristic. Short, // all-caps agent replies regularly carry valid protocol or markup identifiers; // add an entry here only after observing it as a wrapper artifact in production. @@ -1122,11 +1133,12 @@ class AgentMessageService { } if (!sanitizedContent) { - // ADR-012 §4: agent-dm-conclusion trigger. The reply is silent - // (NO_REPLY swallowed by sanitizeAgentContent). If the pod is an - // agent-dm, both peers get a system_exchanges entry whose takeaway is - // the SENDER's preceding non-NO_REPLY message. Fire-and-forget — never - // delays the silent return; failures are swallowed inside the helper. + // ADR-012 §4: agent-dm-conclusion trigger. A total-match NO_REPLY or a + // ratified leading-bare NO_REPLY is silent under sanitizeAgentContent. + // If the pod is an agent-dm, both peers get a system_exchanges entry + // whose takeaway is the SENDER's preceding substantive message. The + // suppressed content is intentionally not persisted. Fire-and-forget — + // never delays the silent return; failures are swallowed inside the helper. void AgentMessageService.maybeRecordAgentDmConclusion({ podId: String(podId), senderAgentName: agentName, @@ -1749,6 +1761,45 @@ class AgentMessageService { return BARE_RUNTIME_ARTIFACTS.has(String(content)); } + /** + * Whether the first non-whitespace token is a bare NO_REPLY sentinel. + * Code-fenced content is an explicit mention, not a silence. Inline code + * starts with a backtick, so it never reaches the bare-token match. + */ + static hasLeadingBareNoReply(content: unknown): boolean { + if (content === null || content === undefined) return false; + const raw = String(content); + const outerFence = raw.match(/^```[^\n]*\n([\s\S]*?)```\s*$/s); + if (outerFence) return false; + + const trimmed = raw.trim(); + const sentinel = 'NO_REPLY'; + let sentinelEnd = 0; + while (trimmed.startsWith(sentinel, sentinelEnd)) { + sentinelEnd += sentinel.length; + } + const nextCharacter = trimmed[sentinelEnd]; + const nextIsWordCharacter = nextCharacter !== undefined + && /[A-Za-z0-9_]/.test(nextCharacter); + return sentinelEnd > 0 && !nextIsWordCharacter; + } + + /** + * The sentinel forms that represent a silent agent reply across consumers. + * Keep this separate from sanitizeAgentContent: callers that only need the + * turn-policy decision must not rely on the sanitizer's strip-and-post work. + */ + static isSilentNoReply(content: unknown): boolean { + if (content === null || content === undefined) return false; + const raw = String(content); + const outerFence = raw.match(/^```[^\n]*\n([\s\S]*?)```\s*$/s); + const stripped = outerFence ? outerFence[1] : raw; + const trimmed = stripped.trim(); + + if (/^(?:NO_REPLY\s*)+$/.test(trimmed)) return true; + return !outerFence && AgentMessageService.hasLeadingBareNoReply(raw); + } + /** * `observe` is opt-in and carries the identity for the edit-warning below. * It is deliberately NOT a default: this function is also used as a @@ -1770,10 +1821,9 @@ class AgentMessageService { const stripped = outerFence ? outerFence[1] : raw; const trimmed = stripped.trim(); - // Sentinels are total-match contracts: suppress only when the complete - // reply consists of NO_REPLY tokens. Gateways have historically joined - // silent blocks into "NO_REPLYNO_REPLY" (or separated duplicates with - // whitespace), so retain that compatibility. + // A reply made only of NO_REPLY tokens is always silent. Gateways have + // historically joined silent blocks into "NO_REPLYNO_REPLY" (or separated + // duplicates with whitespace), so retain that compatibility. if (/^(?:NO_REPLY\s*)+$/.test(trimmed)) return ''; // A substantive fully fenced reply is explicitly code-formatted even @@ -1834,6 +1884,20 @@ class AgentMessageService { ) ); const sentinel = 'NO_REPLY'; + + // TASK-067 — Sam ratified leading bare NO_REPLY as a full suppression. + // Check after the code-format guards above: a leading `NO_REPLY` or fenced + // mention is deliberate content, while a bare token followed by prose is + // an intended silence that must not leak the prose into the pod. + if (AgentMessageService.hasLeadingBareNoReply(trimmed)) { + if (observe) { + console.warn( + `[agent-msg] suppressed substantive reply with leading bare sentinel from agent=${observe.agentName} instance=${observe.instanceId} pod=${observe.podId}: ${trimmed.slice(0, LEADING_NO_REPLY_LOG_EXCERPT_LIMIT)}`, + ); + } + return ''; + } + let cleaned = ''; let cursor = 0; let rangeIndex = 0; diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index c088e6b2a..89ccbf404 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -437,9 +437,11 @@ naturally well before the guard trips. ### Use of NO_REPLY -\`NO_REPLY\` only suppresses output when it is your *entire* -reply. Do not append it to normal text — it will be sent -verbatim and the user will see it. +\`NO_REPLY\` is silent only when it is your *entire* reply. Never +begin substantive prose with a bare \`NO_REPLY\`: direct API/MCP +paths suppress the whole reply, and this gateway's temporary +strip-and-post handling still will not preserve the token. Backtick +\`NO_REPLY\` when you need to mention it in normal text. `; const ensureWorkspaceSoulFile = async (accountId: any, content: any, { gateway } : any = {}) => { diff --git a/backend/services/systemExchangeTriggers.ts b/backend/services/systemExchangeTriggers.ts index e5b16775c..38d242985 100644 --- a/backend/services/systemExchangeTriggers.ts +++ b/backend/services/systemExchangeTriggers.ts @@ -103,11 +103,12 @@ interface RecordAgentDmConclusionArgs { ts?: Date; } -// Look up the most recent non-NO_REPLY message from a specific user in a pod. +// Look up the most recent message sanitizeAgentContent considers substantive +// from a specific user in a pod. // PG-first (matches how the bot-loop guard reads message history); falls back // silently if PG is unavailable, with the takeaway degrading to the kind-only -// literal. NO_REPLY-only messages and bare empty strings are skipped — we want -// the last *substantive* turn from this sender. +// literal. Total-match and leading-bare NO_REPLY replies, plus bare empty +// strings, are skipped — we want the last substantive turn from this sender. // // Filters at the SQL level by user_id so a noisy DM with frequent cross-talk // doesn't push the sender's prior substantive turn outside the scan window. @@ -123,8 +124,8 @@ async function findPreviousNonSilentMessage(podId: string, senderUserId: string) if (!pool || typeof pool.query !== 'function') return null; // user_id-scoped scan, most-recent-first; 20 rows is generous for "most // recent substantive turn from THIS sender" since irrelevant turns are - // already excluded by the WHERE clause. A pure NO_REPLY row collapses to - // empty after stripping, so we keep iterating in JS. + // already excluded by the WHERE clause. A reply that sanitizeAgentContent + // treats as silent collapses to empty, so we keep iterating in JS. const result = await pool.query( `SELECT content FROM messages WHERE pod_id = $1 AND user_id = $2 @@ -138,19 +139,16 @@ async function findPreviousNonSilentMessage(podId: string, senderUserId: string) // sync with the swallow logic in postMessage. // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const AMS = require('./agentMessageService') as { - AgentMessageService?: { sanitizeAgentContent?: (s: unknown) => string }; - default?: { sanitizeAgentContent?: (s: unknown) => string }; + sanitizeAgentContent?: (s: unknown) => string; }; - const sanitize = ( - AMS.AgentMessageService?.sanitizeAgentContent - ?? AMS.default?.sanitizeAgentContent - ); + const sanitize = AMS.sanitizeAgentContent; + // The CommonJS module exports the service class directly. If an isolated + // caller replaces it without this predicate, do not invent a third + // sentinel matcher and accidentally record a false takeaway. + if (typeof sanitize !== 'function') return null; for (const m of result.rows) { const raw = typeof m?.content === 'string' ? (m.content as string) : String(m?.content ?? ''); - const trimmed = raw.trim(); - const cleaned = typeof sanitize === 'function' - ? sanitize(raw) - : (/^(?:NO_REPLY\s*)+$/.test(trimmed) ? '' : trimmed); + const cleaned = sanitize(raw); if (cleaned) return cleaned; } return null; @@ -160,10 +158,10 @@ async function findPreviousNonSilentMessage(podId: string, senderUserId: string) } } -// ADR-012 §4: agent-dm-conclusion — fired when an agent's reply is NO_REPLY -// (the entire reply) in an agent-dm pod. Both peers' memory envelopes get -// the entry — pixel reads pixel's record, codex reads codex's. Same event, -// two private records (ADR-012 §6). +// ADR-012 §4: agent-dm-conclusion — fired when sanitizeAgentContent suppresses +// an agent reply (a total-match or leading-bare NO_REPLY) in an agent-dm pod. +// Both peers' memory envelopes get the entry — pixel reads pixel's record, +// codex reads codex's. Same event, two private records (ADR-012 §6). // // Listener vs speaker disambiguation: the speaker's takeaway is the verbatim // prior content. The listener's takeaway is prefixed with `@:` so that diff --git a/cli/skills/commonly/SKILL.md b/cli/skills/commonly/SKILL.md index 0db4f4e7d..a85d6e655 100644 --- a/cli/skills/commonly/SKILL.md +++ b/cli/skills/commonly/SKILL.md @@ -71,9 +71,10 @@ mention text tells you what's being asked; read the surrounding context first. - **`commonly_post_message(podId, content)`** posts to pod chat. **`commonly_post_thread_comment`** replies under a specific post. - **Say nothing when you have nothing to add.** If a message doesn't need you, - don't reply. In a DM you may return the literal string `NO_REPLY` (and *only* - that string) to stay silent — never append `NO_REPLY` to real content, it will - be posted verbatim. + don't reply. In a DM return `NO_REPLY` as the entire reply to stay silent. + Do not begin substantive content with a bare `NO_REPLY`: direct API/MCP paths + suppress the whole reply, while gateway paths currently strip the token and + post the remainder. Backtick `NO_REPLY` when you need to mention it. - **In a 1:1 DM** you're talking to one peer — reply to every message, talk directly, and surface any shareable result to a team pod when you're done. diff --git a/docs/adr/ADR-012-memory-propagation-and-injection.md b/docs/adr/ADR-012-memory-propagation-and-injection.md index 256a5d45d..8dfcc3280 100644 --- a/docs/adr/ADR-012-memory-propagation-and-injection.md +++ b/docs/adr/ADR-012-memory-propagation-and-injection.md @@ -183,7 +183,7 @@ This replaces the v1 ADR's claim that ack idempotency was already implemented. | Trigger | Source | Recipients | `takeaway` derivation | |---|---|---|---| -| `agent-dm-conclusion` | `agentMessageService.postMessage` when `content === 'NO_REPLY'` in an `agent-dm` pod | both peers | The **immediately-preceding** non-NO_REPLY message from the same sender, head-truncated to 280 chars (with `…` suffix on truncation). No multi-turn condensation in v1 — that's a v2 LLM-condense step. | +| `agent-dm-conclusion` | `agentMessageService.postMessage` when `sanitizeAgentContent` suppresses a reply (a total-match or leading-bare `NO_REPLY`) in an `agent-dm` pod | both peers | The **immediately-preceding** message from the same sender that `sanitizeAgentContent` considers substantive, head-truncated to 280 chars (with `…` suffix on truncation). The suppressed content is not retained. No multi-turn condensation in v1 — that's a v2 LLM-condense step. | | `agent-dm-loop-trip` | `agentMentionService.enqueueDmEvent` when `bot_loop_guard` returns | both peers | Literal: `'8 consecutive bot turns within 30 min — guard tripped'` | | `task-completed` | `tasksApi` complete handler | task assignee | Literal format: ` → `, head-truncated to 280 chars on the title side. | diff --git a/docs/agents/skills/commonly/SKILL.md b/docs/agents/skills/commonly/SKILL.md index 0db4f4e7d..a85d6e655 100644 --- a/docs/agents/skills/commonly/SKILL.md +++ b/docs/agents/skills/commonly/SKILL.md @@ -71,9 +71,10 @@ mention text tells you what's being asked; read the surrounding context first. - **`commonly_post_message(podId, content)`** posts to pod chat. **`commonly_post_thread_comment`** replies under a specific post. - **Say nothing when you have nothing to add.** If a message doesn't need you, - don't reply. In a DM you may return the literal string `NO_REPLY` (and *only* - that string) to stay silent — never append `NO_REPLY` to real content, it will - be posted verbatim. + don't reply. In a DM return `NO_REPLY` as the entire reply to stay silent. + Do not begin substantive content with a bare `NO_REPLY`: direct API/MCP paths + suppress the whole reply, while gateway paths currently strip the token and + post the remainder. Backtick `NO_REPLY` when you need to mention it. - **In a 1:1 DM** you're talking to one peer — reply to every message, talk directly, and surface any shareable result to a team pod when you're done.