From 338665ac4823d4309ca58215b51242ce4e4269d7 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 26 Feb 2026 16:49:55 -0800 Subject: [PATCH] fix: add env var fallback for heartbeat and cron features (#421) --- src/config/normalize.test.ts | 131 +++++++++++++++++++++++++++++++++++ src/config/types.ts | 29 +++++++- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index 86e71b7..5c28876 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -33,6 +33,8 @@ describe('normalizeAgents', () => { 'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD', 'BLUESKY_SERVICE_URL', 'BLUESKY_APPVIEW_URL', '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', + 'CRON_ENABLED', ]; const savedEnv: Record = {}; @@ -370,6 +372,135 @@ describe('normalizeAgents', () => { expect(agents[0].channels.telegram).toBeUndefined(); }); + it('should pick up heartbeat from env vars when YAML features is empty', () => { + process.env.HEARTBEAT_ENABLED = 'true'; + process.env.HEARTBEAT_INTERVAL_MIN = '15'; + process.env.HEARTBEAT_SKIP_RECENT_USER_MIN = '5'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.heartbeat).toEqual({ + enabled: true, + intervalMin: 15, + skipRecentUserMin: 5, + }); + }); + + it('should pick up cron from env vars when YAML features is empty', () => { + process.env.CRON_ENABLED = 'true'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.cron).toBe(true); + }); + + it('should merge env var heartbeat into existing YAML features', () => { + process.env.HEARTBEAT_ENABLED = 'true'; + process.env.HEARTBEAT_INTERVAL_MIN = '20'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + features: { + cron: true, + maxToolCalls: 50, + }, + }; + + const agents = normalizeAgents(config); + + // Env var heartbeat should merge in + expect(agents[0].features?.heartbeat).toEqual({ + enabled: true, + intervalMin: 20, + }); + // Existing YAML features should be preserved + 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'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + features: { + heartbeat: { + enabled: true, + intervalMin: 10, + skipRecentUserMin: 3, + }, + }, + }; + + const agents = normalizeAgents(config); + + // YAML values should win + expect(agents[0].features?.heartbeat?.intervalMin).toBe(10); + expect(agents[0].features?.heartbeat?.skipRecentUserMin).toBe(3); + }); + + it('should handle heartbeat env var with defaults when interval not set', () => { + process.env.HEARTBEAT_ENABLED = 'true'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.heartbeat).toEqual({ enabled: true }); + }); + + it('should not override YAML cron: false with env var', () => { + process.env.CRON_ENABLED = 'true'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + features: { + cron: false, + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.cron).toBe(false); + }); + + it('should not enable heartbeat when env var is not true', () => { + process.env.HEARTBEAT_ENABLED = 'false'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].features?.heartbeat).toBeUndefined(); + }); + it('should pick up all channel types from env vars', () => { process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; process.env.SLACK_BOT_TOKEN = 'slack-bot'; diff --git a/src/config/types.ts b/src/config/types.ts index 830bc5d..9a8fe53 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -639,6 +639,33 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { }; } + // Field-level env var fallback for features (heartbeat, cron). + // Unlike channels (all-or-nothing), features are independent toggles so we + // merge at the field level: env vars fill in fields missing from YAML. + const features = { ...config.features } as NonNullable; + + if (features.cron == null && process.env.CRON_ENABLED === 'true') { + features.cron = true; + } + + if (!features.heartbeat && process.env.HEARTBEAT_ENABLED === 'true') { + const intervalMin = process.env.HEARTBEAT_INTERVAL_MIN + ? parseInt(process.env.HEARTBEAT_INTERVAL_MIN, 10) + : undefined; + const skipRecentUserMin = process.env.HEARTBEAT_SKIP_RECENT_USER_MIN + ? parseInt(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN, 10) + : undefined; + + features.heartbeat = { + enabled: true, + ...(Number.isFinite(intervalMin) ? { intervalMin } : {}), + ...(Number.isFinite(skipRecentUserMin) ? { skipRecentUserMin } : {}), + }; + } + + // Only pass features if there's actually something set + const hasFeatures = Object.keys(features).length > 0; + return [{ name: agentName, id, @@ -646,7 +673,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { model, channels, conversations: config.conversations, - features: config.features, + features: hasFeatures ? features : config.features, polling: config.polling, integrations: config.integrations, }];