From 94b7eea1279016be940f272886b62b454d92ce93 Mon Sep 17 00:00:00 2001 From: Nouamane Benbrahim Date: Tue, 3 Mar 2026 17:13:13 -0500 Subject: [PATCH] feat: unified lettabot initialization via lettactl (agents.yml) (#393) Co-authored-by: Cameron --- src/config/fleet-adapter.test.ts | 354 +++++++++++++++++++++++++++++++ src/config/fleet-adapter.ts | 207 ++++++++++++++++++ src/config/index.ts | 1 + src/config/io.test.ts | 233 ++++++++++++++++++++ src/config/io.ts | 202 ++++++++++++++++-- src/main.ts | 15 +- 6 files changed, 990 insertions(+), 22 deletions(-) create mode 100644 src/config/fleet-adapter.test.ts create mode 100644 src/config/fleet-adapter.ts diff --git a/src/config/fleet-adapter.test.ts b/src/config/fleet-adapter.test.ts new file mode 100644 index 0000000..e49c266 --- /dev/null +++ b/src/config/fleet-adapter.test.ts @@ -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); + }); +}); diff --git a/src/config/fleet-adapter.ts b/src/config/fleet-adapter.ts new file mode 100644 index 0000000..9ad39f4 --- /dev/null +++ b/src/config/fleet-adapter.ts @@ -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; + 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; + 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; +} + +/** + * 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, +): 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) { + 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, + }; +} diff --git a/src/config/index.ts b/src/config/index.ts index 877b41f..e4a0269 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,3 +1,4 @@ export * from './types.js'; export * from './io.js'; export * from './runtime.js'; +export { wasLoadedFromFleetConfig } from './fleet-adapter.js'; diff --git a/src/config/io.test.ts b/src/config/io.test.ts index 03ac197..12e1413 100644 --- a/src/config/io.test.ts +++ b/src/config/io.test.ts @@ -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); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index b9f0b9e..6d4cfa1 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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 = [ - resolve(process.cwd(), 'lettabot.yaml'), // Project-local - resolve(process.cwd(), 'lettabot.yml'), // Project-local alt - join(homedir(), '.lettabot', 'config.yaml'), // User global - join(homedir(), '.lettabot', 'config.yml'), // User global alt -]; +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 { } function parseAndNormalizeConfig(content: string): LettaBotConfig { - const parsed = YAML.parse(content) as Partial; + 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; + + // 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; // 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 & Pick): 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; + + const rawLettabot = agent.lettabot; + if (!rawLettabot || typeof rawLettabot !== 'object' || Array.isArray(rawLettabot)) continue; + const lettabot = rawLettabot as Record; + + const rawChannels = lettabot.channels; + if (!rawChannels || typeof rawChannels !== 'object' || Array.isArray(rawChannels)) continue; + const channelsConfig = rawChannels as Record; + + for (const ch of channels) { + const rawChannelCfg = channelsConfig[ch]; + if (!rawChannelCfg || typeof rawChannelCfg !== 'object' || Array.isArray(rawChannelCfg)) continue; + const channelCfg = rawChannelCfg as Record; + + 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 = {}; + 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 = {}; + 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; + const rawLettabot = agent.lettabot; + if (!rawLettabot || typeof rawLettabot !== 'object' || Array.isArray(rawLettabot)) continue; + const lettabot = rawLettabot as Record; + const rawChannels = lettabot.channels; + if (!rawChannels || typeof rawChannels !== 'object' || Array.isArray(rawChannels)) continue; + const channelsConfig = rawChannels as Record; + + for (const ch of channels) { + const rawChannelCfg = channelsConfig[ch]; + if (!rawChannelCfg || typeof rawChannelCfg !== 'object' || Array.isArray(rawChannelCfg)) continue; + const channelCfg = rawChannelCfg as Record; + + 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 = {}; + for (const [key, value] of Object.entries(channelCfg.groups as Record)) { + 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 diff --git a/src/main.ts b/src/main.ts index 6dbf45f..07fb83f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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.