fix: align commands, batching, reactions, and LRU with forcePerChat

Address review findings from self-review and codex:

- Commands (/reset, /cancel) now receive forcePerChat from the Discord
  adapter and resolve the correct per-thread conversation key
- Group batcher propagates forcePerChat to synthetic batch messages so
  debounced thread messages don't fall back to shared routing
- Reaction handler sets forcePerChat for thread-only reactions
- Session LRU eviction fires regardless of conversationMode so
  forcePerChat sessions don't accumulate without bounds
- Docs now correctly state thread-only overrides shared/per-channel
  modes (not disabled mode)

Written by Cameron ◯ Letta Code

"The best way to predict the future is to implement it." -- David Heinemeier Hansson
This commit is contained in:
Cameron
2026-03-09 18:40:09 -07:00
parent b79f29d8fa
commit be60a00057
8 changed files with 22 additions and 15 deletions

View File

@@ -461,7 +461,7 @@ channels:
Thread messages inherit parent channel config, so child threads under `EZRA_CHANNEL_ID` use the same group rules. Thread messages inherit parent channel config, so child threads under `EZRA_CHANNEL_ID` use the same group rules.
When `threadMode: thread-only` is set, each thread automatically gets its own isolated conversation (message history), regardless of the global `conversations.mode` setting. This prevents messages from different threads from being interleaved in the same conversation. Agent memory (blocks) is still shared across all threads. When `threadMode: thread-only` is set, each thread automatically gets its own isolated conversation (message history). This overrides `shared` and `per-channel` conversation modes so that messages from different threads are never interleaved. Agent memory (blocks) is still shared across all threads. (In `disabled` mode, all messages use the agent's built-in default conversation and thread isolation does not apply.)
### Finding Group IDs ### Finding Group IDs

View File

@@ -209,7 +209,7 @@ Behavior summary:
- Top-level messages are ignored in `thread-only` mode. - Top-level messages are ignored in `thread-only` mode.
- Top-level @mentions create a thread and are answered in that thread when `autoCreateThreadOnMention` is enabled. - Top-level @mentions create a thread and are answered in that thread when `autoCreateThreadOnMention` is enabled.
- Thread messages inherit parent channel config. If `EZRA_CHANNEL_ID` is configured, replies in its child threads use that same config. - Thread messages inherit parent channel config. If `EZRA_CHANNEL_ID` is configured, replies in its child threads use that same config.
- Each thread gets its own isolated conversation (message history), regardless of the global `conversations.mode` setting. This prevents crosstalk between threads. Agent memory (blocks) is still shared. - Each thread gets its own isolated conversation (message history), overriding `shared` and `per-channel` conversation modes. This prevents crosstalk between threads. Agent memory (blocks) is still shared.
Required Discord permissions for auto-create: Required Discord permissions for auto-create:

View File

@@ -185,7 +185,7 @@ describe('DiscordAdapter command gating', () => {
await client!.emit('messageCreate', message); await client!.emit('messageCreate', message);
expect(onCommand).toHaveBeenCalledTimes(1); expect(onCommand).toHaveBeenCalledTimes(1);
expect(onCommand).toHaveBeenCalledWith('status', 'thread-1', undefined); expect(onCommand).toHaveBeenCalledWith('status', 'thread-1', undefined, true);
expect(message.channel.send).toHaveBeenCalledWith('ok'); expect(message.channel.send).toHaveBeenCalledWith('ok');
await adapter.stop(); await adapter.stop();
}); });
@@ -222,7 +222,7 @@ describe('DiscordAdapter command gating', () => {
expect(message.startThread).toHaveBeenCalledTimes(1); expect(message.startThread).toHaveBeenCalledTimes(1);
expect(onCommand).toHaveBeenCalledTimes(1); expect(onCommand).toHaveBeenCalledTimes(1);
expect(onCommand).toHaveBeenCalledWith('status', 'thread-created', undefined); expect(onCommand).toHaveBeenCalledWith('status', 'thread-created', undefined, true);
expect(threadSend).toHaveBeenCalledWith('ok'); expect(threadSend).toHaveBeenCalledWith('ok');
expect(message.channel.send).not.toHaveBeenCalled(); expect(message.channel.send).not.toHaveBeenCalled();
await adapter.stop(); await adapter.stop();

View File

@@ -115,7 +115,7 @@ export class DiscordAdapter implements ChannelAdapter {
private attachmentsMaxBytes?: number; private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>; onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>; onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise<string | null>;
constructor(config: DiscordConfig) { constructor(config: DiscordConfig) {
this.config = { this.config = {
@@ -330,9 +330,11 @@ Ask the bot owner to approve with:
? (message.channel as { send: (content: string) => Promise<unknown> }) ? (message.channel as { send: (content: string) => Promise<unknown> })
: null; : null;
let commandForcePerChat = false;
if (isGroup && this.config.groups) { if (isGroup && this.config.groups) {
const threadMode = resolveDiscordThreadMode(this.config.groups, keys); const threadMode = resolveDiscordThreadMode(this.config.groups, keys);
if (threadMode === 'thread-only' && !isThreadMessage) { commandForcePerChat = threadMode === 'thread-only';
if (commandForcePerChat && !isThreadMessage) {
const shouldCreateThread = const shouldCreateThread =
wasMentioned && resolveDiscordAutoCreateThreadOnMention(this.config.groups, keys); wasMentioned && resolveDiscordAutoCreateThreadOnMention(this.config.groups, keys);
if (!shouldCreateThread) { if (!shouldCreateThread) {
@@ -365,7 +367,7 @@ Ask the bot owner to approve with:
} }
if (this.onCommand && isManagedCommand) { if (this.onCommand && isManagedCommand) {
const result = await this.onCommand(command, commandChatId, cmdArgs); const result = await this.onCommand(command, commandChatId, cmdArgs, commandForcePerChat || undefined);
if (result) { if (result) {
if (!commandSendTarget) return; if (!commandSendTarget) return;
await commandSendTarget.send(result); await commandSendTarget.send(result);
@@ -619,6 +621,7 @@ Ask the bot owner to approve with:
} }
let isListeningMode = false; let isListeningMode = false;
let reactionForcePerChat = false;
if (isGroup && this.config.groups) { if (isGroup && this.config.groups) {
if (!isGroupAllowed(this.config.groups, keys)) { if (!isGroupAllowed(this.config.groups, keys)) {
log.info(`Reaction group ${channelId} not in allowlist, ignoring`); log.info(`Reaction group ${channelId} not in allowlist, ignoring`);
@@ -639,6 +642,7 @@ Ask the bot owner to approve with:
if (threadMode === 'thread-only' && !isThreadMessage) { if (threadMode === 'thread-only' && !isThreadMessage) {
return; return;
} }
reactionForcePerChat = threadMode === 'thread-only';
const limits = resolveDailyLimits(this.config.groups, keys); const limits = resolveDailyLimits(this.config.groups, keys);
const counterScope = limits.matchedKey ?? channelId; const counterScope = limits.matchedKey ?? channelId;
@@ -677,6 +681,7 @@ Ask the bot owner to approve with:
groupName, groupName,
serverId: message.guildId || undefined, serverId: message.guildId || undefined,
isListeningMode, isListeningMode,
forcePerChat: reactionForcePerChat || undefined,
reaction: { reaction: {
emoji, emoji,
messageId: message.id, messageId: message.id,

View File

@@ -33,7 +33,7 @@ export interface ChannelAdapter {
// Event handlers (set by bot core) // Event handlers (set by bot core)
onMessage?: (msg: InboundMessage) => Promise<void>; onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>; onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise<string | null>;
} }
/** /**

View File

@@ -605,7 +605,7 @@ export class LettaBot implements AgentSession {
registerChannel(adapter: ChannelAdapter): void { registerChannel(adapter: ChannelAdapter): void {
adapter.onMessage = (msg) => this.handleMessage(msg, adapter); adapter.onMessage = (msg) => this.handleMessage(msg, adapter);
adapter.onCommand = (cmd, chatId, args) => this.handleCommand(cmd, adapter.id, chatId, args); adapter.onCommand = (cmd, chatId, args, forcePerChat) => this.handleCommand(cmd, adapter.id, chatId, args, forcePerChat);
// Wrap outbound methods when any redaction layer is active. // Wrap outbound methods when any redaction layer is active.
// Secrets are enabled by default unless explicitly disabled. // Secrets are enabled by default unless explicitly disabled.
@@ -666,7 +666,7 @@ export class LettaBot implements AgentSession {
// Commands // Commands
// ========================================================================= // =========================================================================
private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string): Promise<string | null> { private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string, forcePerChat?: boolean): Promise<string | null> {
log.info(`Received: /${command}${args ? ` ${args}` : ''}`); log.info(`Received: /${command}${args ? ` ${args}` : ''}`);
switch (command) { switch (command) {
case 'status': { case 'status': {
@@ -694,7 +694,7 @@ export class LettaBot implements AgentSession {
// other channels/chats' conversations are never silently destroyed. // other channels/chats' conversations are never silently destroyed.
// resolveConversationKey returns 'shared' for non-override channels, // resolveConversationKey returns 'shared' for non-override channels,
// the channel id for per-channel, or channel:chatId for per-chat. // the channel id for per-channel, or channel:chatId for per-chat.
const convKey = channelId ? this.resolveConversationKey(channelId, chatId) : 'shared'; const convKey = channelId ? this.resolveConversationKey(channelId, chatId, forcePerChat) : 'shared';
// In disabled mode the bot always uses the agent's built-in default // In disabled mode the bot always uses the agent's built-in default
// conversation -- there's nothing to reset locally. // conversation -- there's nothing to reset locally.
@@ -725,7 +725,7 @@ export class LettaBot implements AgentSession {
} }
} }
case 'cancel': { case 'cancel': {
const convKey = channelId ? this.resolveConversationKey(channelId, chatId) : 'shared'; const convKey = channelId ? this.resolveConversationKey(channelId, chatId, forcePerChat) : 'shared';
// Check if there's actually an active run for this conversation key // Check if there's actually an active run for this conversation key
if (!this.processingKeys.has(convKey) && !this.processing) { if (!this.processingKeys.has(convKey) && !this.processing) {

View File

@@ -93,6 +93,7 @@ export class GroupBatcher {
wasMentioned: messages.some((m) => m.wasMentioned), wasMentioned: messages.some((m) => m.wasMentioned),
// Preserve listening-mode intent only if every message in the batch is non-mentioned listen mode. // Preserve listening-mode intent only if every message in the batch is non-mentioned listen mode.
isListeningMode: messages.every((m) => m.isListeningMode === true) ? true : undefined, isListeningMode: messages.every((m) => m.isListeningMode === true) ? true : undefined,
forcePerChat: last.forcePerChat,
isBatch: true, isBatch: true,
batchedMessages: messages, batchedMessages: messages,
formatterHints: last.formatterHints, formatterHints: last.formatterHints,

View File

@@ -360,10 +360,11 @@ export class SessionManager {
return this.ensureSessionForKey(key, bootstrapRetried); return this.ensureSessionForKey(key, bootstrapRetried);
} }
// LRU eviction: in per-chat mode, limit concurrent sessions to avoid // LRU eviction: limit concurrent sessions to avoid unbounded subprocess
// unbounded subprocess growth. // growth. Applies in per-chat mode and when forcePerChat (e.g., Discord
// thread-only) creates per-thread keys in other modes.
const maxSessions = this.config.maxSessions ?? 10; const maxSessions = this.config.maxSessions ?? 10;
if (this.config.conversationMode === 'per-chat' && this.sessions.size >= maxSessions) { if (this.sessions.size >= maxSessions) {
let oldestKey: string | null = null; let oldestKey: string | null = null;
let oldestTime = Infinity; let oldestTime = Infinity;
for (const [k, ts] of this.sessionLastUsed) { for (const [k, ts] of this.sessionLastUsed) {