feat: add disabled conversation mode (#453)

This commit is contained in:
Cameron
2026-03-02 13:01:38 -08:00
committed by GitHub
parent db5630740f
commit b3366228ef
5 changed files with 58 additions and 13 deletions

View File

@@ -56,7 +56,7 @@ agent:
# Conversation routing (optional) # Conversation routing (optional)
conversations: conversations:
mode: shared # "shared" | "per-channel" | "per-chat" mode: shared # "disabled" | "shared" | "per-channel" | "per-chat"
heartbeat: last-active # "dedicated" | "last-active" | "<channel>" heartbeat: last-active # "dedicated" | "last-active" | "<channel>"
# Channel configurations # Channel configurations
@@ -268,7 +268,7 @@ Conversation routing controls which incoming messages share a Letta conversation
```yaml ```yaml
conversations: conversations:
mode: shared # "shared" | "per-channel" | "per-chat" mode: shared # "disabled" | "shared" | "per-channel" | "per-chat"
heartbeat: last-active # "dedicated" | "last-active" | "<channel>" heartbeat: last-active # "dedicated" | "last-active" | "<channel>"
maxSessions: 10 # per-chat only: max concurrent sessions (LRU eviction) maxSessions: 10 # per-chat only: max concurrent sessions (LRU eviction)
perChannel: perChannel:
@@ -279,6 +279,7 @@ conversations:
| Mode | Key | Description | | 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 | | `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-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. | | `per-chat` | `"telegram:12345"` | One conversation per unique chat within each channel. Every DM and group gets its own isolated message history. |

View File

@@ -66,7 +66,7 @@ export interface AgentConfig {
}; };
/** Conversation routing */ /** Conversation routing */
conversations?: { 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" | "<channel>" (default: last-active) heartbeat?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
perChannel?: string[]; // Channels that should always have their own conversation perChannel?: string[]; // Channels that should always have their own conversation
maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction) maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)
@@ -154,7 +154,7 @@ export interface LettaBotConfig {
// Conversation routing // Conversation routing
conversations?: { 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" | "<channel>" (default: last-active) heartbeat?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
perChannel?: string[]; // Channels that should always have their own conversation perChannel?: string[]; // Channels that should always have their own conversation
maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction) maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)

View File

@@ -138,6 +138,7 @@ export function resolveConversationKey(
conversationOverrides: Set<string>, conversationOverrides: Set<string>,
chatId?: string, chatId?: string,
): string { ): string {
if (conversationMode === 'disabled') return 'default';
const normalized = channel.toLowerCase(); const normalized = channel.toLowerCase();
if (conversationMode === 'per-chat' && chatId) return `${normalized}:${chatId}`; if (conversationMode === 'per-chat' && chatId) return `${normalized}:${chatId}`;
if (conversationMode === 'per-channel') return normalized; if (conversationMode === 'per-channel') return normalized;
@@ -158,6 +159,7 @@ export function resolveHeartbeatConversationKey(
lastActiveChannel?: string, lastActiveChannel?: string,
lastActiveChatId?: string, lastActiveChatId?: string,
): string { ): string {
if (conversationMode === 'disabled') return 'default';
const hb = heartbeatConversation || 'last-active'; const hb = heartbeatConversation || 'last-active';
if (conversationMode === 'per-chat') { if (conversationMode === 'per-chat') {
@@ -605,18 +607,25 @@ export class LettaBot implements AgentSession {
let session: Session; let session: Session;
let sessionAgentId: string | undefined; let sessionAgentId: string | undefined;
// In per-channel mode, look up per-key conversation ID. // In disabled mode, always resume the agent's built-in default conversation.
// In shared mode (key === "shared"), use the legacy single conversationId. // Skip store lookup entirely -- no conversation ID is persisted.
const convId = key === 'shared' const convId = key === 'default'
? this.store.conversationId ? null
: this.store.getConversationId(key); : key === 'shared'
? this.store.conversationId
: this.store.getConversationId(key);
// Propagate per-agent cron store path to CLI subprocesses (lettabot-schedule) // Propagate per-agent cron store path to CLI subprocesses (lettabot-schedule)
if (this.config.cronStorePath) { if (this.config.cronStorePath) {
process.env.CRON_STORE_PATH = 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; process.env.LETTA_AGENT_ID = this.store.agentId || undefined;
if (this.store.agentId) { if (this.store.agentId) {
installSkillsToAgent(this.store.agentId, this.config.skills); installSkillsToAgent(this.store.agentId, this.config.skills);
@@ -648,7 +657,11 @@ export class LettaBot implements AgentSession {
installSkillsToAgent(newAgentId, this.config.skills); installSkillsToAgent(newAgentId, this.config.skills);
sessionAgentId = newAgentId; 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() // 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'; const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined); this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined);
log.info('Agent ID updated:', session.agentId); 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. // 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. // 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') { if (convKey && convKey !== 'shared') {
const existing = this.store.getConversationId(convKey); const existing = this.store.getConversationId(convKey);
if (session.conversationId !== existing) { if (session.conversationId !== existing) {
@@ -1125,6 +1139,13 @@ export class LettaBot implements AgentSession {
// 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) : '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.clearConversation(convKey);
this.store.resetRecoveryAttempts(); this.store.resetRecoveryAttempts();
this.invalidateSession(convKey); this.invalidateSession(convKey);

View File

@@ -74,6 +74,21 @@ describe('resolveConversationKey', () => {
expect(resolveConversationKey('telegram', 'shared', new Set(), '12345')).toBe('shared'); expect(resolveConversationKey('telegram', 'shared', new Set(), '12345')).toBe('shared');
expect(resolveConversationKey('telegram', 'per-channel', new Set(), '12345')).toBe('telegram'); 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', () => { 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'); 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');
});
}); });

View File

@@ -169,7 +169,7 @@ export interface BotConfig {
cronStorePath?: string; // Resolved cron store path (per-agent in multi-agent mode) cronStorePath?: string; // Resolved cron store path (per-agent in multi-agent mode)
// Conversation routing // Conversation routing
conversationMode?: 'shared' | 'per-channel' | 'per-chat'; // Default: shared conversationMode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat'; // Default: shared
heartbeatConversation?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active) heartbeatConversation?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
conversationOverrides?: string[]; // Channels that always use their own conversation (shared mode) conversationOverrides?: string[]; // Channels that always use their own conversation (shared mode)
maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction) maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)