fix: add env var fallback for heartbeat and cron features (#421)

This commit is contained in:
Cameron
2026-02-26 16:49:55 -08:00
committed by GitHub
parent 7658538b73
commit 338665ac48
2 changed files with 159 additions and 1 deletions

View File

@@ -33,6 +33,8 @@ describe('normalizeAgents', () => {
'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD', 'BLUESKY_SERVICE_URL', 'BLUESKY_APPVIEW_URL', 'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD', 'BLUESKY_SERVICE_URL', 'BLUESKY_APPVIEW_URL',
'BLUESKY_NOTIFICATIONS_ENABLED', 'BLUESKY_NOTIFICATIONS_INTERVAL_SEC', 'BLUESKY_NOTIFICATIONS_LIMIT', 'BLUESKY_NOTIFICATIONS_ENABLED', 'BLUESKY_NOTIFICATIONS_INTERVAL_SEC', 'BLUESKY_NOTIFICATIONS_LIMIT',
'BLUESKY_NOTIFICATIONS_PRIORITY', 'BLUESKY_NOTIFICATIONS_REASONS', 'BLUESKY_NOTIFICATIONS_PRIORITY', 'BLUESKY_NOTIFICATIONS_REASONS',
'HEARTBEAT_ENABLED', 'HEARTBEAT_INTERVAL_MIN', 'HEARTBEAT_SKIP_RECENT_USER_MIN',
'CRON_ENABLED',
]; ];
const savedEnv: Record<string, string | undefined> = {}; const savedEnv: Record<string, string | undefined> = {};
@@ -370,6 +372,135 @@ describe('normalizeAgents', () => {
expect(agents[0].channels.telegram).toBeUndefined(); 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', () => { it('should pick up all channel types from env vars', () => {
process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; process.env.TELEGRAM_BOT_TOKEN = 'tg-token';
process.env.SLACK_BOT_TOKEN = 'slack-bot'; process.env.SLACK_BOT_TOKEN = 'slack-bot';

View File

@@ -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<LettaBotConfig['features']>;
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 [{ return [{
name: agentName, name: agentName,
id, id,
@@ -646,7 +673,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
model, model,
channels, channels,
conversations: config.conversations, conversations: config.conversations,
features: config.features, features: hasFeatures ? features : config.features,
polling: config.polling, polling: config.polling,
integrations: config.integrations, integrations: config.integrations,
}]; }];