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
This commit is contained in:
Cameron
2026-02-10 14:58:46 -08:00
committed by GitHub
parent e8a97a2cbb
commit 745291841d
4 changed files with 327 additions and 100 deletions

151
src/config/io.test.ts Normal file
View File

@@ -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<LettaBotConfig>;
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();
});
});

View File

@@ -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<LettaBotConfig> & Pick<LettaBotConfig, 'server'>, 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<void> {
export async function syncProviders(config: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'>): Promise<void> {
if (config.server.mode !== 'cloud' || !config.server.apiKey) {
return;
}

View File

@@ -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);
});
});

View File

@@ -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,19 +116,18 @@ function readConfigFromEnv(existingConfig: any): any {
async function saveConfigFromEnv(config: any, configPath: string): Promise<void> {
const { saveConfig } = await import('./config/index.js');
const lettabotConfig: LettaBotConfig = {
const lettabotConfig: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'> = {
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
},
...(config.agentId ? { id: config.agentId } : {}),
channels: {
telegram: config.telegram.enabled ? {
...(config.telegram.enabled ? {
telegram: {
enabled: true,
token: config.telegram.botToken,
dmPolicy: config.telegram.dmPolicy,
@@ -137,9 +136,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
groupPollIntervalMin: config.telegram.groupPollIntervalMin,
instantGroups: config.telegram.instantGroups,
listeningGroups: config.telegram.listeningGroups,
} : { enabled: false },
slack: config.slack.enabled ? {
}
} : {}),
...(config.slack.enabled ? {
slack: {
enabled: true,
botToken: config.slack.botToken,
appToken: config.slack.appToken,
@@ -148,9 +148,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
groupPollIntervalMin: config.slack.groupPollIntervalMin,
instantGroups: config.slack.instantGroups,
listeningGroups: config.slack.listeningGroups,
} : { enabled: false },
discord: config.discord.enabled ? {
}
} : {}),
...(config.discord.enabled ? {
discord: {
enabled: true,
token: config.discord.botToken,
dmPolicy: config.discord.dmPolicy,
@@ -159,9 +160,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
groupPollIntervalMin: config.discord.groupPollIntervalMin,
instantGroups: config.discord.instantGroups,
listeningGroups: config.discord.listeningGroups,
} : { enabled: false },
whatsapp: config.whatsapp.enabled ? {
}
} : {}),
...(config.whatsapp.enabled ? {
whatsapp: {
enabled: true,
selfChat: config.whatsapp.selfChat,
dmPolicy: config.whatsapp.dmPolicy,
@@ -170,9 +172,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
groupPollIntervalMin: config.whatsapp.groupPollIntervalMin,
instantGroups: config.whatsapp.instantGroups,
listeningGroups: config.whatsapp.listeningGroups,
} : { enabled: false },
signal: config.signal.enabled ? {
}
} : {}),
...(config.signal.enabled ? {
signal: {
enabled: true,
phone: config.signal.phoneNumber,
selfChat: config.signal.selfChat,
@@ -182,7 +185,8 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
groupPollIntervalMin: config.signal.groupPollIntervalMin,
instantGroups: config.signal.instantGroups,
listeningGroups: config.signal.listeningGroups,
} : { enabled: false },
}
} : {}),
},
features: {
cron: false,
@@ -191,6 +195,7 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
intervalMin: 60,
},
},
}],
};
saveConfig(lettabotConfig);
@@ -1495,6 +1500,44 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
// Review loop
await reviewLoop(config, env);
// Create agent eagerly if user chose "new" and we don't have an ID yet
if (config.agentChoice === 'new' && !config.agentId) {
const { createAgent } = await import('@letta-ai/letta-code-sdk');
const { updateAgentName, ensureNoToolApprovals } = await import('./tools/letta-api.js');
const { installSkillsToAgent } = await import('./skills/loader.js');
const { loadMemoryBlocks } = await import('./core/memory.js');
const { SYSTEM_PROMPT } = await import('./core/system-prompt.js');
const spinner = p.spinner();
spinner.start('Creating agent...');
try {
const agentId = await createAgent({
systemPrompt: SYSTEM_PROMPT,
memory: loadMemoryBlocks(config.agentName || 'LettaBot'),
...(config.model ? { model: config.model } : {}),
});
// Set name and install skills
if (config.agentName) {
await updateAgentName(agentId, config.agentName).catch(() => {});
}
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<v
p.note(summary, 'Configuration Summary');
// Convert to YAML config
const yamlConfig: LettaBotConfig = {
server: {
mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud',
...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}),
...(config.apiKey ? { apiKey: config.apiKey } : {}),
},
agent: {
// Build per-agent config (multi-agent format)
const agentConfig: AgentConfig = {
name: config.agentName || 'LettaBot',
// model is configured on the Letta agent server-side, not saved to config
...(config.agentId ? { id: config.agentId } : {}),
},
channels: {
...(config.telegram.enabled ? {
telegram: {
@@ -1706,13 +1741,6 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
intervalMin: config.heartbeat.interval ? parseInt(config.heartbeat.interval) : undefined,
},
},
...(config.transcription.enabled && config.transcription.apiKey ? {
transcription: {
provider: 'openai' as const,
apiKey: config.transcription.apiKey,
...(config.transcription.model ? { model: config.transcription.model } : {}),
},
} : {}),
...(config.google.enabled ? {
integrations: {
google: {
@@ -1731,15 +1759,30 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
} : {}),
};
// Add BYOK providers if configured
if (config.providers && config.providers.length > 0) {
yamlConfig.providers = config.providers.map(p => ({
// Convert to YAML config (multi-agent format)
const yamlConfig: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'> = {
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, // id is the type (anthropic, openai, etc.)
type: p.id,
apiKey: p.apiKey,
}));
}
})),
} : {}),
};
// Save YAML config (use project-local path)
const savePath = resolve(process.cwd(), 'lettabot.yaml');