From 0baf32e4773f249b7f869c975ffd6fe80b8420c3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:23:50 -0700 Subject: [PATCH 01/10] fix(agents): suppress replies led by NO_REPLY --- CLAUDE.md | 2 +- .../agentMessageService.chatNoise.test.js | 47 ++++++++++++++++--- backend/services/agentMessageService.ts | 25 ++++++++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 989a2f0ec..7132f0e9f 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.** 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__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index bbbf32d98..89c5e00af 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -46,11 +46,28 @@ 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(''); + }); + + 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 +120,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 +136,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 +215,15 @@ 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); + }); }); diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 48947f17d..12f677eb0 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -1770,10 +1770,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 +1833,24 @@ 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. + const startsWithLeadingBareSentinel = ( + trimmed.startsWith(sentinel) + && !isWordCharacter(trimmed[sentinel.length]) + ); + if (startsWithLeadingBareSentinel) { + 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, 120)}`, + ); + } + return ''; + } + let cleaned = ''; let cursor = 0; let rangeIndex = 0; From a64e9437c67a11e09c317f15b49d1958808810a4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:30:59 -0700 Subject: [PATCH 02/10] test(agents): pin leading silence DM memory flow --- CLAUDE.md | 2 +- .../agentMessageService.chatNoise.test.js | 35 +++++++++++++++++++ backend/services/agentMessageService.ts | 11 +++--- backend/services/systemExchangeTriggers.ts | 19 +++++----- ...DR-012-memory-propagation-and-injection.md | 2 +- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7132f0e9f..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 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.** 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`. +- **`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__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index 89c5e00af..ae2bf156c 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -227,3 +227,38 @@ describe('AgentMessageService.sanitizeAgentContent — strip observability', () expect(line).toContain(input); }); }); + +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/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 12f677eb0..2c4e4d743 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -1122,11 +1122,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, diff --git a/backend/services/systemExchangeTriggers.ts b/backend/services/systemExchangeTriggers.ts index e5b16775c..6ec52cafc 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 @@ -160,10 +161,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/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. | From f9b74bc20d12981128b5e924980504208b806d40 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:38:12 -0700 Subject: [PATCH 03/10] fix(agents): share leading silence semantics --- .../services/agentEnsembleService.test.js | 20 +++++++++ .../agentMessageService.chatNoise.test.js | 7 ++++ backend/services/agentEnsembleService.ts | 5 ++- backend/services/agentMessageService.ts | 41 ++++++++++++++++--- .../services/agentProvisionerServiceK8s.ts | 8 ++-- backend/services/systemExchangeTriggers.ts | 23 +++++++++-- cli/skills/commonly/SKILL.md | 7 ++-- 7 files changed, 95 insertions(+), 16 deletions(-) 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 ae2bf156c..0d6c71c9d 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -55,6 +55,13 @@ describe('sanitizeAgentContent — NO_REPLY suppression and sanitization', () => .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.'); 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 2c4e4d743..dc0d482ac 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -1750,6 +1750,41 @@ 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'; + const nextCharacter = trimmed[sentinel.length]; + const nextIsWordCharacter = nextCharacter !== undefined + && /[A-Za-z0-9_]/.test(nextCharacter); + return trimmed.startsWith(sentinel) && !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 @@ -1839,11 +1874,7 @@ class AgentMessageService { // 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. - const startsWithLeadingBareSentinel = ( - trimmed.startsWith(sentinel) - && !isWordCharacter(trimmed[sentinel.length]) - ); - if (startsWithLeadingBareSentinel) { + 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, 120)}`, 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 6ec52cafc..06ee378e5 100644 --- a/backend/services/systemExchangeTriggers.ts +++ b/backend/services/systemExchangeTriggers.ts @@ -139,19 +139,36 @@ 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 }; + AgentMessageService?: { + sanitizeAgentContent?: (s: unknown) => string; + isSilentNoReply?: (s: unknown) => boolean; + }; + default?: { + sanitizeAgentContent?: (s: unknown) => string; + isSilentNoReply?: (s: unknown) => boolean; + }; }; const sanitize = ( AMS.AgentMessageService?.sanitizeAgentContent ?? AMS.default?.sanitizeAgentContent ); + const isSilentNoReply = ( + AMS.AgentMessageService?.isSilentNoReply + ?? AMS.default?.isSilentNoReply + ); 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); + : (typeof isSilentNoReply === 'function' + ? (isSilentNoReply(raw) ? '' : trimmed) + // The lazy import can be absent in isolated tests. Keep that + // backstop aligned with the live total-match + leading-bare rule. + : (/^(?:NO_REPLY\s*)+$/.test(trimmed) + || /^NO_REPLY(?:$|[^A-Za-z0-9_])/.test(trimmed) + ? '' + : trimmed)); if (cleaned) return cleaned; } return null; 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. From 575c900fad9d4e84ca597c601a4f36e4ce4c93b8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:39:25 -0700 Subject: [PATCH 04/10] fix(memory): resolve shared sentinel predicate --- backend/services/systemExchangeTriggers.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/services/systemExchangeTriggers.ts b/backend/services/systemExchangeTriggers.ts index 06ee378e5..a1935ca64 100644 --- a/backend/services/systemExchangeTriggers.ts +++ b/backend/services/systemExchangeTriggers.ts @@ -139,6 +139,8 @@ 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 { + sanitizeAgentContent?: (s: unknown) => string; + isSilentNoReply?: (s: unknown) => boolean; AgentMessageService?: { sanitizeAgentContent?: (s: unknown) => string; isSilentNoReply?: (s: unknown) => boolean; @@ -149,11 +151,13 @@ async function findPreviousNonSilentMessage(podId: string, senderUserId: string) }; }; const sanitize = ( - AMS.AgentMessageService?.sanitizeAgentContent + AMS.sanitizeAgentContent + ?? AMS.AgentMessageService?.sanitizeAgentContent ?? AMS.default?.sanitizeAgentContent ); const isSilentNoReply = ( - AMS.AgentMessageService?.isSilentNoReply + AMS.isSilentNoReply + ?? AMS.AgentMessageService?.isSilentNoReply ?? AMS.default?.isSilentNoReply ); for (const m of result.rows) { From a31cf03d64c08693a5416875eba87d461c932908 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:46:49 -0700 Subject: [PATCH 05/10] fix(skills): sync sentinel guidance --- docs/agents/skills/commonly/SKILL.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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. From 7bfccd66e9da882e15515d361fa1f8b132ce584c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:59:03 -0700 Subject: [PATCH 06/10] fix(agents): retain leading sentinel audit context --- .../agentMessageService.chatNoise.test.js | 15 +++++++++++++++ backend/services/agentMessageService.ts | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js index 0d6c71c9d..14dfe28f9 100644 --- a/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js +++ b/backend/__tests__/unit/services/agentMessageService.chatNoise.test.js @@ -53,6 +53,10 @@ describe('sanitizeAgentContent — NO_REPLY suppression and sanitization', () => .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', () => { @@ -233,6 +237,17 @@ describe('AgentMessageService.sanitizeAgentContent — strip observability', () 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', () => { diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index dc0d482ac..afe2e25f9 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -53,6 +53,15 @@ 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, but direct API/MCP/CLI posts without an AgentRun have no +// equivalent durable turn record. Keep a useful bounded audit excerpt for that +// latter 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. +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. @@ -1763,10 +1772,14 @@ class AgentMessageService { const trimmed = raw.trim(); const sentinel = 'NO_REPLY'; - const nextCharacter = trimmed[sentinel.length]; + 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 trimmed.startsWith(sentinel) && !nextIsWordCharacter; + return sentinelEnd > 0 && !nextIsWordCharacter; } /** @@ -1877,7 +1890,7 @@ class AgentMessageService { 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, 120)}`, + `[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 ''; From 966192b09cde51949a763d4e0544b3641bb16d49 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:26:09 -0700 Subject: [PATCH 07/10] test(memory): cover leading sentinel history scan --- ...temExchangeTriggers.leadingNoReply.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 backend/__tests__/unit/services/systemExchangeTriggers.leadingNoReply.test.ts 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.', + })); + }); +}); From d64a219eb545f1a3c63b0dd38e9cd1372bb9449a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:31:37 -0700 Subject: [PATCH 08/10] refactor(memory): remove duplicate sentinel fallback --- backend/services/systemExchangeTriggers.ts | 36 ++++------------------ 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/backend/services/systemExchangeTriggers.ts b/backend/services/systemExchangeTriggers.ts index a1935ca64..38d242985 100644 --- a/backend/services/systemExchangeTriggers.ts +++ b/backend/services/systemExchangeTriggers.ts @@ -140,39 +140,15 @@ async function findPreviousNonSilentMessage(podId: string, senderUserId: string) // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const AMS = require('./agentMessageService') as { sanitizeAgentContent?: (s: unknown) => string; - isSilentNoReply?: (s: unknown) => boolean; - AgentMessageService?: { - sanitizeAgentContent?: (s: unknown) => string; - isSilentNoReply?: (s: unknown) => boolean; - }; - default?: { - sanitizeAgentContent?: (s: unknown) => string; - isSilentNoReply?: (s: unknown) => boolean; - }; }; - const sanitize = ( - AMS.sanitizeAgentContent - ?? AMS.AgentMessageService?.sanitizeAgentContent - ?? AMS.default?.sanitizeAgentContent - ); - const isSilentNoReply = ( - AMS.isSilentNoReply - ?? AMS.AgentMessageService?.isSilentNoReply - ?? AMS.default?.isSilentNoReply - ); + 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) - : (typeof isSilentNoReply === 'function' - ? (isSilentNoReply(raw) ? '' : trimmed) - // The lazy import can be absent in isolated tests. Keep that - // backstop aligned with the live total-match + leading-bare rule. - : (/^(?:NO_REPLY\s*)+$/.test(trimmed) - || /^NO_REPLY(?:$|[^A-Za-z0-9_])/.test(trimmed) - ? '' - : trimmed)); + const cleaned = sanitize(raw); if (cleaned) return cleaned; } return null; From 1c0a63e5b597c6b106406aaaf7b0596d2f11282d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:33:50 -0700 Subject: [PATCH 09/10] docs(agents): scope suppressed-turn audit retention --- backend/services/agentMessageService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index afe2e25f9..eeae7d363 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -55,11 +55,11 @@ 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, but direct API/MCP/CLI posts without an AgentRun have no -// equivalent durable turn record. Keep a useful bounded audit excerpt for that -// latter 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. +// service is called. MCP and CLI posts 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. const LEADING_NO_REPLY_LOG_EXCERPT_LIMIT = 4096; // Runtime artifacts are exact values, not a language heuristic. Short, From 9bb6d8b86938734cf42a440036682691d07d0c4c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:34:40 -0700 Subject: [PATCH 10/10] docs(agents): clarify suppressed-turn audit scope --- backend/services/agentMessageService.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index eeae7d363..1b3714948 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -55,11 +55,13 @@ 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. MCP and CLI posts 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. +// 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,