From be60a00057f420bcb44b17aabfe8281c1d3a3d42 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Mar 2026 18:40:09 -0700 Subject: [PATCH] fix: align commands, batching, reactions, and LRU with forcePerChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/configuration.md | 2 +- docs/discord-setup.md | 2 +- src/channels/discord-adapter.test.ts | 4 ++-- src/channels/discord.ts | 11 ++++++++--- src/channels/types.ts | 2 +- src/core/bot.ts | 8 ++++---- src/core/group-batcher.ts | 1 + src/core/session-manager.ts | 7 ++++--- 8 files changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 823f931..4130761 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -461,7 +461,7 @@ channels: 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 diff --git a/docs/discord-setup.md b/docs/discord-setup.md index ee9e4f4..fe1633b 100644 --- a/docs/discord-setup.md +++ b/docs/discord-setup.md @@ -209,7 +209,7 @@ Behavior summary: - 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. - 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: diff --git a/src/channels/discord-adapter.test.ts b/src/channels/discord-adapter.test.ts index eadc272..be34579 100644 --- a/src/channels/discord-adapter.test.ts +++ b/src/channels/discord-adapter.test.ts @@ -185,7 +185,7 @@ describe('DiscordAdapter command gating', () => { await client!.emit('messageCreate', message); 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'); await adapter.stop(); }); @@ -222,7 +222,7 @@ describe('DiscordAdapter command gating', () => { expect(message.startThread).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(message.channel.send).not.toHaveBeenCalled(); await adapter.stop(); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index fde1db2..e067a6a 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -115,7 +115,7 @@ export class DiscordAdapter implements ChannelAdapter { private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string, args?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise; constructor(config: DiscordConfig) { this.config = { @@ -330,9 +330,11 @@ Ask the bot owner to approve with: ? (message.channel as { send: (content: string) => Promise }) : null; + let commandForcePerChat = false; if (isGroup && this.config.groups) { const threadMode = resolveDiscordThreadMode(this.config.groups, keys); - if (threadMode === 'thread-only' && !isThreadMessage) { + commandForcePerChat = threadMode === 'thread-only'; + if (commandForcePerChat && !isThreadMessage) { const shouldCreateThread = wasMentioned && resolveDiscordAutoCreateThreadOnMention(this.config.groups, keys); if (!shouldCreateThread) { @@ -365,7 +367,7 @@ Ask the bot owner to approve with: } 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 (!commandSendTarget) return; await commandSendTarget.send(result); @@ -619,6 +621,7 @@ Ask the bot owner to approve with: } let isListeningMode = false; + let reactionForcePerChat = false; if (isGroup && this.config.groups) { if (!isGroupAllowed(this.config.groups, keys)) { 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) { return; } + reactionForcePerChat = threadMode === 'thread-only'; const limits = resolveDailyLimits(this.config.groups, keys); const counterScope = limits.matchedKey ?? channelId; @@ -677,6 +681,7 @@ Ask the bot owner to approve with: groupName, serverId: message.guildId || undefined, isListeningMode, + forcePerChat: reactionForcePerChat || undefined, reaction: { emoji, messageId: message.id, diff --git a/src/channels/types.ts b/src/channels/types.ts index 69cc8e3..863891b 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -33,7 +33,7 @@ export interface ChannelAdapter { // Event handlers (set by bot core) onMessage?: (msg: InboundMessage) => Promise; - onCommand?: (command: string, chatId?: string, args?: string) => Promise; + onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise; } /** diff --git a/src/core/bot.ts b/src/core/bot.ts index c7d13a0..70ca34b 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -605,7 +605,7 @@ export class LettaBot implements AgentSession { registerChannel(adapter: ChannelAdapter): void { 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. // Secrets are enabled by default unless explicitly disabled. @@ -666,7 +666,7 @@ export class LettaBot implements AgentSession { // Commands // ========================================================================= - private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string): Promise { + private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string, forcePerChat?: boolean): Promise { log.info(`Received: /${command}${args ? ` ${args}` : ''}`); switch (command) { case 'status': { @@ -694,7 +694,7 @@ export class LettaBot implements AgentSession { // other channels/chats' conversations are never silently destroyed. // resolveConversationKey returns 'shared' for non-override channels, // 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 // conversation -- there's nothing to reset locally. @@ -725,7 +725,7 @@ export class LettaBot implements AgentSession { } } 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 if (!this.processingKeys.has(convKey) && !this.processing) { diff --git a/src/core/group-batcher.ts b/src/core/group-batcher.ts index f347bd1..f6b337a 100644 --- a/src/core/group-batcher.ts +++ b/src/core/group-batcher.ts @@ -93,6 +93,7 @@ export class GroupBatcher { wasMentioned: messages.some((m) => m.wasMentioned), // 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, + forcePerChat: last.forcePerChat, isBatch: true, batchedMessages: messages, formatterHints: last.formatterHints, diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 57177ba..ace76d9 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -360,10 +360,11 @@ export class SessionManager { return this.ensureSessionForKey(key, bootstrapRetried); } - // LRU eviction: in per-chat mode, limit concurrent sessions to avoid - // unbounded subprocess growth. + // LRU eviction: limit concurrent sessions to avoid unbounded subprocess + // 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; - if (this.config.conversationMode === 'per-chat' && this.sessions.size >= maxSessions) { + if (this.sessions.size >= maxSessions) { let oldestKey: string | null = null; let oldestTime = Infinity; for (const [k, ts] of this.sessionLastUsed) {