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:
@@ -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`) |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
src/main.ts
14
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',
|
||||
|
||||
Reference in New Issue
Block a user