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

@@ -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();

View File

@@ -115,7 +115,7 @@ export class DiscordAdapter implements ChannelAdapter {
private attachmentsMaxBytes?: number;
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) {
this.config = {
@@ -330,9 +330,11 @@ Ask the bot owner to approve with:
? (message.channel as { send: (content: string) => Promise<unknown> })
: 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,

View File

@@ -33,7 +33,7 @@ export interface ChannelAdapter {
// Event handlers (set by bot core)
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 {
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<string | null> {
private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string, forcePerChat?: boolean): Promise<string | null> {
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) {

View File

@@ -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,

View File

@@ -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) {