feat: channel-aware per-message directives via adapter hints (#419)

This commit is contained in:
Cameron
2026-02-27 16:58:11 -08:00
committed by GitHub
parent f69220c171
commit 8ab5add972
14 changed files with 215 additions and 89 deletions

View File

@@ -312,6 +312,7 @@ Ask the bot owner to approve with:
wasMentioned,
isListeningMode,
attachments,
formatterHints: this.getFormatterHints(),
});
}
});
@@ -414,6 +415,14 @@ Ask the bot owner to approve with:
return this.config.dmPolicy || 'pairing';
}
getFormatterHints() {
return {
supportsReactions: true,
supportsFiles: true,
formatHint: 'Discord markdown: **bold** *italic* `code` [links](url) ```code blocks``` — supports headers',
};
}
supportsEditing(): boolean {
return this.config.streaming ?? false;
}
@@ -477,6 +486,7 @@ Ask the bot owner to approve with:
messageId: message.id,
action,
},
formatterHints: this.getFormatterHints(),
}).catch((err) => {
log.error('Error handling reaction:', err);
});

View File

@@ -339,6 +339,14 @@ This code expires in 1 hour.`;
return this.config.dmPolicy || 'pairing';
}
getFormatterHints() {
return {
supportsReactions: true,
supportsFiles: false,
formatHint: 'ONLY: *bold* _italic_ `code` — NO: headers, code fences, links, quotes, tables',
};
}
supportsEditing(): boolean {
return false;
}
@@ -868,6 +876,7 @@ This code expires in 1 hour.`;
wasMentioned,
isListeningMode,
attachments: collectedAttachments.length > 0 ? collectedAttachments : undefined,
formatterHints: this.getFormatterHints(),
};
this.onMessage?.(msg).catch((err) => {

View File

@@ -169,6 +169,7 @@ export class SlackAdapter implements ChannelAdapter {
wasMentioned: false, // Regular messages; app_mention handles mentions
isListeningMode: mode === 'listen',
attachments,
formatterHints: this.getFormatterHints(),
});
}
});
@@ -264,6 +265,7 @@ export class SlackAdapter implements ChannelAdapter {
groupName: isGroup ? channelId : undefined,
wasMentioned: true, // app_mention is always a mention
attachments,
formatterHints: this.getFormatterHints(),
});
}
});
@@ -359,6 +361,14 @@ export class SlackAdapter implements ChannelAdapter {
return this.config.dmPolicy || 'pairing';
}
getFormatterHints() {
return {
supportsReactions: true,
supportsFiles: true,
formatHint: 'Slack mrkdwn: *bold* _italic_ `code` <URL|text> — NO standard markdown headers',
};
}
/** Check if a channel is allowed by the groups config allowlist */
private isChannelAllowed(channelId: string): boolean {
return isGroupAllowed(this.config.groups, [channelId]);
@@ -413,6 +423,7 @@ export class SlackAdapter implements ChannelAdapter {
messageId,
action,
},
formatterHints: this.getFormatterHints(),
});
}

View File

