feat: add disabled conversation mode (#453)
This commit is contained in:
@@ -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>"
|
||||
|
||||
# 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" | "<channel>"
|
||||
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. |
|
||||
|
||||
@@ -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" | "<channel>" (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" | "<channel>" (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)
|
||||
|
||||
@@ -138,6 +138,7 @@ export function resolveConversationKey(
|
||||
conversationOverrides: Set<string>,
|
||||
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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" | "<channel>" (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)
|
||||
|
||||
Reference in New Issue
Block a user