From b3366228ef42f5c92b50b01b65a4c0ae0264de9f Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 2 Mar 2026 13:01:38 -0800 Subject: [PATCH] feat: add `disabled` conversation mode (#453) --- docs/configuration.md | 5 +++-- src/config/types.ts | 4 ++-- src/core/bot.ts | 37 ++++++++++++++++++++++++------- src/core/conversation-key.test.ts | 23 +++++++++++++++++++ src/core/types.ts | 2 +- 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 51199aa..c1e672c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -56,7 +56,7 @@ agent: # Conversation routing (optional) conversations: - mode: shared # "shared" | "per-channel" | "per-chat" + mode: shared # "disabled" | "shared" | "per-channel" | "per-chat" heartbeat: last-active # "dedicated" | "last-active" | "" # Channel configurations @@ -268,7 +268,7 @@ Conversation routing controls which incoming messages share a Letta conversation ```yaml conversations: - mode: shared # "shared" | "per-channel" | "per-chat" + mode: shared # "disabled" | "shared" | "per-channel" | "per-chat" heartbeat: last-active # "dedicated" | "last-active" | "" maxSessions: 10 # per-chat only: max concurrent sessions (LRU eviction) perChannel: @@ -279,6 +279,7 @@ conversations: | Mode | Key | Description | |------|-----|-------------| +| `disabled` | `"default"` | Always uses the agent's built-in default conversation. No new conversations are created. | | `shared` (default) | `"shared"` | One conversation across all channels and all chats | | `per-channel` | `"telegram"`, `"discord"`, etc. | One conversation per channel adapter. All Telegram groups share one conversation, all Discord channels share another. | | `per-chat` | `"telegram:12345"` | One conversation per unique chat within each channel. Every DM and group gets its own isolated message history. | diff --git a/src/config/types.ts b/src/config/types.ts index a5290c7..7266db4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -66,7 +66,7 @@ export interface AgentConfig { }; /** Conversation routing */ conversations?: { - mode?: 'shared' | 'per-channel' | 'per-chat'; // Default: shared (single conversation across all channels) + mode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat'; // Default: shared (single conversation across all channels) heartbeat?: string; // "dedicated" | "last-active" | "" (default: last-active) perChannel?: string[]; // Channels that should always have their own conversation maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction) @@ -154,7 +154,7 @@ export interface LettaBotConfig { // Conversation routing conversations?: { - mode?: 'shared' | 'per-channel' | 'per-chat'; // Default: shared (single conversation across all channels) + mode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat'; // Default: shared (single conversation across all channels) heartbeat?: string; // "dedicated" | "last-active" | "" (default: last-active) perChannel?: string[]; // Channels that should always have their own conversation maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction) diff --git a/src/core/bot.ts b/src/core/bot.ts index 73e0f3b..3aceb13 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -138,6 +138,7 @@ export function resolveConversationKey( conversationOverrides: Set, chatId?: string, ): string { + if (conversationMode === 'disabled') return 'default'; const normalized = channel.toLowerCase(); if (conversationMode === 'per-chat' && chatId) return `${normalized}:${chatId}`; if (conversationMode === 'per-channel') return normalized; @@ -158,6 +159,7 @@ export function resolveHeartbeatConversationKey( lastActiveChannel?: string, lastActiveChatId?: string, ): string { + if (conversationMode === 'disabled') return 'default'; const hb = heartbeatConversation || 'last-active'; if (conversationMode === 'per-chat') { @@ -605,18 +607,25 @@ export class LettaBot implements AgentSession { let session: Session; let sessionAgentId: string | undefined; - // In per-channel mode, look up per-key conversation ID. - // In shared mode (key === "shared"), use the legacy single conversationId. - const convId = key === 'shared' - ? this.store.conversationId - : this.store.getConversationId(key); + // In disabled mode, always resume the agent's built-in default conversation. + // Skip store lookup entirely -- no conversation ID is persisted. + const convId = key === 'default' + ? null + : key === 'shared' + ? this.store.conversationId + : this.store.getConversationId(key); // Propagate per-agent cron store path to CLI subprocesses (lettabot-schedule) if (this.config.cronStorePath) { process.env.CRON_STORE_PATH = this.config.cronStorePath; } - if (convId) { + if (key === 'default' && this.store.agentId) { + process.env.LETTA_AGENT_ID = this.store.agentId; + installSkillsToAgent(this.store.agentId, this.config.skills); + sessionAgentId = this.store.agentId; + session = resumeSession('default', opts); + } else if (convId) { process.env.LETTA_AGENT_ID = this.store.agentId || undefined; if (this.store.agentId) { installSkillsToAgent(this.store.agentId, this.config.skills); @@ -648,7 +657,11 @@ export class LettaBot implements AgentSession { installSkillsToAgent(newAgentId, this.config.skills); sessionAgentId = newAgentId; - session = createSession(newAgentId, opts); + // In disabled mode, resume the built-in default conversation instead of + // creating a new one. Other modes create a fresh conversation per key. + session = key === 'default' + ? resumeSession('default', opts) + : createSession(newAgentId, opts); } // Initialize eagerly so the subprocess is ready before the first send() @@ -843,9 +856,10 @@ export class LettaBot implements AgentSession { const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined); log.info('Agent ID updated:', session.agentId); - } else if (session.conversationId && session.conversationId !== 'default') { + } else if (session.conversationId && session.conversationId !== 'default' && convKey !== 'default') { // In per-channel mode, persist per-key. In shared mode, use legacy field. // Skip saving "default" -- it's an API alias, not a real conversation ID. + // In disabled mode (convKey === 'default'), skip -- always use the built-in default. if (convKey && convKey !== 'shared') { const existing = this.store.getConversationId(convKey); if (session.conversationId !== existing) { @@ -1125,6 +1139,13 @@ export class LettaBot implements AgentSession { // 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'; + + // In disabled mode the bot always uses the agent's built-in default + // conversation -- there's nothing to reset locally. + if (convKey === 'default') { + return 'Conversations are disabled -- nothing to reset.'; + } + this.store.clearConversation(convKey); this.store.resetRecoveryAttempts(); this.invalidateSession(convKey); diff --git a/src/core/conversation-key.test.ts b/src/core/conversation-key.test.ts index 105ca23..6954d3d 100644 --- a/src/core/conversation-key.test.ts +++ b/src/core/conversation-key.test.ts @@ -74,6 +74,21 @@ describe('resolveConversationKey', () => { expect(resolveConversationKey('telegram', 'shared', new Set(), '12345')).toBe('shared'); expect(resolveConversationKey('telegram', 'per-channel', new Set(), '12345')).toBe('telegram'); }); + + // --- disabled mode --- + + it('returns "default" in disabled mode', () => { + expect(resolveConversationKey('telegram', 'disabled', new Set())).toBe('default'); + }); + + it('returns "default" in disabled mode regardless of chatId', () => { + expect(resolveConversationKey('telegram', 'disabled', new Set(), '12345')).toBe('default'); + }); + + it('returns "default" in disabled mode regardless of overrides', () => { + const overrides = new Set(['telegram']); + expect(resolveConversationKey('telegram', 'disabled', overrides)).toBe('default'); + }); }); // --------------------------------------------------------------------------- @@ -148,4 +163,12 @@ describe('resolveHeartbeatConversationKey', () => { it('falls back to "shared" in per-chat mode when no last-active target', () => { expect(resolveHeartbeatConversationKey('per-chat', 'last-active', new Set(), undefined, undefined)).toBe('shared'); }); + + // --- disabled mode --- + + it('returns "default" in disabled mode regardless of heartbeat setting', () => { + expect(resolveHeartbeatConversationKey('disabled', 'last-active', new Set(), 'telegram')).toBe('default'); + expect(resolveHeartbeatConversationKey('disabled', 'dedicated', new Set(), 'telegram')).toBe('default'); + expect(resolveHeartbeatConversationKey('disabled', undefined, new Set())).toBe('default'); + }); }); diff --git a/src/core/types.ts b/src/core/types.ts index 1ebfa6f..0751489 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -169,7 +169,7 @@ export interface BotConfig { cronStorePath?: string; // Resolved cron store path (per-agent in multi-agent mode) // Conversation routing - conversationMode?: 'shared' | 'per-channel' | 'per-chat'; // Default: shared + conversationMode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat'; // Default: shared heartbeatConversation?: string; // "dedicated" | "last-active" | "" (default: last-active) conversationOverrides?: string[]; // Channels that always use their own conversation (shared mode) maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)