Skip to content

fix: NO_REPLY suppression also needs to match end-of-message pattern #111

@fitz123

Description

@fitz123

Problem

LLM cron prompts that say "if everything is clean, reply with NO_REPLY" produce summaries followed by NO_REPLY at the end. The current regex (^NO_REPLY\b in bot/src/stream-relay.ts:274 and bot/src/cron-runner.ts:393) only matches when NO_REPLY is at the start, so the summary is delivered as a real message.

Evidence

Reproduces consistently across all 5 workspace-health crons (2026-04-27 03:30–03:50 MSK batch). Sample agent outputs that were delivered instead of suppressed:

All checks complete. Everything is clean — no real issues found.

NO_REPLY
All checks complete. Everything is clean — no errors, no warnings, no drift, no fact mismatches.

NO_REPLY
All checks complete. Let me compile the results:
• Size audit: OK (335M, no bloat)
• Hook integrity: OK
• Config check: 1 warning (settings.local.json missing outputStyle — minor, file doesn't exist)
[... 6 more bullets ...]
The only finding is the settings.local.json warning, which is informational — the file simply doesn't exist, and it's optional.

NO_REPLY

100% failure rate (5/5 crons leaked).

Why prompt strengthening didn't fix it

Two prompt-side mitigations were tried and both failed:

  1. Per-cron prompts were strengthened to "your ENTIRE response must be exactly the literal token NO_REPLY — nothing else, no preamble, no summary" with explicit warnings about the regex. (commit bb3d22d in operator's workspace)
  2. Platform rule (.claude/rules/platform/communication.md) was strengthened in PR docs(rules): strengthen NO_REPLY communication rule #110 (merged 2026-04-26) with the same explicit wording, regex citation, and wrong/right examples.

Both changes were live before the failed batch. All 5 crons produced summary-then-NO_REPLY anyway. The model's "report what you did" tendency from RLHF training overrides explicit instructions to output a bare token. Prompt engineering is not the right tool here.

Root cause

bot/src/stream-relay.ts:274:

if (accumulated && /^NO_REPLY\b/.test(trimmed)) { return; }

bot/src/cron-runner.ts:393:

if (cron.type === "llm" && /^NO_REPLY\b/.test(output.trim())) { ... return; }

Both require NO_REPLY to be at the start of the trimmed output. Agents reliably put it at the end.

Proposed fix

Accept NO_REPLY either at the start (current behavior, backward compat) OR alone on the last non-empty line (new). Pseudocode:

const isFirstLineMatch = /^NO_REPLY\b/.test(trimmed);
const isLastLineMatch = /(^|\n)\s*NO_REPLY\s*$/.test(trimmed);
if (accumulated && (isFirstLineMatch || isLastLineMatch)) { return; }

Apply identical change to both files.

False-positive risk

Someone writing prose ending with NO_REPLY (e.g., a meta-discussion about the suppression mechanism) would be silently suppressed. Two mitigations:

  • The \s*NO_REPLY\s*$ requires NO_REPLY to be the entire last line (alone, only whitespace around it). This is a rare prose pattern.
  • Documentation: update .claude/rules/platform/communication.md to note that NO_REPLY on its own line at end of message ALSO suppresses delivery, so agents can write naturally.

Test plan

  • Add unit tests in bot/src/__tests__/stream-relay.test.ts covering: NO_REPLY at end alone, NO_REPLY at end with surrounding whitespace, NO_REPLY at end with content above, NO_REPLY at end on same line as content (should NOT suppress).
  • Add unit tests in bot/src/__tests__/cron-runner.test.ts (or wherever cron suppression is tested) for the same patterns.
  • Manually trigger one workspace-health cron via launchctl kickstart — verify suppression when output ends with NO_REPLY.
  • Verify backward compat: existing ^NO_REPLY cases still suppress (bedtime-reminder, vitaminka, etc.).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions