feat: unified lettabot initialization via lettactl (agents.yml) (#393)
Co-authored-by: Cameron <cameron@pfiffer.org>
This commit is contained in:
committed by
GitHub
parent
743cb1fbc9
commit
94b7eea127
354
src/config/fleet-adapter.test.ts
Normal file
354
src/config/fleet-adapter.test.ts
Normal 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
207
src/config/fleet-adapter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './io.js';
|
export * from './io.js';
|
||||||
export * from './runtime.js';
|
export * from './runtime.js';
|
||||||
|
export { wasLoadedFromFleetConfig } from './fleet-adapter.js';
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ vi.mock('../logger.js', () => ({
|
|||||||
|
|
||||||
import { saveConfig, loadConfig, loadConfigStrict, configToEnv, didLoadFail } from './io.js';
|
import { saveConfig, loadConfig, loadConfigStrict, configToEnv, didLoadFail } from './io.js';
|
||||||
import { normalizeAgents, DEFAULT_CONFIG } from './types.js';
|
import { normalizeAgents, DEFAULT_CONFIG } from './types.js';
|
||||||
|
import { wasLoadedFromFleetConfig, setLoadedFromFleetConfig } from './fleet-adapter.js';
|
||||||
import type { LettaBotConfig } from './types.js';
|
import type { LettaBotConfig } from './types.js';
|
||||||
|
|
||||||
describe('saveConfig with agents[] format', () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
202
src/config/io.ts
202
src/config/io.ts
@@ -5,7 +5,8 @@
|
|||||||
* 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64-encoded YAML)
|
* 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64-encoded YAML)
|
||||||
* 2. LETTABOT_CONFIG env var (file path)
|
* 2. LETTABOT_CONFIG env var (file path)
|
||||||
* 3. ./lettabot.yaml or ./lettabot.yml (project-local)
|
* 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';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
@@ -14,18 +15,23 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import type { LettaBotConfig, ProviderConfig } from './types.js';
|
import type { LettaBotConfig, ProviderConfig } from './types.js';
|
||||||
import { DEFAULT_CONFIG, canonicalizeServerMode, isApiServerMode, isDockerServerMode } 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 { LETTA_API_URL } from '../auth/oauth.js';
|
||||||
|
|
||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
|
|
||||||
const log = createLogger('Config');
|
const log = createLogger('Config');
|
||||||
// Config file locations (checked in order)
|
// Config file locations (checked in order)
|
||||||
const CONFIG_PATHS = [
|
function getConfigPaths(): string[] {
|
||||||
resolve(process.cwd(), 'lettabot.yaml'), // Project-local
|
return [
|
||||||
resolve(process.cwd(), 'lettabot.yml'), // Project-local alt
|
resolve(process.cwd(), 'lettabot.yaml'), // Project-local
|
||||||
join(homedir(), '.lettabot', 'config.yaml'), // User global
|
resolve(process.cwd(), 'lettabot.yml'), // Project-local alt
|
||||||
join(homedir(), '.lettabot', 'config.yml'), // User global 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');
|
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)
|
* 1. LETTABOT_CONFIG env var (explicit override)
|
||||||
* 2. ./lettabot.yaml (project-local)
|
* 2. ./lettabot.yaml (project-local)
|
||||||
* 3. ./lettabot.yml (project-local alt)
|
* 3. ./lettabot.yml (project-local alt)
|
||||||
* 4. ~/.lettabot/config.yaml (user global)
|
* 4. ./agents.yml (fleet config from lettactl)
|
||||||
* 5. ~/.lettabot/config.yml (user global alt)
|
* 5. ./agents.yaml (fleet config alt)
|
||||||
|
* 6. ~/.lettabot/config.yaml (user global)
|
||||||
|
* 7. ~/.lettabot/config.yml (user global alt)
|
||||||
*/
|
*/
|
||||||
export function resolveConfigPath(): string {
|
export function resolveConfigPath(): string {
|
||||||
// Environment variable takes priority
|
// Environment variable takes priority
|
||||||
@@ -90,7 +98,7 @@ export function resolveConfigPath(): string {
|
|||||||
return resolve(process.env.LETTABOT_CONFIG);
|
return resolve(process.env.LETTABOT_CONFIG);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const p of CONFIG_PATHS) {
|
for (const p of getConfigPaths()) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
@@ -110,16 +118,50 @@ function hasObject(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseAndNormalizeConfig(content: string): LettaBotConfig {
|
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)
|
// Fix instantGroups: YAML parses large numeric IDs (e.g. Discord snowflakes)
|
||||||
// as JavaScript numbers, losing precision for values > Number.MAX_SAFE_INTEGER.
|
// as JavaScript numbers, losing precision for values > Number.MAX_SAFE_INTEGER.
|
||||||
// Re-extract from document AST to preserve the original string representation.
|
// 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
|
// Reject ambiguous API server configuration. During migration from top-level
|
||||||
// `api` to `server.api`, having both can silently drop fields.
|
// `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(
|
throw new Error(
|
||||||
'Conflicting API config: both top-level `api` and `server.api` are set. Remove top-level `api` and keep only `server.api`.'
|
'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.
|
// Merge with defaults and canonicalize server mode.
|
||||||
const merged = {
|
const merged = {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
...parsed,
|
...typedParsed,
|
||||||
server: { ...DEFAULT_CONFIG.server, ...parsed.server },
|
server: { ...DEFAULT_CONFIG.server, ...typedParsed.server },
|
||||||
agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent },
|
agent: { ...DEFAULT_CONFIG.agent, ...typedParsed.agent },
|
||||||
channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels },
|
channels: { ...DEFAULT_CONFIG.channels, ...typedParsed.channels },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@@ -155,6 +197,7 @@ function parseAndNormalizeConfig(content: string): LettaBotConfig {
|
|||||||
*/
|
*/
|
||||||
export function loadConfig(): LettaBotConfig {
|
export function loadConfig(): LettaBotConfig {
|
||||||
_lastLoadFailed = false;
|
_lastLoadFailed = false;
|
||||||
|
setLoadedFromFleetConfig(false);
|
||||||
|
|
||||||
// Inline config takes priority over file-based config
|
// Inline config takes priority over file-based config
|
||||||
if (hasInlineConfig()) {
|
if (hasInlineConfig()) {
|
||||||
@@ -192,6 +235,7 @@ export function loadConfig(): LettaBotConfig {
|
|||||||
*/
|
*/
|
||||||
export function loadConfigStrict(): LettaBotConfig {
|
export function loadConfigStrict(): LettaBotConfig {
|
||||||
_lastLoadFailed = false;
|
_lastLoadFailed = false;
|
||||||
|
setLoadedFromFleetConfig(false);
|
||||||
|
|
||||||
// Inline config takes priority over file-based config
|
// Inline config takes priority over file-based config
|
||||||
if (hasInlineConfig()) {
|
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.
|
* Fix group identifiers that may contain large numeric IDs parsed by YAML.
|
||||||
* Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses them
|
* Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses them
|
||||||
|
|||||||
15
src/main.ts
15
src/main.ts
@@ -23,6 +23,7 @@ import {
|
|||||||
hasInlineConfig,
|
hasInlineConfig,
|
||||||
isDockerServerMode,
|
isDockerServerMode,
|
||||||
serverModeLabel,
|
serverModeLabel,
|
||||||
|
wasLoadedFromFleetConfig,
|
||||||
} from './config/index.js';
|
} from './config/index.js';
|
||||||
import { isLettaApiUrl } from './utils/server.js';
|
import { isLettaApiUrl } from './utils/server.js';
|
||||||
import { getCronDataDir, getDataDir, getWorkingDir, hasRailwayVolume, resolveWorkingDirPath } from './utils/paths.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)
|
2. LETTABOT_CONFIG env var (file path)
|
||||||
3. ./lettabot.yaml (project-local - recommended for local dev)
|
3. ./lettabot.yaml (project-local - recommended for local dev)
|
||||||
4. ./lettabot.yml
|
4. ./lettabot.yml
|
||||||
5. ~/.lettabot/config.yaml (user global)
|
5. ./agents.yml (fleet config from lettactl)
|
||||||
6. ~/.lettabot/config.yml
|
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'
|
Encode your config: base64 < lettabot.yaml | tr -d '\\n'
|
||||||
`);
|
`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -643,8 +647,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container deploy: discover by name under an inter-process lock to avoid startup races.
|
// Discover by name under an inter-process lock to avoid startup races.
|
||||||
if (!initialStatus.agentId && isContainerDeploy) {
|
// Fleet configs rely on pre-created agents from lettactl apply.
|
||||||
|
if (!initialStatus.agentId && (isContainerDeploy || wasLoadedFromFleetConfig())) {
|
||||||
try {
|
try {
|
||||||
await withDiscoveryLock(agentConfig.name, async () => {
|
await withDiscoveryLock(agentConfig.name, async () => {
|
||||||
// Re-read status after lock acquisition in case another instance already set it.
|
// Re-read status after lock acquisition in case another instance already set it.
|
||||||
|
|||||||
Reference in New Issue
Block a user