feat: add config-driven sleeptime support with memfs guard (#534)

Co-authored-by: Letta Code <noreply@letta.com>
Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Cameron <cpfiffer@users.noreply.github.com>
This commit is contained in:
Cameron
2026-03-10 11:51:02 -07:00
committed by GitHub
parent d3367e8c6a
commit 0321558ee6
9 changed files with 265 additions and 0 deletions

View File

@@ -589,6 +589,48 @@ Only files inside this directory (and its subdirectories) can be sent. Paths tha
|-------|------|---------|-------------|
| `features.sendFileDir` | string | _(workingDir)_ | Directory that `<send-file>` 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`) |

View File

@@ -84,6 +84,10 @@ features:
# sendFileMaxSize: 52428800 # Max file size in bytes for <send-file> (default: 50MB)
# sendFileCleanup: false # Allow <send-file cleanup="true"> 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:

View File

@@ -431,6 +431,17 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
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';
}

View File

@@ -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<string, string | undefined> = {};
@@ -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';

View File

@@ -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 <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (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 <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (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;

View File

@@ -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<string, unknown>).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',
},
})
);
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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',