From 0321558ee6f15989beffda8cac9dee74a5fa65bb Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 10 Mar 2026 11:51:02 -0700 Subject: [PATCH] feat: add config-driven sleeptime support with memfs guard (#534) Co-authored-by: Letta Code Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Cameron --- docs/configuration.md | 45 ++++++++++++++++ lettabot.example.yaml | 4 ++ src/config/io.ts | 11 ++++ src/config/normalize.test.ts | 72 +++++++++++++++++++++++++ src/config/types.ts | 38 ++++++++++++++ src/core/sdk-session-contract.test.ts | 75 +++++++++++++++++++++++++++ src/core/session-manager.ts | 2 + src/core/types.ts | 4 ++ src/main.ts | 14 +++++ 9 files changed, 265 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 4130761..679da2b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -589,6 +589,48 @@ Only files inside this directory (and its subdirectories) can be sent. Paths tha |-------|------|---------|-------------| | `features.sendFileDir` | string | _(workingDir)_ | Directory that `` paths must be inside | +### Sleeptime (Background Reflection) + +Sleeptime lets the agent reflect on recent interactions in the background, updating its memory without being prompted. It requires [memory filesystem](#memory-filesystem-memfs) (`memfs: true`) to be enabled -- if memfs is off, sleeptime is silently ignored with a startup warning. + +```yaml +features: + memfs: true + sleeptime: + trigger: step-count # "off" | "step-count" | "compaction-event" + behavior: reminder # "reminder" | "auto-launch" + stepCount: 10 # Steps between reflections (step-count trigger only) +``` + +**Triggers:** + +| Trigger | Description | +|---------|-------------| +| `off` | Disable sleeptime (explicit opt-out) | +| `step-count` | Reflect every N steps (configured via `stepCount`) | +| `compaction-event` | Reflect when the context window is compacted | + +**Behaviors:** + +| Behavior | Description | +|----------|-------------| +| `reminder` | Agent is reminded to reflect but can choose to skip | +| `auto-launch` | Reflection is launched automatically | + +Via environment variables (only used when `features.sleeptime` is not set in YAML): + +```bash +SLEEPTIME_TRIGGER=step-count +SLEEPTIME_BEHAVIOR=reminder +SLEEPTIME_STEP_COUNT=10 +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `features.sleeptime.trigger` | `'off'` \| `'step-count'` \| `'compaction-event'` | _(none)_ | When to trigger background reflection | +| `features.sleeptime.behavior` | `'reminder'` \| `'auto-launch'` | _(none)_ | How reflection is initiated | +| `features.sleeptime.stepCount` | number | _(none)_ | Steps between reflections (only used with `step-count` trigger) | + ### Cron Jobs ```yaml @@ -1015,6 +1057,9 @@ Reference: | `ALLOWED_TOOLS` | `features.allowedTools` (comma-separated list) | | `DISALLOWED_TOOLS` | `features.disallowedTools` (comma-separated list) | | `LETTABOT_WORKING_DIR` | Agent working directory (overridden by per-agent `workingDir`) | +| `SLEEPTIME_TRIGGER` | `features.sleeptime.trigger` (off/step-count/compaction-event) | +| `SLEEPTIME_BEHAVIOR` | `features.sleeptime.behavior` (reminder/auto-launch) | +| `SLEEPTIME_STEP_COUNT` | `features.sleeptime.stepCount` | | `TTS_PROVIDER` | TTS backend: `elevenlabs` (default) or `openai` | | `ELEVENLABS_API_KEY` | API key for ElevenLabs TTS | | `ELEVENLABS_VOICE_ID` | ElevenLabs voice ID (default: `onwK4e9ZLuTAKqWW03F9`) | diff --git a/lettabot.example.yaml b/lettabot.example.yaml index 42bb78b..7d1712f 100644 --- a/lettabot.example.yaml +++ b/lettabot.example.yaml @@ -84,6 +84,10 @@ features: # sendFileMaxSize: 52428800 # Max file size in bytes for (default: 50MB) # sendFileCleanup: false # Allow to delete files after send (default: false) # memfs: true # Enable memory filesystem (git-backed context repository). Syncs memory blocks to local files. + # sleeptime: # Background reflection (requires memfs: true) + # trigger: step-count # "off" | "step-count" | "compaction-event" + # behavior: reminder # "reminder" | "auto-launch" + # stepCount: 10 # Steps between reflections (step-count trigger only) # allowedTools: [Bash, Read, Edit, Write, Glob, Grep, Task, web_search, conversation_search] # Global default # disallowedTools: [EnterPlanMode, ExitPlanMode] # Global default # display: diff --git a/src/config/io.ts b/src/config/io.ts index 69244bf..66a6bf7 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -431,6 +431,17 @@ export function configToEnv(config: LettaBotConfig): Record { env.HEARTBEAT_SKIP_RECENT_USER_MIN = String(config.features.heartbeat.skipRecentUserMin); } } + if (config.features?.sleeptime) { + if (config.features.sleeptime.trigger) { + env.SLEEPTIME_TRIGGER = config.features.sleeptime.trigger; + } + if (config.features.sleeptime.behavior) { + env.SLEEPTIME_BEHAVIOR = config.features.sleeptime.behavior; + } + if (config.features.sleeptime.stepCount !== undefined) { + env.SLEEPTIME_STEP_COUNT = String(config.features.sleeptime.stepCount); + } + } if (config.features?.inlineImages === false) { env.INLINE_IMAGES = 'false'; } diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index 5c28876..4dcbca7 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -34,6 +34,7 @@ describe('normalizeAgents', () => { 'BLUESKY_NOTIFICATIONS_ENABLED', 'BLUESKY_NOTIFICATIONS_INTERVAL_SEC', 'BLUESKY_NOTIFICATIONS_LIMIT', 'BLUESKY_NOTIFICATIONS_PRIORITY', 'BLUESKY_NOTIFICATIONS_REASONS', 'HEARTBEAT_ENABLED', 'HEARTBEAT_INTERVAL_MIN', 'HEARTBEAT_SKIP_RECENT_USER_MIN', + 'SLEEPTIME_TRIGGER', 'SLEEPTIME_BEHAVIOR', 'SLEEPTIME_STEP_COUNT', 'CRON_ENABLED', ]; const savedEnv: Record = {}; @@ -392,6 +393,26 @@ describe('normalizeAgents', () => { }); }); + it('should pick up sleeptime from env vars when YAML features is empty', () => { + process.env.SLEEPTIME_TRIGGER = 'step-count'; + process.env.SLEEPTIME_BEHAVIOR = 'reminder'; + process.env.SLEEPTIME_STEP_COUNT = '25'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.sleeptime).toEqual({ + trigger: 'step-count', + behavior: 'reminder', + stepCount: 25, + }); + }); + it('should pick up cron from env vars when YAML features is empty', () => { process.env.CRON_ENABLED = 'true'; @@ -432,6 +453,30 @@ describe('normalizeAgents', () => { expect(agents[0].features?.maxToolCalls).toBe(50); }); + it('should merge env var sleeptime into existing YAML features', () => { + process.env.SLEEPTIME_TRIGGER = 'compaction-event'; + process.env.SLEEPTIME_BEHAVIOR = 'auto-launch'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + features: { + cron: true, + maxToolCalls: 50, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.sleeptime).toEqual({ + trigger: 'compaction-event', + behavior: 'auto-launch', + }); + expect(agents[0].features?.cron).toBe(true); + expect(agents[0].features?.maxToolCalls).toBe(50); + }); + it('should not override YAML heartbeat with env vars', () => { process.env.HEARTBEAT_ENABLED = 'true'; process.env.HEARTBEAT_INTERVAL_MIN = '99'; @@ -456,6 +501,33 @@ describe('normalizeAgents', () => { expect(agents[0].features?.heartbeat?.skipRecentUserMin).toBe(3); }); + it('should not override YAML sleeptime with env vars', () => { + process.env.SLEEPTIME_TRIGGER = 'step-count'; + process.env.SLEEPTIME_BEHAVIOR = 'reminder'; + process.env.SLEEPTIME_STEP_COUNT = '99'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + features: { + sleeptime: { + trigger: 'compaction-event', + behavior: 'auto-launch', + stepCount: 10, + }, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.sleeptime).toEqual({ + trigger: 'compaction-event', + behavior: 'auto-launch', + stepCount: 10, + }); + }); + it('should handle heartbeat env var with defaults when interval not set', () => { process.env.HEARTBEAT_ENABLED = 'true'; diff --git a/src/config/types.ts b/src/config/types.ts index d98d9e6..2ca7f7f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -40,6 +40,15 @@ export interface DisplayConfig { reasoningMaxChars?: number; } +export type SleeptimeTrigger = 'off' | 'step-count' | 'compaction-event'; +export type SleeptimeBehavior = 'reminder' | 'auto-launch'; + +export interface SleeptimeConfig { + trigger?: SleeptimeTrigger; + behavior?: SleeptimeBehavior; + stepCount?: number; +} + /** * Configuration for a single agent in multi-agent mode. * Each agent has its own name, channels, and features. @@ -84,6 +93,7 @@ export interface AgentConfig { target?: string; // Delivery target ("telegram:123", "slack:C123", etc.) }; memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions + sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) maxToolCalls?: number; sendFileDir?: string; // Restrict directive to this directory (default: data/outbound) sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) @@ -175,6 +185,7 @@ export interface LettaBotConfig { }; inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions + sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) sendFileDir?: string; // Restrict directive to this directory (default: data/outbound) sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) @@ -690,6 +701,33 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { }; } + const sleeptimeTriggerRaw = process.env.SLEEPTIME_TRIGGER; + const sleeptimeBehaviorRaw = process.env.SLEEPTIME_BEHAVIOR; + const sleeptimeStepCountRaw = process.env.SLEEPTIME_STEP_COUNT; + + const sleeptimeTrigger = sleeptimeTriggerRaw === 'off' + || sleeptimeTriggerRaw === 'step-count' + || sleeptimeTriggerRaw === 'compaction-event' + ? sleeptimeTriggerRaw + : undefined; + const sleeptimeBehavior = sleeptimeBehaviorRaw === 'reminder' + || sleeptimeBehaviorRaw === 'auto-launch' + ? sleeptimeBehaviorRaw + : undefined; + const sleeptimeStepCountParsed = sleeptimeStepCountRaw ? parseInt(sleeptimeStepCountRaw, 10) : undefined; + const sleeptimeStepCount = Number.isFinite(sleeptimeStepCountParsed) + && (sleeptimeStepCountParsed as number) > 0 + ? sleeptimeStepCountParsed + : undefined; + + if (!features.sleeptime && (sleeptimeTrigger || sleeptimeBehavior || sleeptimeStepCount)) { + features.sleeptime = { + ...(sleeptimeTrigger ? { trigger: sleeptimeTrigger } : {}), + ...(sleeptimeBehavior ? { behavior: sleeptimeBehavior } : {}), + ...(sleeptimeStepCount ? { stepCount: sleeptimeStepCount } : {}), + }; + } + // Only pass features if there's actually something set const hasFeatures = Object.keys(features).length > 0; diff --git a/src/core/sdk-session-contract.test.ts b/src/core/sdk-session-contract.test.ts index 0b341c3..0f2abf4 100644 --- a/src/core/sdk-session-contract.test.ts +++ b/src/core/sdk-session-contract.test.ts @@ -552,6 +552,72 @@ describe('SDK session contract', () => { expect(opts).not.toHaveProperty('memfs'); }); + it('passes sleeptime options to resumeSession when configured', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'assistant', content: 'ack' }; + yield { type: 'result', success: true }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conversation-contract-test', + }; + + vi.mocked(resumeSession).mockReturnValue(mockSession as never); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + sleeptime: { + trigger: 'step-count', + behavior: 'reminder', + stepCount: 25, + }, + }); + + await bot.sendToAgent('test'); + + const opts = vi.mocked(resumeSession).mock.calls[0][1]; + expect(opts).toHaveProperty('sleeptime'); + expect((opts as Record).sleeptime).toEqual({ + trigger: 'step-count', + behavior: 'reminder', + stepCount: 25, + }); + }); + + it('omits sleeptime key from resumeSession options when config sleeptime is undefined', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'assistant', content: 'ack' }; + yield { type: 'result', success: true }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conversation-contract-test', + }; + + vi.mocked(resumeSession).mockReturnValue(mockSession as never); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + }); + + await bot.sendToAgent('test'); + + const opts = vi.mocked(resumeSession).mock.calls[0][1]; + expect(opts).not.toHaveProperty('sleeptime'); + }); + it('keeps canUseTool callbacks isolated for concurrent keyed sessions', async () => { const store = new Store(undefined, 'LettaBot'); store.setAgent('agent-contract-test', 'https://api.letta.com'); @@ -1030,6 +1096,11 @@ describe('SDK session contract', () => { const bot = new LettaBot({ workingDir: join(dataDir, 'working'), allowedTools: [], + memfs: true, + sleeptime: { + trigger: 'compaction-event', + behavior: 'auto-launch', + }, }); await bot.sendToAgent('first message'); @@ -1038,6 +1109,10 @@ describe('SDK session contract', () => { expect(vi.mocked(createAgent)).toHaveBeenCalledWith( expect.objectContaining({ tags: ['origin:lettabot'], + sleeptime: { + trigger: 'compaction-event', + behavior: 'auto-launch', + }, }) ); }); diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index c4af2d6..4d9d1a3 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -161,6 +161,7 @@ export class SessionManager { tools: [createManageTodoTool(this.getTodoAgentKey())], // Memory filesystem (context repository): true -> --memfs, false -> --no-memfs, undefined -> leave unchanged ...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}), + ...(this.config.sleeptime ? { sleeptime: this.config.sleeptime } : {}), // In bypassPermissions mode, canUseTool is only called for interactive // tools (AskUserQuestion, ExitPlanMode). When no callback is provided // (background triggers), the SDK auto-denies interactive tools. @@ -273,6 +274,7 @@ export class SessionManager { memory: loadMemoryBlocks(this.config.agentName), tags: ['origin:lettabot'], ...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}), + ...(this.config.sleeptime ? { sleeptime: this.config.sleeptime } : {}), }); const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; this.store.setAgent(newAgentId, currentBaseUrl); diff --git a/src/core/types.ts b/src/core/types.ts index b9a0f2a..5afb65f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -132,6 +132,9 @@ export interface SkillsConfig { additionalSkills?: string[]; } +import type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig } from '../config/types.js'; +export type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig }; + /** * Bot configuration */ @@ -158,6 +161,7 @@ export interface BotConfig { // Memory filesystem (context repository) memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged + sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) // Security redaction?: import('./redact.js').RedactionConfig; diff --git a/src/main.ts b/src/main.ts index 91dbc16..fc94ca3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -303,6 +303,19 @@ async function main() { // Default false prevents the SDK from auto-enabling memfs, which crashes on // self-hosted Letta servers that don't have the git endpoint. const resolvedMemfs = agentConfig.features?.memfs ?? (process.env.LETTABOT_MEMFS === 'true' ? true : false); + const configuredSleeptime = agentConfig.features?.sleeptime; + // Treat missing trigger as active (conservative): only `trigger: 'off'` explicitly disables. + const sleeptimeRequiresMemfs = !!configuredSleeptime && configuredSleeptime.trigger !== 'off'; + const effectiveSleeptime = !resolvedMemfs && sleeptimeRequiresMemfs + ? undefined + : configuredSleeptime; + + if (!resolvedMemfs && sleeptimeRequiresMemfs) { + log.warn( + `Agent ${agentConfig.name}: sleeptime is configured but memfs is disabled; ` + + `sleeptime will be ignored. Enable features.memfs (or LETTABOT_MEMFS=true) to use sleeptime.` + ); + } // Create LettaBot for this agent const resolvedWorkingDir = agentConfig.workingDir @@ -327,6 +340,7 @@ async function main() { sendFileMaxSize: agentConfig.features?.sendFileMaxSize, sendFileCleanup: agentConfig.features?.sendFileCleanup, memfs: resolvedMemfs, + sleeptime: effectiveSleeptime, display: agentConfig.features?.display, conversationMode: agentConfig.conversations?.mode || 'shared', heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',