feat: unified lettabot initialization via lettactl (agents.yml) (#393)

Co-authored-by: Cameron <cameron@pfiffer.org>
This commit is contained in:
Nouamane Benbrahim
2026-03-03 17:13:13 -05:00
committed by GitHub
parent 743cb1fbc9
commit 94b7eea127
6 changed files with 990 additions and 22 deletions

View File

@@ -0,0 +1,354 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('../logger.js', () => ({
createLogger: () => ({
fatal: (...args: unknown[]) => console.error(...args),
error: (...args: unknown[]) => console.error(...args),
warn: (...args: unknown[]) => console.warn(...args),
info: (...args: unknown[]) => console.log(...args),
debug: (...args: unknown[]) => console.log(...args),
trace: (...args: unknown[]) => console.log(...args),
pino: {},
}),
}));
import {
isFleetConfig,
fleetConfigToLettaBotConfig,
wasLoadedFromFleetConfig,
setLoadedFromFleetConfig,
} from './fleet-adapter.js';
// ---------------------------------------------------------------------------
// isFleetConfig
// ---------------------------------------------------------------------------
describe('isFleetConfig', () => {
it('returns true for a fleet config with llm_config', () => {
expect(
isFleetConfig({
agents: [
{
name: 'bot',
llm_config: { model: 'gpt-4' },
system_prompt: { value: 'hi' },
lettabot: { channels: {} },
},
],
}),
).toBe(true);
});
it('returns true for a fleet config with system_prompt only', () => {
expect(
isFleetConfig({
agents: [{ name: 'bot', system_prompt: { value: 'hi' }, lettabot: { channels: {} } }],
}),
).toBe(true);
});
it('returns false for native LettaBot single-agent config', () => {
expect(
isFleetConfig({
server: { mode: 'api' },
agent: { name: 'Bot' },
channels: { telegram: { enabled: true, token: 'abc' } },
}),
).toBe(false);
});
it('returns false for native LettaBot multi-agent config', () => {
expect(
isFleetConfig({
server: { mode: 'api' },
agents: [
{ name: 'Bot1', channels: { telegram: { enabled: true } } },
],
}),
).toBe(false);
});
it('returns false when fleet-only fields exist but lettabot section is missing', () => {
expect(
isFleetConfig({
agents: [{ name: 'bot', llm_config: { model: 'gpt-4' }, system_prompt: { value: 'hi' } }],
}),
).toBe(false);
});
it('returns false when lettabot is an array', () => {
expect(
isFleetConfig({
agents: [{ name: 'bot', llm_config: { model: 'gpt-4' }, lettabot: [] }],
}),
).toBe(false);
});
it('returns false for null', () => {
expect(isFleetConfig(null)).toBe(false);
});
it('returns false for empty object', () => {
expect(isFleetConfig({})).toBe(false);
});
it('returns false for empty agents array', () => {
expect(isFleetConfig({ agents: [] })).toBe(false);
});
it('returns false for an array', () => {
expect(isFleetConfig([{ llm_config: {} }])).toBe(false);
});
});
// ---------------------------------------------------------------------------
// fleetConfigToLettaBotConfig — single agent
// ---------------------------------------------------------------------------
describe('fleetConfigToLettaBotConfig (single agent)', () => {
it('converts a single qualifying agent to single-agent format', () => {
const fleet = {
agents: [
{
name: 'MyBot',
description: 'Test bot',
llm_config: { model: 'gpt-4', context_window: 128000 },
system_prompt: { value: 'You are helpful' },
tools: ['web_search'],
lettabot: {
server: { mode: 'docker', baseUrl: 'http://localhost:8283' },
displayName: 'Bot',
channels: {
telegram: { enabled: true, token: 'tg-token', dmPolicy: 'open' },
},
features: { cron: true },
},
},
],
};
const result = fleetConfigToLettaBotConfig(fleet);
expect(result.agent.name).toBe('MyBot');
expect(result.agent.displayName).toBe('Bot');
expect(result.server.mode).toBe('docker');
expect(result.server.baseUrl).toBe('http://localhost:8283');
expect(result.channels.telegram?.token).toBe('tg-token');
expect(result.channels.telegram?.dmPolicy).toBe('open');
expect(result.features?.cron).toBe(true);
// lettactl-only fields should NOT be present
expect((result as any).description).toBeUndefined();
expect((result as any).llm_config).toBeUndefined();
expect((result as any).system_prompt).toBeUndefined();
expect((result as any).tools).toBeUndefined();
});
it('maps all lettabot fields correctly', () => {
const fleet = {
agents: [
{
name: 'FullBot',
llm_config: { model: 'gpt-4' },
lettabot: {
server: { mode: 'api', apiKey: 'sk-test', logLevel: 'debug' },
displayName: 'Full',
conversations: { mode: 'per-channel', heartbeat: 'dedicated' },
channels: {
slack: { enabled: true, appToken: 'xapp-1', botToken: 'xoxb-1' },
},
features: {
heartbeat: { enabled: true, intervalMin: 15 },
maxToolCalls: 50,
display: { showToolCalls: true },
},
providers: [{ id: 'anthropic', name: 'anthropic', type: 'anthropic', apiKey: 'sk-ant' }],
polling: { gmail: { enabled: true, account: 'user@gmail.com' } },
transcription: { provider: 'openai', apiKey: 'sk-oai' },
attachments: { maxMB: 10, maxAgeDays: 7 },
tts: { provider: 'openai', apiKey: 'sk-openai-tts', voiceId: 'alloy', model: 'gpt-4o-mini-tts' },
integrations: { google: { enabled: true, account: 'user@gmail.com' } },
security: { redaction: { secrets: true, pii: true } },
},
},
],
};
const result = fleetConfigToLettaBotConfig(fleet);
expect(result.server.apiKey).toBe('sk-test');
expect(result.server.logLevel).toBe('debug');
expect(result.conversations?.mode).toBe('per-channel');
expect(result.conversations?.heartbeat).toBe('dedicated');
expect(result.channels.slack?.appToken).toBe('xapp-1');
expect(result.features?.heartbeat?.intervalMin).toBe(15);
expect(result.features?.maxToolCalls).toBe(50);
expect(result.features?.display?.showToolCalls).toBe(true);
expect(result.providers).toHaveLength(1);
expect(result.providers![0].apiKey).toBe('sk-ant');
expect(result.polling?.gmail?.account).toBe('user@gmail.com');
expect(result.transcription?.provider).toBe('openai');
expect(result.attachments?.maxMB).toBe(10);
expect(result.tts?.provider).toBe('openai');
expect(result.integrations?.google?.enabled).toBe(true);
expect(result.security?.redaction?.secrets).toBe(true);
});
it('skips agents without lettabot section', () => {
const fleet = {
agents: [
{
name: 'ServerOnly',
llm_config: { model: 'gpt-4' },
system_prompt: { value: 'no lettabot' },
},
{
name: 'WithBot',
llm_config: { model: 'gpt-4' },
system_prompt: { value: 'has lettabot' },
lettabot: {
channels: { telegram: { enabled: true, token: 'tg' } },
},
},
],
};
const result = fleetConfigToLettaBotConfig(fleet);
expect(result.agent.name).toBe('WithBot');
expect(result.channels.telegram?.token).toBe('tg');
// Should be single-agent format (not multi-agent) since only one qualifies
expect(result.agents).toBeUndefined();
});
it('throws when an agent with lettabot section is missing name', () => {
const fleet = {
agents: [
{
llm_config: { model: 'gpt-4' },
lettabot: { channels: {} },
},
],
};
expect(() => fleetConfigToLettaBotConfig(fleet as any)).toThrow(/missing required `name`/);
});
it('ignores agents with array-typed lettabot section', () => {
const fleet = {
agents: [
{ name: 'Bad', llm_config: { model: 'gpt-4' }, lettabot: [] },
{ name: 'Good', llm_config: { model: 'gpt-4' }, lettabot: { channels: {} } },
],
};
const result = fleetConfigToLettaBotConfig(fleet as any);
expect(result.agent.name).toBe('Good');
});
it('throws when no agents have lettabot section', () => {
const fleet = {
agents: [
{ name: 'Bot1', llm_config: { model: 'gpt-4' } },
{ name: 'Bot2', llm_config: { model: 'gpt-4' } },
],
};
expect(() => fleetConfigToLettaBotConfig(fleet)).toThrow(
/No agents in fleet config have a `lettabot:` section/,
);
});
it('defaults to empty channels when lettabot has no channels', () => {
const fleet = {
agents: [
{
name: 'MinimalBot',
llm_config: { model: 'gpt-4' },
lettabot: {
server: { mode: 'docker', baseUrl: 'http://localhost:8283' },
},
},
],
};
const result = fleetConfigToLettaBotConfig(fleet);
expect(result.channels).toEqual({});
});
});
// ---------------------------------------------------------------------------
// fleetConfigToLettaBotConfig — multi-agent
// ---------------------------------------------------------------------------
describe('fleetConfigToLettaBotConfig (multi-agent)', () => {
it('converts multiple qualifying agents to multi-agent format', () => {
const fleet = {
agents: [
{
name: 'Bot1',
llm_config: { model: 'gpt-4' },
lettabot: {
server: { mode: 'docker', baseUrl: 'http://localhost:8283' },
displayName: 'First',
channels: { telegram: { enabled: true, token: 'tg1' } },
providers: [{ id: 'oai', name: 'openai', type: 'openai', apiKey: 'sk-1' }],
transcription: { provider: 'openai' },
attachments: { maxMB: 5 },
tts: { provider: 'openai' },
integrations: { google: { enabled: true, account: 'multi@gmail.com' } },
security: { redaction: { secrets: true } },
},
},
{
name: 'Bot2',
llm_config: { model: 'claude-3' },
lettabot: {
displayName: 'Second',
channels: { slack: { enabled: true, appToken: 'xapp', botToken: 'xoxb' } },
features: { cron: true },
},
},
],
};
const result = fleetConfigToLettaBotConfig(fleet);
// Should be multi-agent format
expect(result.agents).toHaveLength(2);
expect(result.agents![0].name).toBe('Bot1');
expect(result.agents![0].displayName).toBe('First');
expect(result.agents![0].channels.telegram?.token).toBe('tg1');
expect(result.agents![1].name).toBe('Bot2');
expect(result.agents![1].displayName).toBe('Second');
expect(result.agents![1].channels.slack?.appToken).toBe('xapp');
expect(result.agents![1].features?.cron).toBe(true);
expect(result.agents![0].security?.redaction?.secrets).toBe(true);
// System-wide fields promoted from first agent
expect(result.server.mode).toBe('docker');
expect(result.server.baseUrl).toBe('http://localhost:8283');
expect(result.providers).toHaveLength(1);
expect(result.transcription?.provider).toBe('openai');
expect(result.attachments?.maxMB).toBe(5);
expect(result.tts?.provider).toBe('openai');
expect(result.integrations?.google?.enabled).toBe(true);
expect(result.agent.name).toBe('LettaBot');
});
});
// ---------------------------------------------------------------------------
// wasLoadedFromFleetConfig / setLoadedFromFleetConfig
// ---------------------------------------------------------------------------
describe('wasLoadedFromFleetConfig', () => {
it('defaults to false', () => {
setLoadedFromFleetConfig(false);
expect(wasLoadedFromFleetConfig()).toBe(false);
});
it('can be set to true', () => {
setLoadedFromFleetConfig(true);
expect(wasLoadedFromFleetConfig()).toBe(true);
// Clean up
setLoadedFromFleetConfig(false);
});
});

207
src/config/fleet-adapter.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* Fleet Config Adapter
*
* Detects and transforms lettactl's agents.yml (fleet format) into
* LettaBot's native config shape so users can define everything in one file.
*
* Fleet format is identified by the presence of `agents[]` entries that contain
* lettactl-only fields like `llm_config` or `system_prompt`.
*/
import type { LettaBotConfig, AgentConfig } from './types.js';
// ---------------------------------------------------------------------------
// Fleet-loaded flag
// ---------------------------------------------------------------------------
let _loadedFromFleetConfig = false;
export function wasLoadedFromFleetConfig(): boolean {
return _loadedFromFleetConfig;
}
export function setLoadedFromFleetConfig(value: boolean): void {
_loadedFromFleetConfig = value;
}
// ---------------------------------------------------------------------------
// Detection
// ---------------------------------------------------------------------------
/**
* Returns true when `parsed` looks like a fleet config (lettactl agents.yml)
* rather than a native lettabot.yaml.
*
* Fleet configs have an `agents[]` array whose entries carry lettactl-only
* fields (`llm_config`, `system_prompt`) that LettaBot's native format never
* uses.
*/
export function isFleetConfig(parsed: unknown): boolean {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return false;
}
const obj = parsed as Record<string, unknown>;
const agents = obj.agents;
if (!Array.isArray(agents) || agents.length === 0) {
return false;
}
// At least one entry must look like a lettactl agent and include
// a lettabot runtime section so unrelated agents.yml files are ignored.
return agents.some((a: unknown) => {
if (!a || typeof a !== 'object' || Array.isArray(a)) {
return false;
}
const candidate = a as Record<string, unknown>;
const hasFleetOnlyFields = 'llm_config' in candidate || 'system_prompt' in candidate;
const lettabot = candidate.lettabot;
const hasLettaBotSection = !!lettabot && typeof lettabot === 'object' && !Array.isArray(lettabot);
return hasFleetOnlyFields && hasLettaBotSection;
});
}
// ---------------------------------------------------------------------------
// Transformation
// ---------------------------------------------------------------------------
interface FleetAgent {
name?: string;
description?: string;
llm_config?: unknown;
system_prompt?: unknown;
embedding?: unknown;
tools?: unknown;
mcp_tools?: unknown;
shared_blocks?: unknown;
memory_blocks?: unknown;
archives?: unknown;
folders?: unknown;
shared_folders?: unknown;
embedding_config?: unknown;
first_message?: unknown;
reasoning?: unknown;
tags?: unknown;
lettabot?: Record<string, unknown>;
}
/**
* Transform a fleet config object into a LettaBot native config.
*
* - Filters to agents that have a `lettabot:` section
* - Throws if no agents qualify
* - Single qualifying agent -> single-agent format (agent: + top-level fields)
* - Multiple qualifying agents -> multi-agent format (agents[])
* - lettactl-only fields are dropped
*/
export function fleetConfigToLettaBotConfig(
parsed: Record<string, unknown>,
): LettaBotConfig {
const rawAgents = parsed.agents as FleetAgent[];
for (const agent of rawAgents) {
if (!agent || typeof agent !== 'object' || Array.isArray(agent)) {
continue;
}
if (!agent.lettabot) {
continue;
}
if (!agent.name || !agent.name.trim()) {
throw new Error(
'Fleet config agent is missing required `name`. Add `name` to each agent with a `lettabot:` section.',
);
}
}
const qualifying = rawAgents.filter(
(a) => !!a.lettabot && typeof a.lettabot === 'object' && !Array.isArray(a.lettabot),
);
if (qualifying.length === 0) {
throw new Error(
'No agents in fleet config have a `lettabot:` section. ' +
'Add a `lettabot:` block to at least one agent in agents.yml.',
);
}
if (qualifying.length === 1) {
return buildSingleAgentConfig(qualifying[0]);
}
return buildMultiAgentConfig(qualifying);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractLettabotFields(lb: Record<string, unknown>) {
return {
server: lb.server as LettaBotConfig['server'] | undefined,
displayName: lb.displayName as string | undefined,
conversations: lb.conversations as LettaBotConfig['conversations'],
channels: lb.channels as LettaBotConfig['channels'],
features: lb.features as LettaBotConfig['features'],
providers: lb.providers as LettaBotConfig['providers'],
polling: lb.polling as LettaBotConfig['polling'],
transcription: lb.transcription as LettaBotConfig['transcription'],
attachments: lb.attachments as LettaBotConfig['attachments'],
tts: lb.tts as LettaBotConfig['tts'],
integrations: lb.integrations as LettaBotConfig['integrations'],
security: lb.security as LettaBotConfig['security'],
};
}
function buildSingleAgentConfig(agent: FleetAgent): LettaBotConfig {
const lb = extractLettabotFields(agent.lettabot!);
return {
server: { mode: 'api', ...lb.server },
agent: {
name: agent.name!,
displayName: lb.displayName,
},
channels: lb.channels ?? {},
conversations: lb.conversations,
features: lb.features,
providers: lb.providers,
polling: lb.polling,
transcription: lb.transcription,
attachments: lb.attachments,
tts: lb.tts,
integrations: lb.integrations,
security: lb.security,
};
}
function buildMultiAgentConfig(agents: FleetAgent[]): LettaBotConfig {
// Server, providers, transcription, attachments are promoted from the first
// qualifying agent (they are system-wide settings, not per-agent).
const firstLb = extractLettabotFields(agents[0].lettabot!);
const nativeAgents: AgentConfig[] = agents.map((agent) => {
const lb = extractLettabotFields(agent.lettabot!);
return {
name: agent.name!,
displayName: lb.displayName,
channels: lb.channels ?? {},
conversations: lb.conversations,
features: lb.features,
polling: lb.polling,
security: lb.security,
};
});
return {
server: { mode: 'api', ...firstLb.server },
agent: { name: 'LettaBot' },
channels: {},
agents: nativeAgents,
providers: firstLb.providers,
transcription: firstLb.transcription,
attachments: firstLb.attachments,
tts: firstLb.tts,
integrations: firstLb.integrations,
};
}

View File

@@ -1,3 +1,4 @@
export * from './types.js';
export * from './io.js';
export * from './runtime.js';
export { wasLoadedFromFleetConfig } from './fleet-adapter.js';

View File

@@ -19,6 +19,7 @@ vi.mock('../logger.js', () => ({
import { saveConfig, loadConfig, loadConfigStrict, configToEnv, didLoadFail } from './io.js';
import { normalizeAgents, DEFAULT_CONFIG } from './types.js';
import { wasLoadedFromFleetConfig, setLoadedFromFleetConfig } from './fleet-adapter.js';
import type { LettaBotConfig } from './types.js';
describe('saveConfig with agents[] format', () => {
@@ -449,3 +450,235 @@ describe('loadConfigStrict', () => {
}
});
});
describe('loadConfig with fleet config', () => {
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-fleet-test-'));
originalEnv = process.env.LETTABOT_CONFIG;
delete process.env.LETTABOT_CONFIG_YAML;
setLoadedFromFleetConfig(false);
});
afterEach(() => {
process.env.LETTABOT_CONFIG = originalEnv;
delete process.env.LETTABOT_CONFIG_YAML;
setLoadedFromFleetConfig(false);
rmSync(tmpDir, { recursive: true, force: true });
});
it('should load fleet config (agents.yml) and convert to LettaBot format', () => {
const fleetYaml = YAML.stringify({
agents: [
{
name: 'TestBot',
description: 'A test agent',
llm_config: { model: 'gpt-4', context_window: 128000 },
system_prompt: { value: 'You are helpful' },
lettabot: {
server: { mode: 'docker', baseUrl: 'http://localhost:8283' },
displayName: 'Testy',
channels: {
telegram: { enabled: true, token: 'tg-fleet-token', dmPolicy: 'open' },
},
features: { cron: true },
},
},
],
});
const configPath = join(tmpDir, 'agents.yml');
writeFileSync(configPath, fleetYaml, 'utf-8');
process.env.LETTABOT_CONFIG = configPath;
const config = loadConfig();
expect(config.agent.name).toBe('TestBot');
expect(config.agent.displayName).toBe('Testy');
expect(config.server.mode).toBe('docker');
expect(config.server.baseUrl).toBe('http://localhost:8283');
expect(config.channels.telegram?.token).toBe('tg-fleet-token');
expect(config.features?.cron).toBe(true);
expect(wasLoadedFromFleetConfig()).toBe(true);
// lettactl-only fields should not leak through
expect((config as any).llm_config).toBeUndefined();
expect((config as any).system_prompt).toBeUndefined();
});
it('should reset wasLoadedFromFleetConfig to false when config file is missing', () => {
process.env.LETTABOT_CONFIG = join(tmpDir, 'missing.yaml');
setLoadedFromFleetConfig(true);
loadConfig();
expect(wasLoadedFromFleetConfig()).toBe(false);
});
it('should set wasLoadedFromFleetConfig to false for native config', () => {
const configPath = join(tmpDir, 'lettabot.yaml');
writeFileSync(
configPath,
'server:\n mode: api\nagent:\n name: NativeBot\nchannels: {}\n',
'utf-8',
);
process.env.LETTABOT_CONFIG = configPath;
loadConfig();
expect(wasLoadedFromFleetConfig()).toBe(false);
});
it('lettabot.yaml should take priority over agents.yml', () => {
// Write both files in an isolated cwd and rely on resolveConfigPath() scanning.
const nativePath = join(tmpDir, 'lettabot.yaml');
writeFileSync(
nativePath,
YAML.stringify({
server: { mode: 'api' },
agent: { name: 'NativeWins' },
channels: {},
}),
'utf-8',
);
writeFileSync(
join(tmpDir, 'agents.yml'),
YAML.stringify({
agents: [{
name: 'FleetLoses',
llm_config: { model: 'gpt-4' },
system_prompt: { value: 'hi' },
lettabot: { channels: {} },
}],
}),
'utf-8',
);
const originalCwd = process.cwd();
delete process.env.LETTABOT_CONFIG;
delete process.env.LETTABOT_CONFIG_YAML;
process.chdir(tmpDir);
try {
const config = loadConfig();
expect(config.agent.name).toBe('NativeWins');
expect(wasLoadedFromFleetConfig()).toBe(false);
} finally {
process.chdir(originalCwd);
}
});
it('should not treat agents.yml without lettabot section as fleet config', () => {
const configPath = join(tmpDir, 'agents.yml');
writeFileSync(
configPath,
YAML.stringify({
agents: [
{
name: 'ServerOnly',
llm_config: { model: 'gpt-4' },
system_prompt: { value: 'hi' },
},
],
}),
'utf-8',
);
process.env.LETTABOT_CONFIG = configPath;
const config = loadConfig();
expect(config.agent.name).toBe(DEFAULT_CONFIG.agent.name);
expect(wasLoadedFromFleetConfig()).toBe(false);
});
it('should not throw via loadConfigStrict when agents.yml has no lettabot sections', () => {
const configPath = join(tmpDir, 'agents.yml');
writeFileSync(
configPath,
YAML.stringify({
agents: [
{ name: 'Bot1', llm_config: { model: 'gpt-4' }, system_prompt: { value: 'hi' } },
],
}),
'utf-8',
);
process.env.LETTABOT_CONFIG = configPath;
const config = loadConfigStrict();
expect(config.agent.name).toBe(DEFAULT_CONFIG.agent.name);
expect(wasLoadedFromFleetConfig()).toBe(false);
});
it('should preserve large discord group IDs from fleet config', () => {
const configPath = join(tmpDir, 'agents.yml');
writeFileSync(
configPath,
[
'agents:',
' - name: SnowflakeBot',
' llm_config:',
' model: gpt-4',
' system_prompt:',
' value: hi',
' lettabot:',
' channels:',
' discord:',
' enabled: true',
' token: test-token',
' instantGroups:',
' - 123456789012345678',
' groups:',
' 123456789012345678:',
' mode: listen',
].join('\n'),
'utf-8',
);
process.env.LETTABOT_CONFIG = configPath;
const config = loadConfig();
expect(config.channels.discord?.instantGroups?.[0]).toBe('123456789012345678');
expect(config.channels.discord?.groups?.['123456789012345678']).toEqual({ mode: 'listen' });
});
it('should handle multi-agent fleet config', () => {
const configPath = join(tmpDir, 'agents.yml');
writeFileSync(
configPath,
YAML.stringify({
agents: [
{
name: 'Agent1',
llm_config: { model: 'gpt-4' },
system_prompt: { value: 'First' },
lettabot: {
server: { mode: 'docker', baseUrl: 'http://localhost:8283' },
channels: { telegram: { enabled: true, token: 'tg1' } },
},
},
{
name: 'Agent2',
llm_config: { model: 'claude-3' },
system_prompt: { value: 'Second' },
lettabot: {
channels: { slack: { enabled: true, appToken: 'xapp', botToken: 'xoxb' } },
},
},
],
}),
'utf-8',
);
process.env.LETTABOT_CONFIG = configPath;
const config = loadConfig();
expect(config.agents).toHaveLength(2);
expect(config.agents![0].name).toBe('Agent1');
expect(config.agents![1].name).toBe('Agent2');
expect(config.server.mode).toBe('docker');
expect(wasLoadedFromFleetConfig()).toBe(true);
});
});

View File

@@ -5,7 +5,8 @@
* 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64-encoded YAML)
* 2. LETTABOT_CONFIG env var (file path)
* 3. ./lettabot.yaml or ./lettabot.yml (project-local)
* 4. ~/.lettabot/config.yaml or ~/.lettabot/config.yml (user global)
* 4. ./agents.yml or ./agents.yaml (fleet config from lettactl)
* 5. ~/.lettabot/config.yaml or ~/.lettabot/config.yml (user global)
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -14,18 +15,23 @@ import { dirname, join, resolve } from 'node:path';
import YAML from 'yaml';
import type { LettaBotConfig, ProviderConfig } from './types.js';
import { DEFAULT_CONFIG, canonicalizeServerMode, isApiServerMode, isDockerServerMode } from './types.js';
import { isFleetConfig, fleetConfigToLettaBotConfig, setLoadedFromFleetConfig } from './fleet-adapter.js';
import { LETTA_API_URL } from '../auth/oauth.js';
import { createLogger } from '../logger.js';
const log = createLogger('Config');
// Config file locations (checked in order)
const CONFIG_PATHS = [
function getConfigPaths(): string[] {
return [
resolve(process.cwd(), 'lettabot.yaml'), // Project-local
resolve(process.cwd(), 'lettabot.yml'), // Project-local alt
resolve(process.cwd(), 'agents.yml'), // Fleet config
resolve(process.cwd(), 'agents.yaml'), // Fleet config alt
join(homedir(), '.lettabot', 'config.yaml'), // User global
join(homedir(), '.lettabot', 'config.yml'), // User global alt
];
];
}
const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml');
@@ -81,8 +87,10 @@ export function encodeConfigForEnv(yamlContent: string): string {
* 1. LETTABOT_CONFIG env var (explicit override)
* 2. ./lettabot.yaml (project-local)
* 3. ./lettabot.yml (project-local alt)
* 4. ~/.lettabot/config.yaml (user global)
* 5. ~/.lettabot/config.yml (user global alt)
* 4. ./agents.yml (fleet config from lettactl)
* 5. ./agents.yaml (fleet config alt)
* 6. ~/.lettabot/config.yaml (user global)
* 7. ~/.lettabot/config.yml (user global alt)
*/
export function resolveConfigPath(): string {
// Environment variable takes priority
@@ -90,7 +98,7 @@ export function resolveConfigPath(): string {
return resolve(process.env.LETTABOT_CONFIG);
}
for (const p of CONFIG_PATHS) {
for (const p of getConfigPaths()) {
if (existsSync(p)) {
return p;
}
@@ -110,16 +118,50 @@ function hasObject(value: unknown): value is Record<string, unknown> {
}
function parseAndNormalizeConfig(content: string): LettaBotConfig {
const parsed = YAML.parse(content) as Partial<LettaBotConfig>;
const parsed = YAML.parse(content);
// Fleet config detection: agents.yml from lettactl with llm_config/system_prompt
if (isFleetConfig(parsed)) {
const parsedFleet = parsed as Record<string, unknown>;
// Preserve large numeric IDs under agents[].lettabot.channels before conversion.
fixLargeGroupIdsInFleetConfig(content, parsedFleet);
const converted = fleetConfigToLettaBotConfig(parsedFleet);
setLoadedFromFleetConfig(true);
// Safety pass on converted top-level channels (single-agent format).
fixLargeGroupIds(content, converted);
// Merge with defaults and canonicalize server mode (same as native path)
const merged = {
...DEFAULT_CONFIG,
...converted,
server: { ...DEFAULT_CONFIG.server, ...converted.server },
agent: { ...DEFAULT_CONFIG.agent, ...converted.agent },
channels: { ...DEFAULT_CONFIG.channels, ...converted.channels },
};
return {
...merged,
server: {
...merged.server,
mode: canonicalizeServerMode(merged.server.mode),
},
};
}
setLoadedFromFleetConfig(false);
const typedParsed = parsed as Partial<LettaBotConfig>;
// Fix instantGroups: YAML parses large numeric IDs (e.g. Discord snowflakes)
// as JavaScript numbers, losing precision for values > Number.MAX_SAFE_INTEGER.
// Re-extract from document AST to preserve the original string representation.
fixLargeGroupIds(content, parsed);
fixLargeGroupIds(content, typedParsed);
// Reject ambiguous API server configuration. During migration from top-level
// `api` to `server.api`, having both can silently drop fields.
if (hasObject(parsed.api) && hasObject(parsed.server) && hasObject(parsed.server.api)) {
if (hasObject(typedParsed.api) && hasObject(typedParsed.server) && hasObject(typedParsed.server.api)) {
throw new Error(
'Conflicting API config: both top-level `api` and `server.api` are set. Remove top-level `api` and keep only `server.api`.'
);
@@ -128,10 +170,10 @@ function parseAndNormalizeConfig(content: string): LettaBotConfig {
// Merge with defaults and canonicalize server mode.
const merged = {
...DEFAULT_CONFIG,
...parsed,
server: { ...DEFAULT_CONFIG.server, ...parsed.server },
agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent },
channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels },
...typedParsed,
server: { ...DEFAULT_CONFIG.server, ...typedParsed.server },
agent: { ...DEFAULT_CONFIG.agent, ...typedParsed.agent },
channels: { ...DEFAULT_CONFIG.channels, ...typedParsed.channels },
};
const config = {
@@ -155,6 +197,7 @@ function parseAndNormalizeConfig(content: string): LettaBotConfig {
*/
export function loadConfig(): LettaBotConfig {
_lastLoadFailed = false;
setLoadedFromFleetConfig(false);
// Inline config takes priority over file-based config
if (hasInlineConfig()) {
@@ -192,6 +235,7 @@ export function loadConfig(): LettaBotConfig {
*/
export function loadConfigStrict(): LettaBotConfig {
_lastLoadFailed = false;
setLoadedFromFleetConfig(false);
// Inline config takes priority over file-based config
if (hasInlineConfig()) {
@@ -548,6 +592,130 @@ export async function syncProviders(config: Partial<LettaBotConfig> & Pick<Letta
}
}
/**
* Fleet config variant of large group ID preservation.
* Targets: agents[].lettabot.channels.*.{instantGroups,listeningGroups,groups}
*/
function fixLargeGroupIdsInFleetConfig(yamlContent: string, parsed: Record<string, unknown>): void {
const channels = ['telegram', 'slack', 'whatsapp', 'signal', 'discord'] as const;
const groupFields = ['instantGroups', 'listeningGroups'] as const;
const rawAgents = parsed.agents;
if (!Array.isArray(rawAgents)) return;
try {
const doc = YAML.parseDocument(yamlContent);
for (let i = 0; i < rawAgents.length; i += 1) {
const rawAgent = rawAgents[i];
if (!rawAgent || typeof rawAgent !== 'object' || Array.isArray(rawAgent)) continue;
const agent = rawAgent as Record<string, unknown>;
const rawLettabot = agent.lettabot;
if (!rawLettabot || typeof rawLettabot !== 'object' || Array.isArray(rawLettabot)) continue;
const lettabot = rawLettabot as Record<string, unknown>;
const rawChannels = lettabot.channels;
if (!rawChannels || typeof rawChannels !== 'object' || Array.isArray(rawChannels)) continue;
const channelsConfig = rawChannels as Record<string, unknown>;
for (const ch of channels) {
const rawChannelCfg = channelsConfig[ch];
if (!rawChannelCfg || typeof rawChannelCfg !== 'object' || Array.isArray(rawChannelCfg)) continue;
const channelCfg = rawChannelCfg as Record<string, unknown>;
for (const field of groupFields) {
const seq = doc.getIn(['agents', i, 'lettabot', 'channels', ch, field], true);
if (YAML.isSeq(seq)) {
channelCfg[field] = seq.items.map((item: unknown) => {
if (YAML.isScalar(item)) {
if (typeof item.value === 'number' && item.source) {
return item.source;
}
return String(item.value);
}
return String(item);
});
}
}
const groupsNode = doc.getIn(['agents', i, 'lettabot', 'channels', ch, 'groups'], true);
if (YAML.isMap(groupsNode)) {
const fixedGroups: Record<string, unknown> = {};
for (const pair of groupsNode.items) {
const keyNode = (pair as { key?: unknown }).key;
const valueNode = (pair as { value?: unknown }).value;
let groupKey: string;
if (YAML.isScalar(keyNode)) {
if (typeof keyNode.value === 'number' && keyNode.source) {
groupKey = keyNode.source;
} else {
groupKey = String(keyNode.value);
}
} else {
groupKey = String(keyNode);
}
if (YAML.isMap(valueNode)) {
const groupConfig: Record<string, unknown> = {};
for (const settingPair of valueNode.items) {
const settingKeyNode = (settingPair as { key?: unknown }).key;
const settingValueNode = (settingPair as { value?: unknown }).value;
const settingKey = YAML.isScalar(settingKeyNode)
? String(settingKeyNode.value)
: String(settingKeyNode);
if (YAML.isScalar(settingValueNode)) {
groupConfig[settingKey] = settingValueNode.value;
} else {
groupConfig[settingKey] = settingValueNode as unknown;
}
}
fixedGroups[groupKey] = groupConfig;
} else if (YAML.isScalar(valueNode)) {
fixedGroups[groupKey] = valueNode.value;
} else {
fixedGroups[groupKey] = valueNode as unknown;
}
}
channelCfg.groups = fixedGroups;
}
}
}
} catch {
for (const rawAgent of rawAgents) {
if (!rawAgent || typeof rawAgent !== 'object' || Array.isArray(rawAgent)) continue;
const agent = rawAgent as Record<string, unknown>;
const rawLettabot = agent.lettabot;
if (!rawLettabot || typeof rawLettabot !== 'object' || Array.isArray(rawLettabot)) continue;
const lettabot = rawLettabot as Record<string, unknown>;
const rawChannels = lettabot.channels;
if (!rawChannels || typeof rawChannels !== 'object' || Array.isArray(rawChannels)) continue;
const channelsConfig = rawChannels as Record<string, unknown>;
for (const ch of channels) {
const rawChannelCfg = channelsConfig[ch];
if (!rawChannelCfg || typeof rawChannelCfg !== 'object' || Array.isArray(rawChannelCfg)) continue;
const channelCfg = rawChannelCfg as Record<string, unknown>;
for (const field of groupFields) {
if (Array.isArray(channelCfg[field])) {
channelCfg[field] = (channelCfg[field] as unknown[]).map((v: unknown) => String(v));
}
}
if (channelCfg.groups && typeof channelCfg.groups === 'object' && !Array.isArray(channelCfg.groups)) {
const fixedGroups: Record<string, unknown> = {};
for (const [key, value] of Object.entries(channelCfg.groups as Record<string, unknown>)) {
fixedGroups[String(key)] = value;
}
channelCfg.groups = fixedGroups;
}
}
}
}
}
/**
* Fix group identifiers that may contain large numeric IDs parsed by YAML.
* Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses them

View File

@@ -23,6 +23,7 @@ import {
hasInlineConfig,
isDockerServerMode,
serverModeLabel,
wasLoadedFromFleetConfig,
} from './config/index.js';
import { isLettaApiUrl } from './utils/server.js';
import { getCronDataDir, getDataDir, getWorkingDir, hasRailwayVolume, resolveWorkingDirPath } from './utils/paths.js';
@@ -192,10 +193,13 @@ No config file found. Searched locations:
2. LETTABOT_CONFIG env var (file path)
3. ./lettabot.yaml (project-local - recommended for local dev)
4. ./lettabot.yml
5. ~/.lettabot/config.yaml (user global)
6. ~/.lettabot/config.yml
5. ./agents.yml (fleet config from lettactl)
6. ./agents.yaml
7. ~/.lettabot/config.yaml (user global)
8. ~/.lettabot/config.yml
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys.
Run "lettabot onboard" to create a config, set LETTABOT_CONFIG_YAML for cloud deploys,
or use an agents.yml from lettactl with a lettabot: section.
Encode your config: base64 < lettabot.yaml | tr -d '\\n'
`);
process.exit(1);
@@ -643,8 +647,9 @@ async function main() {
}
}
// Container deploy: discover by name under an inter-process lock to avoid startup races.
if (!initialStatus.agentId && isContainerDeploy) {
// Discover by name under an inter-process lock to avoid startup races.
// Fleet configs rely on pre-created agents from lettactl apply.
if (!initialStatus.agentId && (isContainerDeploy || wasLoadedFromFleetConfig())) {
try {
await withDiscoveryLock(agentConfig.name, async () => {
// Re-read status after lock acquisition in case another instance already set it.