From 745291841dd862686caf455d51ef9d2f2e91e8f5 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 10 Feb 2026 14:58:46 -0800 Subject: [PATCH] feat: default new configs to multi-agent format (#261) * feat: default new configs to multi-agent format Onboarding and non-interactive config generation now emit the agents[] array format instead of the legacy agent/channels/features flat structure. This makes adding a second agent a simple array append rather than a config format migration. Existing legacy configs continue to work -- normalizeAgents() handles both formats at runtime. Written by Cameron and Letta Code "The future is already here -- it's just not very evenly distributed." -- William Gibson * test: add save/load roundtrip tests for agents[] config format Tests the actual YAML serialization path: - agents[] written without legacy agent/channels at top level - roundtrip through save + load + normalizeAgents preserves all fields - global fields (providers, transcription) stay at top level Written by Cameron and Letta Code "Programs must be written for people to read, and only incidentally for machines to execute." -- Abelson & Sussman * feat: eagerly create agent during onboarding When the user picks "Create new agent", the agent is now created immediately (with spinner) rather than deferred to first message. The agent ID is written directly to lettabot.yaml, making the config file the single source of truth. Creates the agent with system prompt, memory blocks, selected model, display name, skills, and disables tool approvals -- same setup that bot.ts previously did lazily on first message. Graceful fallback: if creation fails, falls back to lazy creation. Written by Cameron and Letta Code "Make it work, make it right, make it fast." -- Kent Beck --- src/config/io.test.ts | 151 ++++++++++++++++++++++ src/config/io.ts | 4 +- src/config/normalize.test.ts | 33 +++++ src/onboard.ts | 239 +++++++++++++++++++++-------------- 4 files changed, 327 insertions(+), 100 deletions(-) create mode 100644 src/config/io.test.ts diff --git a/src/config/io.test.ts b/src/config/io.test.ts new file mode 100644 index 0000000..e9742f8 --- /dev/null +++ b/src/config/io.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import YAML from 'yaml'; +import { saveConfig, loadConfig } from './io.js'; +import { normalizeAgents } from './types.js'; +import type { LettaBotConfig } from './types.js'; + +describe('saveConfig with agents[] format', () => { + let tmpDir: string; + let configPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-config-test-')); + configPath = join(tmpDir, 'lettabot.yaml'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should write agents[] config without legacy agent/channels at top level', () => { + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'TestBot', + id: 'agent-abc123', + channels: { + telegram: { enabled: true, token: 'tg-token', dmPolicy: 'pairing' as const }, + }, + features: { + cron: true, + heartbeat: { enabled: true, intervalMin: 30 }, + }, + }], + }; + + saveConfig(config, configPath); + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw); + + // Should have agents[] at top level + expect(parsed.agents).toHaveLength(1); + expect(parsed.agents[0].name).toBe('TestBot'); + expect(parsed.agents[0].channels.telegram.token).toBe('tg-token'); + + // Should NOT have legacy agent/channels at top level + expect(parsed.agent).toBeUndefined(); + expect(parsed.channels).toBeUndefined(); + expect(parsed.features).toBeUndefined(); + }); + + it('should roundtrip agents[] config through save and loadConfig+normalizeAgents', () => { + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'MyBot', + id: 'agent-xyz', + channels: { + telegram: { enabled: true, token: 'tg-123', dmPolicy: 'open' as const }, + whatsapp: { enabled: true, selfChat: true, dmPolicy: 'pairing' as const }, + }, + features: { + cron: false, + heartbeat: { enabled: true, intervalMin: 15 }, + }, + }], + transcription: { + provider: 'openai' as const, + apiKey: 'whisper-key', + }, + }; + + saveConfig(config, configPath); + + // loadConfig reads from resolveConfigPath(), so we need to load manually + // and merge with defaults the same way loadConfig does + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw) as Partial; + const loaded: LettaBotConfig = { + server: { mode: 'cloud', ...parsed.server }, + agent: { name: 'LettaBot', ...parsed.agent }, + channels: { ...parsed.channels }, + ...parsed, + }; + + // normalizeAgents should pick up agents[] and ignore defaults + const agents = normalizeAgents(loaded); + + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('MyBot'); + expect(agents[0].id).toBe('agent-xyz'); + expect(agents[0].channels.telegram?.token).toBe('tg-123'); + expect(agents[0].channels.whatsapp?.selfChat).toBe(true); + expect(agents[0].features?.heartbeat?.intervalMin).toBe(15); + + // Global fields should survive + expect(loaded.transcription?.apiKey).toBe('whisper-key'); + }); + + it('should always include agent id in agents[] (onboarding contract)', () => { + // After onboarding, agent ID should always be present in the config. + // This test documents the contract: new configs have the ID eagerly set. + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'LettaBot', + id: 'agent-eagerly-created', + channels: { + telegram: { enabled: true, token: 'tg-token' }, + }, + features: { cron: false, heartbeat: { enabled: false } }, + }], + }; + + saveConfig(config, configPath); + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw); + + expect(parsed.agents[0].id).toBe('agent-eagerly-created'); + }); + + it('should preserve providers at top level, not inside agents', () => { + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'TestBot', + channels: {}, + features: { cron: false, heartbeat: { enabled: false } }, + }], + providers: [{ + id: 'anthropic', + name: 'anthropic', + type: 'anthropic', + apiKey: 'sk-ant-test', + }], + }; + + saveConfig(config, configPath); + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw); + + expect(parsed.providers).toHaveLength(1); + expect(parsed.providers[0].name).toBe('anthropic'); + expect(parsed.agents[0].providers).toBeUndefined(); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index 44a690b..46f778a 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -81,7 +81,7 @@ export function loadConfig(): LettaBotConfig { /** * Save config to YAML file */ -export function saveConfig(config: LettaBotConfig, path?: string): void { +export function saveConfig(config: Partial & Pick, path?: string): void { const configPath = path || resolveConfigPath(); // Ensure directory exists @@ -285,7 +285,7 @@ export function applyConfigToEnv(config: LettaBotConfig): void { /** * Create BYOK providers on Letta Cloud */ -export async function syncProviders(config: LettaBotConfig): Promise { +export async function syncProviders(config: Partial & Pick): Promise { if (config.server.mode !== 'cloud' || !config.server.apiKey) { return; } diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index d79acee..0d1a32a 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -374,4 +374,37 @@ describe('normalizeAgents', () => { expect(agents[0].displayName).toBe('💜 Signo'); expect(agents[1].displayName).toBe('👾 DevOps'); }); + + it('should normalize onboarding-generated agents[] config (no legacy agent/channels)', () => { + // This matches the shape that onboarding now writes: agents[] at top level, + // with no legacy agent/channels/features fields. + const config = { + server: { mode: 'cloud' as const }, + agents: [{ + name: 'LettaBot', + id: 'agent-abc123', + channels: { + telegram: { enabled: true, token: 'tg-token', dmPolicy: 'pairing' as const }, + whatsapp: { enabled: true, selfChat: true }, + }, + features: { + cron: true, + heartbeat: { enabled: true, intervalMin: 30 }, + }, + }], + // loadConfig() merges defaults for agent/channels, so they'll exist at runtime + agent: { name: 'LettaBot' }, + channels: {}, + } as LettaBotConfig; + + const agents = normalizeAgents(config); + + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('LettaBot'); + expect(agents[0].id).toBe('agent-abc123'); + expect(agents[0].channels.telegram?.token).toBe('tg-token'); + expect(agents[0].channels.whatsapp?.enabled).toBe(true); + expect(agents[0].features?.cron).toBe(true); + expect(agents[0].features?.heartbeat?.intervalMin).toBe(30); + }); }); diff --git a/src/onboard.ts b/src/onboard.ts index 2331324..286d947 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -7,7 +7,7 @@ import { resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import * as p from '@clack/prompts'; import { saveConfig, syncProviders } from './config/index.js'; -import type { LettaBotConfig, ProviderConfig } from './config/types.js'; +import type { AgentConfig, LettaBotConfig, ProviderConfig } from './config/types.js'; import { isLettaCloudUrl } from './utils/server.js'; import { CHANNELS, getChannelHint, isSignalCliInstalled, setupTelegram, setupSlack, setupDiscord, setupWhatsApp, setupSignal } from './channels/setup.js'; @@ -116,81 +116,86 @@ function readConfigFromEnv(existingConfig: any): any { async function saveConfigFromEnv(config: any, configPath: string): Promise { const { saveConfig } = await import('./config/index.js'); - const lettabotConfig: LettaBotConfig = { + const lettabotConfig: Partial & Pick = { server: { mode: isLettaCloudUrl(config.baseUrl) ? 'cloud' : 'selfhosted', baseUrl: config.baseUrl, apiKey: config.apiKey, }, - agent: { - id: config.agentId, + agents: [{ name: config.agentName, - // model is configured on the Letta agent server-side, not saved to config - }, - channels: { - telegram: config.telegram.enabled ? { - enabled: true, - token: config.telegram.botToken, - dmPolicy: config.telegram.dmPolicy, - allowedUsers: config.telegram.allowedUsers, - groupDebounceSec: config.telegram.groupDebounceSec, - groupPollIntervalMin: config.telegram.groupPollIntervalMin, - instantGroups: config.telegram.instantGroups, - listeningGroups: config.telegram.listeningGroups, - } : { enabled: false }, - - slack: config.slack.enabled ? { - enabled: true, - botToken: config.slack.botToken, - appToken: config.slack.appToken, - allowedUsers: config.slack.allowedUsers, - groupDebounceSec: config.slack.groupDebounceSec, - groupPollIntervalMin: config.slack.groupPollIntervalMin, - instantGroups: config.slack.instantGroups, - listeningGroups: config.slack.listeningGroups, - } : { enabled: false }, - - discord: config.discord.enabled ? { - enabled: true, - token: config.discord.botToken, - dmPolicy: config.discord.dmPolicy, - allowedUsers: config.discord.allowedUsers, - groupDebounceSec: config.discord.groupDebounceSec, - groupPollIntervalMin: config.discord.groupPollIntervalMin, - instantGroups: config.discord.instantGroups, - listeningGroups: config.discord.listeningGroups, - } : { enabled: false }, - - whatsapp: config.whatsapp.enabled ? { - enabled: true, - selfChat: config.whatsapp.selfChat, - dmPolicy: config.whatsapp.dmPolicy, - allowedUsers: config.whatsapp.allowedUsers, - groupDebounceSec: config.whatsapp.groupDebounceSec, - groupPollIntervalMin: config.whatsapp.groupPollIntervalMin, - instantGroups: config.whatsapp.instantGroups, - listeningGroups: config.whatsapp.listeningGroups, - } : { enabled: false }, - - signal: config.signal.enabled ? { - enabled: true, - phone: config.signal.phoneNumber, - selfChat: config.signal.selfChat, - dmPolicy: config.signal.dmPolicy, - allowedUsers: config.signal.allowedUsers, - groupDebounceSec: config.signal.groupDebounceSec, - groupPollIntervalMin: config.signal.groupPollIntervalMin, - instantGroups: config.signal.instantGroups, - listeningGroups: config.signal.listeningGroups, - } : { enabled: false }, - }, - features: { - cron: false, - heartbeat: { - enabled: false, - intervalMin: 60, + ...(config.agentId ? { id: config.agentId } : {}), + channels: { + ...(config.telegram.enabled ? { + telegram: { + enabled: true, + token: config.telegram.botToken, + dmPolicy: config.telegram.dmPolicy, + allowedUsers: config.telegram.allowedUsers, + groupDebounceSec: config.telegram.groupDebounceSec, + groupPollIntervalMin: config.telegram.groupPollIntervalMin, + instantGroups: config.telegram.instantGroups, + listeningGroups: config.telegram.listeningGroups, + } + } : {}), + ...(config.slack.enabled ? { + slack: { + enabled: true, + botToken: config.slack.botToken, + appToken: config.slack.appToken, + allowedUsers: config.slack.allowedUsers, + groupDebounceSec: config.slack.groupDebounceSec, + groupPollIntervalMin: config.slack.groupPollIntervalMin, + instantGroups: config.slack.instantGroups, + listeningGroups: config.slack.listeningGroups, + } + } : {}), + ...(config.discord.enabled ? { + discord: { + enabled: true, + token: config.discord.botToken, + dmPolicy: config.discord.dmPolicy, + allowedUsers: config.discord.allowedUsers, + groupDebounceSec: config.discord.groupDebounceSec, + groupPollIntervalMin: config.discord.groupPollIntervalMin, + instantGroups: config.discord.instantGroups, + listeningGroups: config.discord.listeningGroups, + } + } : {}), + ...(config.whatsapp.enabled ? { + whatsapp: { + enabled: true, + selfChat: config.whatsapp.selfChat, + dmPolicy: config.whatsapp.dmPolicy, + allowedUsers: config.whatsapp.allowedUsers, + groupDebounceSec: config.whatsapp.groupDebounceSec, + groupPollIntervalMin: config.whatsapp.groupPollIntervalMin, + instantGroups: config.whatsapp.instantGroups, + listeningGroups: config.whatsapp.listeningGroups, + } + } : {}), + ...(config.signal.enabled ? { + signal: { + enabled: true, + phone: config.signal.phoneNumber, + selfChat: config.signal.selfChat, + dmPolicy: config.signal.dmPolicy, + allowedUsers: config.signal.allowedUsers, + groupDebounceSec: config.signal.groupDebounceSec, + groupPollIntervalMin: config.signal.groupPollIntervalMin, + instantGroups: config.signal.instantGroups, + listeningGroups: config.signal.listeningGroups, + } + } : {}), }, - }, + features: { + cron: false, + heartbeat: { + enabled: false, + intervalMin: 60, + }, + }, + }], }; saveConfig(lettabotConfig); @@ -1495,6 +1500,44 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise {}); + } + installSkillsToAgent(agentId, { + cronEnabled: config.cron, + googleEnabled: config.google.enabled, + }); + + // Disable tool approvals + ensureNoToolApprovals(agentId).catch(() => {}); + + config.agentId = agentId; + spinner.stop(`Agent created: ${agentId}`); + } catch (err) { + spinner.stop('Failed to create agent'); + p.log.error(`${err}`); + p.log.info('The agent will be created on first message instead.'); + } + } + // Apply config to env if (config.agentName) env.AGENT_NAME = config.agentName; @@ -1624,18 +1667,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise 0) { - yamlConfig.providers = config.providers.map(p => ({ - id: p.id, - name: p.name, - type: p.id, // id is the type (anthropic, openai, etc.) - apiKey: p.apiKey, - })); - } + + // Convert to YAML config (multi-agent format) + const yamlConfig: Partial & Pick = { + server: { + mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud', + ...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(config.apiKey ? { apiKey: config.apiKey } : {}), + }, + agents: [agentConfig], + ...(config.transcription.enabled && config.transcription.apiKey ? { + transcription: { + provider: 'openai' as const, + apiKey: config.transcription.apiKey, + ...(config.transcription.model ? { model: config.transcription.model } : {}), + }, + } : {}), + ...(config.providers && config.providers.length > 0 ? { + providers: config.providers.map(p => ({ + id: p.id, + name: p.name, + type: p.id, + apiKey: p.apiKey, + })), + } : {}), + }; // Save YAML config (use project-local path) const savePath = resolve(process.cwd(), 'lettabot.yaml');