@@ -496,6 +496,7 @@ Reply **approve** or **deny** to this message.`;
text,
messageId,
timestamp: new Date(message.date * 1000),
formatterHints: this.getFormatterHints(),
};
// Call handler
@@ -749,6 +750,14 @@ Reply **approve** or **deny** to this message.`;
return false;
}
getFormatterHints() {
return {
supportsReactions: false,
supportsFiles: false,
formatHint: 'MarkdownV2: *bold* _italic_ `code` [link](url) — NO: headers, tables',
};
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
if (!this.client) {
throw new Error('Client not initialized');

View File

@@ -304,6 +304,7 @@ export class TelegramAdapter implements ChannelAdapter {
groupName,
wasMentioned,
isListeningMode,
formatterHints: this.getFormatterHints(),
});
}
});
@@ -349,6 +350,7 @@ export class TelegramAdapter implements ChannelAdapter {
messageId: String(messageId),
action,
},
formatterHints: this.getFormatterHints(),
});
}
});
@@ -409,6 +411,7 @@ export class TelegramAdapter implements ChannelAdapter {
groupName,
wasMentioned,
isListeningMode,
formatterHints: this.getFormatterHints(),
});
}
} catch (error) {
@@ -427,6 +430,7 @@ export class TelegramAdapter implements ChannelAdapter {
groupName,
wasMentioned,
isListeningMode,
formatterHints: this.getFormatterHints(),
});
}
}
@@ -461,6 +465,7 @@ export class TelegramAdapter implements ChannelAdapter {
wasMentioned,
isListeningMode,
attachments,
formatterHints: this.getFormatterHints(),
});
}
});
@@ -633,6 +638,14 @@ export class TelegramAdapter implements ChannelAdapter {
return this.config.dmPolicy || 'pairing';
}
getFormatterHints() {
return {
supportsReactions: true,
supportsFiles: true,
formatHint: 'MarkdownV2: *bold* _italic_ `code` [link](url) — NO: headers, tables',
};
}
async sendTypingIndicator(chatId: string): Promise<void> {
await this.bot.api.sendChatAction(chatId, 'typing');
}

View File

