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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user