feat: add <no-reply/> silent marker for agent opt-out (#196)

* feat: add {{NO_REPLY}} silent marker for agent opt-out

Allow the agent to respond with {{NO_REPLY}} to suppress message
delivery for messages that don't warrant a reply. The marker is
checked in three places: the streaming edit guard (prefix match to
prevent partial sends), finalizeMessage(), and the post-stream
response handler.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: use <no-reply/> XML marker instead of {{NO_REPLY}}

Switch to XML-style self-closing tag for consistency with the XML
envelope format used elsewhere, and because LLMs produce well-formed
XML tags more reliably than template syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add no-reply hint to group chat envelopes

Agents created outside lettabot (via ADE, Letta Cloud) won't have the
system prompt telling them about <no-reply/>. Adding the hint to group
chat envelopes makes the opt-out mechanism self-documenting.

Written by Cameron ◯ Letta Code

"Silence is one of the great arts of conversation." -- Marcus Tullius Cicero

---------

Co-authored-by: Gabriele Sarti <gabriele.sarti996@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-02-06 11:08:56 -08:00
committed by GitHub
parent 04f58e72c8
commit b1d69965b5
5 changed files with 50 additions and 1 deletions

1
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": {
"name": "lettabot",
"version": "1.0.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^0.11.0",

View File

@@ -443,6 +443,15 @@ export class LettaBot {
// Helper to finalize and send current accumulated response
const finalizeMessage = async () => {
// Check for silent marker - agent chose not to reply
if (response.trim() === '<no-reply/>') {
console.log('[Bot] Agent chose not to reply (no-reply marker)');
sentAnyMessage = true;
response = '';
messageId = null;
lastUpdate = Date.now();
return;
}
if (response.trim()) {
try {
if (messageId) {
@@ -521,8 +530,10 @@ export class LettaBot {
response += streamMsg.content;
// Stream updates only for channels that support editing (Telegram, Slack)
// Hold back streaming edits while response could still become <no-reply/>
const canEdit = adapter.supportsEditing?.() ?? true;
if (canEdit && Date.now() - lastUpdate > 500 && response.length > 0) {
const mayBeNoReply = '<no-reply/>'.startsWith(response.trim());
if (canEdit && !mayBeNoReply && Date.now() - lastUpdate > 500 && response.length > 0) {
try {
if (messageId) {
await adapter.editMessage(msg.chatId, messageId, response);
@@ -600,6 +611,13 @@ export class LettaBot {
clearInterval(typingInterval);
}
// Check for silent marker - agent chose not to reply
if (response.trim() === '<no-reply/>') {
console.log('[Bot] Agent chose not to reply (no-reply marker)');
sentAnyMessage = true;
response = '';
}
// Send final response
if (response.trim()) {
try {

View File

@@ -180,6 +180,18 @@ describe('formatMessageEnvelope', () => {
const result = formatMessageEnvelope(msg);
expect(result).toContain('**Mentioned**: yes');
});
it('includes no-reply hint for group chats', () => {
const msg = createMessage({ isGroup: true });
const result = formatMessageEnvelope(msg);
expect(result).toContain('<no-reply/>');
});
it('omits no-reply hint for DMs', () => {
const msg = createMessage({ isGroup: false });
const result = formatMessageEnvelope(msg);
expect(result).not.toContain('no-reply');
});
});
describe('format hints', () => {

View File

@@ -231,6 +231,7 @@ function buildChatContextLines(msg: InboundMessage, options: EnvelopeOptions): s
if (msg.wasMentioned) {
lines.push(`- **Mentioned**: yes`);
}
lines.push(`- **Hint**: To skip replying, respond with exactly: \`<no-reply/>\``);
} else {
lines.push(`- **Type**: Direct message`);
}

View File

@@ -6,6 +6,8 @@ export const SYSTEM_PROMPT = `You are a self-improving AI agent with advanced me
You are connected to a multi-channel messaging system (LettaBot) that allows you to communicate with users across Telegram, Slack, Discord, WhatsApp, and Signal. You run on a remote server and can execute tools, manage files, and interact with various services.
Not every message requires a response. Before replying, consider whether your response adds value. In group chats especially, avoid replying to messages not directed at you, simple acknowledgments, or conversations between other users. Quality over quantity — only reply when you have something meaningful to contribute.
# Communication System
You communicate through multiple channels and trigger types. Understanding when your messages are delivered is critical:
@@ -68,6 +70,21 @@ During heartbeats and background tasks:
You don't need to notify the user about everything. Use judgment about what's worth interrupting them for.
## Choosing Not to Reply
Not all messages warrant a response. If a message doesn't need a reply, respond with exactly:
\`<no-reply/>\`
This suppresses the message so nothing is sent to the user. Use this for:
- Messages in a group not directed at you
- Simple acknowledgments (e.g., "ok", "thanks", thumbs up)
- Conversations between other users you don't need to join
- Notifications or updates that don't require a response
- Messages you've already addressed
When in doubt, prefer \`<no-reply/>\` over a low-value response. Users appreciate an agent that knows when to stay quiet.
## Available Channels
- **telegram** - Telegram messenger