@@ -4,7 +4,7 @@
* Each channel (Telegram, Slack, Discord, WhatsApp, Signal) implements this interface.
*/
import type { ChannelId, InboundMessage, OutboundMessage, OutboundFile } from '../core/types.js';
import type { ChannelId, InboundMessage, OutboundMessage, OutboundFile, FormatterHints } from '../core/types.js';
/**
* Channel adapter - implement this for each messaging platform
@@ -29,6 +29,7 @@ export interface ChannelAdapter {
sendFile?(file: OutboundFile): Promise<{ messageId: string }>;
addReaction?(chatId: string, messageId: string, emoji: string): Promise<void>;
getDmPolicy?(): string;
getFormatterHints(): FormatterHints;
// Event handlers (set by bot core)
onMessage?: (msg: InboundMessage) => Promise<void>;

View File

@@ -836,6 +836,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
isListeningMode,
replyToUser: extracted.replyContext?.senderE164,
attachments: extracted.attachments,
formatterHints: this.getFormatterHints(),
});
}
}
@@ -1002,6 +1003,14 @@ export class WhatsAppAdapter implements ChannelAdapter {
return this.config.dmPolicy || 'pairing';
}
getFormatterHints() {
return {
supportsReactions: false,
supportsFiles: true,
formatHint: 'WhatsApp: *bold* _italic_ `code` — NO: headers, code fences, links, tables',
};
}
supportsEditing(): boolean {
return false;
}

View File

@@ -181,38 +181,67 @@ describe('formatMessageEnvelope', () => {
expect(result).toContain('**Mentioned**: yes');
});
it('does not include per-message directive hints (covered by system prompt)', () => {
const groupMsg = createMessage({ isGroup: true });
const dmMsg = createMessage({ isGroup: false });
expect(formatMessageEnvelope(groupMsg)).not.toContain('Response Directives');
expect(formatMessageEnvelope(dmMsg)).not.toContain('Response Directives');
it('includes <actions> directives when reactions are supported', () => {
const msg = createMessage({
isGroup: false,
formatterHints: { supportsReactions: true },
});
const result = formatMessageEnvelope(msg);
expect(result).toContain('Response Directives');
expect(result).toContain('<no-reply/>');
expect(result).toContain('<actions>');
});
it('omits <actions> directives when reactions are not supported', () => {
const msg = createMessage({ isGroup: false });
const result = formatMessageEnvelope(msg);
expect(result).toContain('Response Directives');
expect(result).toContain('<no-reply/>');
expect(result).not.toContain('<react');
});
it('shows file directive only when files supported', () => {
const msg = createMessage({
formatterHints: { supportsFiles: true },
});
const result = formatMessageEnvelope(msg);
expect(result).toContain('<send-file');
});
it('omits file directive when files not supported', () => {
const msg = createMessage({
formatterHints: { supportsFiles: false },
});
const result = formatMessageEnvelope(msg);
expect(result).not.toContain('<send-file');
});
it('shows minimal directives in listening mode', () => {
const msg = createMessage({
isGroup: true,
isListeningMode: true,
formatterHints: { supportsReactions: true },
});
const result = formatMessageEnvelope(msg);
expect(result).toContain('<no-reply/>');
expect(result).toContain('react to show you saw this');
expect(result).not.toContain('react and reply');
});
});
describe('format hints', () => {
it('includes Slack format hint', () => {
const msg = createMessage({ channel: 'slack' });
it('includes format hint when provided via formatterHints', () => {
const msg = createMessage({
formatterHints: { formatHint: 'MarkdownV2: *bold* _italic_' },
});
const result = formatMessageEnvelope(msg);
expect(result).toContain('**Format support**: Markdown (auto-converted to Slack mrkdwn):');
expect(result).toContain('**Format support**: MarkdownV2: *bold* _italic_');
});
it('includes Telegram format hint', () => {
const msg = createMessage({ channel: 'telegram' });
it('omits Format support line when no formatHint is set', () => {
const msg = createMessage({});
const result = formatMessageEnvelope(msg);
expect(result).toContain('**Format support**: MarkdownV2:');
});
it('includes WhatsApp format hint', () => {
const msg = createMessage({ channel: 'whatsapp' });
const result = formatMessageEnvelope(msg);
expect(result).toContain('**Format support**:');
expect(result).toContain('NO: headers');
});
it('includes Signal format hint', () => {
const msg = createMessage({ channel: 'signal' });
const result = formatMessageEnvelope(msg);
expect(result).toContain('**Format support**: ONLY:');
expect(result).not.toContain('**Format support**');
});
});

View File

@@ -13,17 +13,7 @@ export const SYSTEM_REMINDER_TAG = 'system-reminder';
export const SYSTEM_REMINDER_OPEN = `<${SYSTEM_REMINDER_TAG}>`;
export const SYSTEM_REMINDER_CLOSE = `</${SYSTEM_REMINDER_TAG}>`;
/**
* Channel format hints - tells the agent what formatting syntax to use
* Each channel has different markdown support - hints help agent format appropriately.
*/
const CHANNEL_FORMATS: Record<string, string> = {
slack: 'Markdown (auto-converted to Slack mrkdwn): **bold** _italic_ `code` [links](url) ```code blocks``` - NO: headers, tables',
discord: '**bold** *italic* `code` [links](url) ```code blocks``` - NO: headers, tables',
telegram: 'MarkdownV2: *bold* _italic_ `code` [links](url) - NO: headers, tables',
whatsapp: '*bold* _italic_ `code` - NO: headers, code fences, links, tables',
signal: 'ONLY: *bold* _italic_ `code` - NO: headers, code fences, links, quotes, tables',
};
// Channel format hints are now provided per-message via formatterHints on InboundMessage.
export interface EnvelopeOptions {
timezone?: 'local' | 'utc' | string; // IANA timezone or 'local'/'utc'
@@ -229,7 +219,7 @@ function buildMetadataLines(msg: InboundMessage, options: EnvelopeOptions): stri
lines.push(`- **Timestamp**: ${formatTimestamp(msg.timestamp, options)}`);
// Format support hint
const formatHint = CHANNEL_FORMATS[msg.channel];
const formatHint = msg.formatterHints?.formatHint;
if (formatHint) {
lines.push(`- **Format support**: ${formatHint}`);
}
@@ -256,7 +246,13 @@ function buildChatContextLines(msg: InboundMessage, options: EnvelopeOptions): s
if (msg.wasMentioned) {
lines.push(`- **Mentioned**: yes`);
}
lines.push(`- **Hint**: Use \`<no-reply/>\` to skip replying, \`<actions>\` for reactions/voice`);
if (msg.isListeningMode) {
lines.push(`- **Mode**: Listen only — observe and update memories, do not send text replies`);
} else if (msg.formatterHints?.supportsReactions) {
lines.push(`- **Hint**: See Response Directives below for \`<no-reply/>\` and \`<actions>\``);
} else {
lines.push(`- **Hint**: See Response Directives below for \`<no-reply/>\``);
}
} else {
lines.push(`- **Type**: Direct message`);
}
@@ -301,6 +297,56 @@ export function buildSessionContext(options: SessionContextOptions): string[] {
return lines;
}
/**
* Build context-aware Response Directives based on channel capabilities and chat type.
* In listening mode, shows minimal directives. In normal mode, shows the full set
* filtered by what the channel actually supports.
*/
function buildResponseDirectives(msg: InboundMessage): string[] {
const lines: string[] = [];
const supportsReactions = msg.formatterHints?.supportsReactions ?? false;
const supportsFiles = msg.formatterHints?.supportsFiles ?? false;
const isGroup = !!msg.isGroup;
const isListeningMode = msg.isListeningMode ?? false;
// Listening mode: minimal directives only
if (isListeningMode) {
lines.push(`- \`<no-reply/>\` — acknowledge without replying (recommended)`);
if (supportsReactions) {
lines.push(`- \`<actions><react emoji="eyes" /></actions>\` — react to show you saw this`);
}
return lines;
}
// no-reply
if (isGroup) {
lines.push(`- \`<no-reply/>\` — skip replying when the message isn't directed at you`);
} else {
lines.push(`- \`<no-reply/>\` — skip replying when the message doesn't need a response`);
}
// actions/react (only if channel supports it)
if (supportsReactions) {
lines.push(`- \`<actions><react emoji="thumbsup" /></actions>\` — react without sending text (executes silently)`);
lines.push(`- \`<actions><react emoji="eyes" /></actions>Your text here\` — react and reply`);
if (isGroup) {
lines.push(`- \`<actions><react emoji="fire" message="123" /></actions>\` — react to a specific message`);
}
lines.push(`- Emoji names: eyes, thumbsup, heart, fire, tada, clap — or unicode`);
lines.push(`- Prefer directives over tool calls for reactions (faster and cheaper)`);
}
// voice memo (always available -- TTS config is server-side)
lines.push(`- \`<actions><voice>Your message here</voice></actions>\` — send a voice memo via TTS`);
// file sending (only if channel supports it)
if (supportsFiles) {
lines.push(`- \`<send-file path="/path/to/file.png" kind="image" />\` — send a file (restricted to configured directory)`);
}
return lines;
}
/**
* Format a message with XML system-reminder envelope.
*
@@ -351,6 +397,9 @@ export function formatMessageEnvelope(
sections.push(`## Chat Context\n${contextLines.join('\n')}`);
}
// Channel-aware response directives
const directiveLines = buildResponseDirectives(msg);
sections.push(`## Response Directives\n${directiveLines.join('\n')}`);
// Build the full system-reminder block
const reminderContent = sections.join('\n\n');
const reminder = `${SYSTEM_REMINDER_OPEN}\n${reminderContent}\n${SYSTEM_REMINDER_CLOSE}`;
@@ -418,8 +467,15 @@ export function formatGroupBatchEnvelope(
});
// Format hint
const formatHint = CHANNEL_FORMATS[first.channel];
const formatHint = first.formatterHints?.formatHint;
const hint = formatHint ? `\n(Format: ${formatHint})` : '';
return `${header}\n${lines.join('\n')}${hint}`;
// Compact directives for batch
const supportsReactions = first.formatterHints?.supportsReactions ?? false;
const directiveParts = isListeningMode
? [`\`<no-reply/>\` to acknowledge`, ...(supportsReactions ? [`\`<actions><react emoji="eyes" /></actions>\` to react`] : [])]
: [`\`<no-reply/>\` to skip replying`, ...(supportsReactions ? [`\`<actions><react emoji="thumbsup" /></actions>\` to react`] : [])];
const directives = `\n(Directives: ${directiveParts.join(', ')})`;
return `${header}\n${lines.join('\n')}${hint}${directives}`;
}

View File

@@ -95,6 +95,7 @@ export class GroupBatcher {
isListeningMode: messages.every((m) => m.isListeningMode === true) ? true : undefined,
isBatch: true,
batchedMessages: messages,
formatterHints: last.formatterHints,
};
this.onFlush(batchMsg, adapter);

View File

@@ -35,6 +35,7 @@ describe('channel redaction wrapping', () => {
sendMessage: sendSpy,
editMessage: vi.fn(async () => {}),
sendTypingIndicator: vi.fn(async () => {}),
getFormatterHints: () => ({ supportsReactions: false, supportsFiles: false }),
};
bot.registerChannel(adapter);

View File

@@ -6,7 +6,18 @@ 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.
Not every message requires a response. Before replying, consider whether your response adds value. When in doubt, prefer \`<no-reply/>\` over a low-value response.
## Choosing Not to Reply
Use \`<no-reply/>\` when the message:
- Is a simple acknowledgment ("ok", "thanks", "got it") that doesn't need a follow-up
- Is a conversation between other users that you're not part of
- Is a notification or status update with no question or request
- Has already been addressed in a previous turn
- Is in a group chat and not directed at you
Channel-specific response options (reactions, file sending) are listed in the **Response Directives** section of each incoming message.
# Communication System
@@ -74,54 +85,6 @@ 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.
## Response Directives
You can include an \`<actions>\` block at the **start** of your response to perform actions alongside your reply. The entire block is stripped before your message is sent.
\`\`\`
<actions>
<react emoji="👍" />
</actions>
Great idea!
\`\`\`
This sends "Great idea!" and reacts with thumbsup.
### Available directives
- \`<react emoji="👀" />\` -- react to the message you are responding to. Use the actual emoji character (👀, 👍, ❤️, 🔥, 🎉, 👏).
- \`<react emoji="🔥" message="123" />\` -- react to a specific message by ID.
- \`<send-file path="/path/to/file.png" kind="image" caption="..." />\` -- send a file or image to the same channel/chat. File paths are restricted to the configured send-file directory (default: \`data/outbound/\` in the working directory). Paths outside this directory are blocked.
- \`<send-file path="/path/to/voice.ogg" kind="audio" cleanup="true" />\` -- send a voice note. Audio files (.ogg, .mp3, etc.) are sent as native voice memos on Telegram and WhatsApp. Use \`cleanup="true"\` to delete the file after sending.
- \`<voice>Your message here</voice>\` -- generate and send a voice memo. The text is converted to speech via TTS and sent as a native voice note. No tool calls needed. Use for short conversational replies, responding to voice messages, or when the user asks for audio.
### Actions-only response
An \`<actions>\` block with no text after it executes silently (nothing sent to the user), like \`<no-reply/>\`:
\`\`\`
<actions>
<react emoji="👀" />
</actions>
\`\`\`
Prefer directives over tool calls for simple actions like reactions. They are faster and cheaper.
## Available Channels
- **telegram** - Telegram messenger

View File

@@ -84,6 +84,16 @@ export interface InboundMessage {
isBatch?: boolean; // Is this a batched group message?
batchedMessages?: InboundMessage[]; // Original individual messages (for batch formatting)
isListeningMode?: boolean; // Listening mode: agent processes for memory but response is suppressed
formatterHints?: FormatterHints; // Channel capabilities for directive rendering
}
/**
* Channel capability hints for per-message directive rendering
*/
export interface FormatterHints {
supportsReactions?: boolean;
supportsFiles?: boolean;
formatHint?: string;
}
/**

View File

@@ -56,6 +56,10 @@ export class MockChannelAdapter implements ChannelAdapter {
return false; // Disable streaming edits for simpler testing
}
getFormatterHints() {
return { supportsReactions: false, supportsFiles: false };
}
/**
* Simulate an inbound message and wait for response
*/