fix: clarify stuck approval error message with /reset and consequences (#590)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-12 23:33:23 -07:00
committed by GitHub
parent 25fbd7b9fa
commit 68056ed21b
13 changed files with 348 additions and 47 deletions

View File

@@ -25,7 +25,7 @@ LettaBot scans these directories in priority order. Same-name skills at higher p
On Railway with a mounted volume, LettaBot stores `.letta` skill paths under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/` by default, so agent-scoped and global skills survive redeploys. On Railway with a mounted volume, LettaBot stores `.letta` skill paths under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/` by default, so agent-scoped and global skills survive redeploys.
Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped. Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`, or `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/{id}/skills/` on Railway) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped.
## Feature-gated skills ## Feature-gated skills
@@ -113,7 +113,7 @@ Run `lettabot skills status` to see which skills are eligible and which have mis
When a session starts, `prependSkillDirsToPath()` in `src/skills/loader.ts` prepends skill directories to `PATH` immediately before `createSession`/`resumeSession` is called. The SDK spawns the Letta Code subprocess at session-creation time, so the subprocess inherits the augmented PATH at fork. Two sources are combined: When a session starts, `prependSkillDirsToPath()` in `src/skills/loader.ts` prepends skill directories to `PATH` immediately before `createSession`/`resumeSession` is called. The SDK spawns the Letta Code subprocess at session-creation time, so the subprocess inherits the augmented PATH at fork. Two sources are combined:
1. **Agent-scoped skills** (`~/.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup. 1. **Agent-scoped skills** (`.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup.
2. **Working-dir skills** (`WORKING_DIR/.skills/`) — skills enabled via `lettabot skills enable <name>` or the interactive `lettabot skills` wizard. 2. **Working-dir skills** (`WORKING_DIR/.skills/`) — skills enabled via `lettabot skills enable <name>` or the interactive `lettabot skills` wizard.
On Railway with a mounted volume, `WORKING_DIR` defaults to `$RAILWAY_VOLUME_MOUNT_PATH/data` when not explicitly set, so working-dir skills are persisted on the volume by default. On Railway with a mounted volume, `WORKING_DIR` defaults to `$RAILWAY_VOLUME_MOUNT_PATH/data` when not explicitly set, so working-dir skills are persisted on the volume by default.

View File

@@ -17,11 +17,89 @@ vi.mock('../logger.js', () => ({
}), }),
})); }));
import { saveConfig, loadConfig, loadConfigStrict, configToEnv, didLoadFail } from './io.js'; import {
saveConfig,
loadConfig,
loadConfigStrict,
configToEnv,
didLoadFail,
decodeYamlOrBase64,
hasInlineConfig,
} 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 { wasLoadedFromFleetConfig, setLoadedFromFleetConfig } from './fleet-adapter.js';
import type { LettaBotConfig } from './types.js'; import type { LettaBotConfig } from './types.js';
describe('inline config helpers', () => {
let originalInline: string | undefined;
beforeEach(() => {
originalInline = process.env.LETTABOT_CONFIG_YAML;
delete process.env.LETTABOT_CONFIG_YAML;
});
afterEach(() => {
if (originalInline === undefined) {
delete process.env.LETTABOT_CONFIG_YAML;
} else {
process.env.LETTABOT_CONFIG_YAML = originalInline;
}
});
it('treats empty inline env vars as unset', () => {
expect(hasInlineConfig()).toBe(false);
process.env.LETTABOT_CONFIG_YAML = '';
expect(hasInlineConfig()).toBe(false);
process.env.LETTABOT_CONFIG_YAML = ' ';
expect(hasInlineConfig()).toBe(false);
});
it('detects non-empty inline env vars', () => {
process.env.LETTABOT_CONFIG_YAML = 'server:\n mode: api\n';
expect(hasInlineConfig()).toBe(true);
});
it('decodes raw yaml inline config', () => {
const yaml = 'server:\n mode: api\n';
expect(decodeYamlOrBase64(yaml)).toBe(yaml);
});
it('decodes base64 inline config', () => {
const yaml = 'server:\n mode: api\n';
const encoded = Buffer.from(yaml, 'utf-8').toString('base64');
expect(decodeYamlOrBase64(encoded)).toBe(yaml);
});
it('decodes unpadded base64 inline config', () => {
const yaml = 'server:\n mode: api\n';
const encoded = Buffer.from(yaml, 'utf-8').toString('base64').replace(/=+$/, '');
expect(decodeYamlOrBase64(encoded)).toBe(yaml);
});
it('decodes URL-safe base64 inline config', () => {
const yaml = 'server:\n mode: api\n';
const encoded = Buffer.from(yaml, 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
expect(decodeYamlOrBase64(encoded)).toBe(yaml);
});
it('throws for empty inline config values', () => {
expect(() => decodeYamlOrBase64('')).toThrow('LETTABOT_CONFIG_YAML is empty');
expect(() => decodeYamlOrBase64(' ')).toThrow('LETTABOT_CONFIG_YAML is empty');
});
it('throws for invalid inline config values', () => {
expect(() => decodeYamlOrBase64('###not-yaml-not-base64###')).toThrow(
'LETTABOT_CONFIG_YAML must be raw YAML or base64-encoded YAML',
);
});
});
describe('saveConfig with agents[] format', () => { describe('saveConfig with agents[] format', () => {
let tmpDir: string; let tmpDir: string;
let configPath: string; let configPath: string;

View File

@@ -21,6 +21,13 @@ 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');
function getInlineConfigEnvValue(): string | undefined {
const raw = process.env.LETTABOT_CONFIG_YAML;
if (raw === undefined) return undefined;
return raw.trim().length > 0 ? raw : undefined;
}
// Config file locations (checked in order) // Config file locations (checked in order)
function getConfigPaths(): string[] { function getConfigPaths(): string[] {
return [ return [
@@ -40,26 +47,71 @@ const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml');
* When set, this takes priority over all file-based config sources. * When set, this takes priority over all file-based config sources.
*/ */
export function hasInlineConfig(): boolean { export function hasInlineConfig(): boolean {
return !!process.env.LETTABOT_CONFIG_YAML; return getInlineConfigEnvValue() !== undefined;
} }
/** /**
* Decode a value that may be raw YAML or base64-encoded YAML. * Decode a value that may be raw YAML or base64-encoded YAML.
* Detection: if the value contains a colon, it's raw YAML (every valid config * Detection strategy:
* has key: value pairs). Otherwise it's base64 (which uses only [A-Za-z0-9+/=]). * 1) Treat values that parse as YAML objects as raw YAML.
* 2) Otherwise, require strict base64 and decode to YAML object.
*/ */
export function decodeYamlOrBase64(value: string): string { export function decodeYamlOrBase64(value: string): string {
if (value.includes(':')) { const trimmed = value.trim();
if (!trimmed) {
throw new Error('LETTABOT_CONFIG_YAML is empty');
}
// Prefer raw YAML when it parses successfully.
try {
const parsed = YAML.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return value; return value;
} }
return Buffer.from(value, 'base64').toString('utf-8'); } catch {
// Fall through to base64 decoding.
}
const normalized = trimmed.replace(/\s+/g, '');
const base64Standard = normalized.replace(/-/g, '+').replace(/_/g, '/');
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64Standard)) {
throw new Error('LETTABOT_CONFIG_YAML must be raw YAML or base64-encoded YAML');
}
const normalizedNoPad = base64Standard.replace(/=+$/, '');
if (normalizedNoPad.length === 0 || normalizedNoPad.length % 4 === 1) {
throw new Error('LETTABOT_CONFIG_YAML must be raw YAML or base64-encoded YAML');
}
const padded = normalizedNoPad + '='.repeat((4 - (normalizedNoPad.length % 4)) % 4);
const decoded = Buffer.from(padded, 'base64').toString('utf-8');
const roundTrip = Buffer.from(decoded, 'utf-8').toString('base64').replace(/=+$/, '');
if (roundTrip !== normalizedNoPad) {
throw new Error('LETTABOT_CONFIG_YAML is not valid base64');
}
try {
const parsed = YAML.parse(decoded);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Decoded YAML must be an object');
}
} catch {
throw new Error('LETTABOT_CONFIG_YAML decoded from base64 but is not valid YAML');
}
return decoded;
} }
/** /**
* Decode inline config from LETTABOT_CONFIG_YAML env var. * Decode inline config from LETTABOT_CONFIG_YAML env var.
*/ */
function decodeInlineConfig(): string { function decodeInlineConfig(): string {
return decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML!); const value = getInlineConfigEnvValue();
if (!value) {
throw new Error('LETTABOT_CONFIG_YAML is empty');
}
return decodeYamlOrBase64(value);
} }
/** /**

View File

@@ -116,7 +116,7 @@ describe('normalizeAgents', () => {
name: 'Bot1', name: 'Bot1',
channels: { channels: {
telegram: { enabled: true, token: 'token1' }, telegram: { enabled: true, token: 'token1' },
slack: { enabled: true, botToken: 'missing-app-token' }, slack: { enabled: true, botToken: 'token1', appToken: 'app1' },
}, },
}, },
{ {
@@ -140,7 +140,7 @@ describe('normalizeAgents', () => {
expect(agents).toHaveLength(2); expect(agents).toHaveLength(2);
expect(agents[0].channels.telegram?.token).toBe('token1'); expect(agents[0].channels.telegram?.token).toBe('token1');
expect(agents[0].channels.slack).toBeUndefined(); expect(agents[0].channels.slack?.botToken).toBe('token1');
expect(agents[1].channels.slack?.botToken).toBe('token2'); expect(agents[1].channels.slack?.botToken).toBe('token2');
expect(agents[1].channels.discord).toBeUndefined(); expect(agents[1].channels.discord).toBeUndefined();
}); });
@@ -173,7 +173,7 @@ describe('normalizeAgents', () => {
expect(agents[0].name).toBe('LettaBot'); expect(agents[0].name).toBe('LettaBot');
}); });
it('should drop channels without required credentials', () => { it('should fail fast when enabled channels are missing required credentials', () => {
const config: LettaBotConfig = { const config: LettaBotConfig = {
server: { mode: 'cloud' }, server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' }, agent: { name: 'TestBot', model: 'test' },
@@ -198,9 +198,40 @@ describe('normalizeAgents', () => {
}, },
}; };
const agents = normalizeAgents(config); expect(() => normalizeAgents(config)).toThrow('Invalid channel configuration');
});
expect(agents[0].channels).toEqual({}); it('should fail fast when telegram-mtproto is missing required credentials', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {
'telegram-mtproto': {
enabled: true,
apiId: 12345,
// Missing apiHash and phoneNumber
},
},
};
expect(() => normalizeAgents(config)).toThrow('channels.telegram-mtproto');
});
it('should fail fast when telegram-mtproto apiId is not a positive integer', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {
'telegram-mtproto': {
enabled: true,
apiId: 0,
apiHash: 'hash',
phoneNumber: '+15550001111',
},
},
};
expect(() => normalizeAgents(config)).toThrow('channels.telegram-mtproto');
}); });
it('should preserve agent id when provided', () => { it('should preserve agent id when provided', () => {
@@ -613,6 +644,26 @@ describe('normalizeAgents', () => {
expect(agents[0].channels.signal?.readReceipts).toBe(false); expect(agents[0].channels.signal?.readReceipts).toBe(false);
}); });
it('treats empty boolean env vars as unset for channel defaults', () => {
process.env.WHATSAPP_ENABLED = 'true';
process.env.WHATSAPP_SELF_CHAT_MODE = ' ';
process.env.SIGNAL_PHONE_NUMBER = '+1234567890';
process.env.SIGNAL_READ_RECEIPTS = '';
process.env.SIGNAL_SELF_CHAT_MODE = ' ';
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.whatsapp?.selfChat).toBe(true);
expect(agents[0].channels.signal?.readReceipts).toBe(true);
expect(agents[0].channels.signal?.selfChat).toBe(true);
});
it('should pick up allowedUsers from env vars for all channels', () => { it('should pick up allowedUsers from env vars for all channels', () => {
process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; process.env.TELEGRAM_BOT_TOKEN = 'tg-token';
process.env.TELEGRAM_DM_POLICY = 'allowlist'; process.env.TELEGRAM_DM_POLICY = 'allowlist';
@@ -658,6 +709,20 @@ describe('normalizeAgents', () => {
expect(agents[0].channels.signal?.dmPolicy).toBe('allowlist'); expect(agents[0].channels.signal?.dmPolicy).toBe('allowlist');
expect(agents[0].channels.signal?.allowedUsers).toEqual(['+1555111111']); expect(agents[0].channels.signal?.allowedUsers).toEqual(['+1555111111']);
}); });
it('treats empty allowed-users env vars as unset', () => {
process.env.TELEGRAM_BOT_TOKEN = 'tg-token';
process.env.TELEGRAM_ALLOWED_USERS = ' , , ';
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.telegram?.allowedUsers).toBeUndefined();
});
}); });
it('should preserve features, polling, and integrations', () => { it('should preserve features, polling, and integrations', () => {

View File

@@ -568,6 +568,9 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
const normalized: AgentConfig['channels'] = {}; const normalized: AgentConfig['channels'] = {};
if (!channels) return normalized; if (!channels) return normalized;
const hasValidMtprotoApiId = (value: unknown): value is number =>
typeof value === 'number' && Number.isInteger(value) && value > 0;
// Merge env vars into YAML blocks that are missing their key credential. // Merge env vars into YAML blocks that are missing their key credential.
// Without this, `signal: enabled: true` + SIGNAL_PHONE_NUMBER env var // Without this, `signal: enabled: true` + SIGNAL_PHONE_NUMBER env var
// silently fails because the env-var-only fallback (below) only fires // silently fails because the env-var-only fallback (below) only fires
@@ -585,14 +588,32 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
if (channels.discord && !channels.discord.token && process.env.DISCORD_BOT_TOKEN) { if (channels.discord && !channels.discord.token && process.env.DISCORD_BOT_TOKEN) {
channels.discord.token = process.env.DISCORD_BOT_TOKEN; channels.discord.token = process.env.DISCORD_BOT_TOKEN;
} }
if (channels['telegram-mtproto']) {
if (channels['telegram-mtproto'].apiId === undefined && process.env.TELEGRAM_API_ID) {
const parsedApiId = parseInt(process.env.TELEGRAM_API_ID, 10);
if (hasValidMtprotoApiId(parsedApiId)) {
channels['telegram-mtproto'].apiId = parsedApiId;
}
}
if (!channels['telegram-mtproto'].apiHash && process.env.TELEGRAM_API_HASH) {
channels['telegram-mtproto'].apiHash = process.env.TELEGRAM_API_HASH;
}
if (!channels['telegram-mtproto'].phoneNumber && process.env.TELEGRAM_PHONE_NUMBER) {
channels['telegram-mtproto'].phoneNumber = process.env.TELEGRAM_PHONE_NUMBER;
}
}
if (channels.telegram?.enabled !== false && channels.telegram?.token) { if (channels.telegram?.enabled !== false && channels.telegram?.token) {
const telegram = { ...channels.telegram }; const telegram = { ...channels.telegram };
normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`); normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`);
normalized.telegram = telegram; normalized.telegram = telegram;
} }
// telegram-mtproto: check apiId as the key credential if (
if (channels['telegram-mtproto']?.enabled !== false && channels['telegram-mtproto']?.apiId) { channels['telegram-mtproto']?.enabled !== false
&& hasValidMtprotoApiId(channels['telegram-mtproto']?.apiId)
&& !!channels['telegram-mtproto']?.apiHash
&& !!channels['telegram-mtproto']?.phoneNumber
) {
normalized['telegram-mtproto'] = channels['telegram-mtproto']; normalized['telegram-mtproto'] = channels['telegram-mtproto'];
} }
if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) { if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) {
@@ -627,17 +648,23 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
} }
} }
// Warn when a channel block exists but was dropped due to missing credentials const channelCredentials: Array<{ name: string; raw: unknown; included: boolean; required: string }> = [
const channelCredentials: Array<[string, unknown, boolean]> = [ { name: 'telegram', raw: channels.telegram, included: !!normalized.telegram, required: 'token' },
['telegram', channels.telegram, !!normalized.telegram], { name: 'telegram-mtproto', raw: channels['telegram-mtproto'], included: !!normalized['telegram-mtproto'], required: 'apiId, apiHash, phoneNumber' },
['slack', channels.slack, !!normalized.slack], { name: 'slack', raw: channels.slack, included: !!normalized.slack, required: 'botToken, appToken' },
['signal', channels.signal, !!normalized.signal], { name: 'signal', raw: channels.signal, included: !!normalized.signal, required: 'phone' },
['discord', channels.discord, !!normalized.discord], { name: 'discord', raw: channels.discord, included: !!normalized.discord, required: 'token' },
]; ];
for (const [name, raw, included] of channelCredentials) {
if (raw && (raw as Record<string, unknown>).enabled !== false && !included) { const invalidChannels = channelCredentials
log.warn(`Channel '${name}' is in ${sourcePath} but missing required credentials -- skipping. Check your lettabot.yaml or environment variables.`); .filter(({ raw, included }) => !!raw && (raw as Record<string, unknown>).enabled !== false && !included)
} .map(({ name, required }) => `- ${sourcePath}.${name}: missing required field(s): ${required}`);
if (invalidChannels.length > 0) {
throw new Error(
`Invalid channel configuration:\n${invalidChannels.join('\n')}\n` +
'Set required credentials in lettabot.yaml or environment variables, or set enabled: false.'
);
} }
return normalized; return normalized;
@@ -662,8 +689,20 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
// Env var fallback for container deploys without lettabot.yaml (e.g. Railway) // Env var fallback for container deploys without lettabot.yaml (e.g. Railway)
// Helper: parse comma-separated env var into string array (or undefined) // Helper: parse comma-separated env var into string array (or undefined)
const parseList = (envVar?: string): string[] | undefined => const parseList = (envVar?: string): string[] | undefined => {
envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined; if (envVar === undefined) return undefined;
const values = envVar.split(',').map(s => s.trim()).filter(Boolean);
return values.length > 0 ? values : undefined;
};
const parseOptionalBooleanEnv = (envVar?: string): boolean | undefined => {
if (envVar === undefined) return undefined;
const normalized = envVar.trim().toLowerCase();
if (!normalized) return undefined;
if (normalized === 'true') return true;
if (normalized === 'false') return false;
return undefined;
};
if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) { if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) {
channels.telegram = { channels.telegram = {
@@ -699,7 +738,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') { if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') {
channels.whatsapp = { channels.whatsapp = {
enabled: true, enabled: true,
selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false', selfChat: parseOptionalBooleanEnv(process.env.WHATSAPP_SELF_CHAT_MODE) ?? true,
dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS), allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS),
}; };
@@ -708,8 +747,8 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
channels.signal = { channels.signal = {
enabled: true, enabled: true,
phone: process.env.SIGNAL_PHONE_NUMBER, phone: process.env.SIGNAL_PHONE_NUMBER,
readReceipts: process.env.SIGNAL_READ_RECEIPTS !== 'false', readReceipts: parseOptionalBooleanEnv(process.env.SIGNAL_READ_RECEIPTS) ?? true,
selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', selfChat: parseOptionalBooleanEnv(process.env.SIGNAL_SELF_CHAT_MODE) ?? true,
dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS), allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS),
}; };

View File

@@ -19,6 +19,7 @@ import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, re
import { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js'; import { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js';
import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js'; import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js';
import type { GroupBatcher } from './group-batcher.js'; import type { GroupBatcher } from './group-batcher.js';
import { recoverPendingApprovalsWithSdk } from './session-sdk-compat.js';
import { redactOutbound } from './redact.js'; import { redactOutbound } from './redact.js';
import { import {
hasIncompleteActionsTag, hasIncompleteActionsTag,
@@ -1562,7 +1563,7 @@ export class LettaBot implements AgentSession {
// Try SDK-level recovery first (through CLI control protocol) // Try SDK-level recovery first (through CLI control protocol)
if (session) { if (session) {
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000);
if (sdkResult.recovered) { if (sdkResult.recovered) {
log.info('SDK approval recovery succeeded, retrying message...'); log.info('SDK approval recovery succeeded, retrying message...');
this.sessionManager.invalidateSession(retryConvKey); this.sessionManager.invalidateSession(retryConvKey);
@@ -1886,7 +1887,7 @@ export class LettaBot implements AgentSession {
&& (lastErrorDetail?.message?.toLowerCase().includes('waiting for approval') || false)); && (lastErrorDetail?.message?.toLowerCase().includes('waiting for approval') || false));
if (isApprovalIssue && !retried) { if (isApprovalIssue && !retried) {
log.info('sendToAgent: approval conflict detected -- attempting SDK recovery...'); log.info('sendToAgent: approval conflict detected -- attempting SDK recovery...');
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000);
if (sdkResult.recovered) { if (sdkResult.recovered) {
log.info('sendToAgent: SDK approval recovery succeeded'); log.info('sendToAgent: SDK approval recovery succeeded');
} else { } else {

View File

@@ -112,7 +112,7 @@ export function formatApiErrorForUser(error: { message: string; stopReason: stri
|| apiMsg.includes('409') || apiMsg.includes('409')
|| stopReason === 'requires_approval'; || stopReason === 'requires_approval';
if (hasApprovalSignal && hasConflictSignal) { if (hasApprovalSignal && hasConflictSignal) {
return '(A stuck tool approval is blocking this conversation. Run `lettabot reset-conversation` to clear it, or approve/deny the pending request at app.letta.com.)'; return '(A stuck tool approval is blocking this conversation. Send /reset to start a new conversation, or approve/deny the pending request at app.letta.com. Note: /reset creates a fresh conversation -- previous context will no longer be active.)';
} }
// 409 CONFLICT (concurrent request on same conversation) // 409 CONFLICT (concurrent request on same conversation)

View File

@@ -16,6 +16,7 @@ import { loadMemoryBlocks } from './memory.js';
import { SYSTEM_PROMPT } from './system-prompt.js'; import { SYSTEM_PROMPT } from './system-prompt.js';
import { createManageTodoTool } from '../tools/todo.js'; import { createManageTodoTool } from '../tools/todo.js';
import { syncTodosFromTool } from '../todo/store.js'; import { syncTodosFromTool } from '../todo/store.js';
import { recoverPendingApprovalsWithSdk } from './session-sdk-compat.js';
import { createLogger } from '../logger.js'; import { createLogger } from '../logger.js';
const log = createLogger('Session'); const log = createLogger('Session');
@@ -376,7 +377,7 @@ export class SessionManager {
log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`); log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`);
// Try SDK-level recovery first (goes through CLI control protocol) // Try SDK-level recovery first (goes through CLI control protocol)
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000);
if (sdkResult.recovered) { if (sdkResult.recovered) {
log.info('Proactive SDK approval recovery succeeded'); log.info('Proactive SDK approval recovery succeeded');
return this._createSessionForKey(key, true, generation); return this._createSessionForKey(key, true, generation);
@@ -574,7 +575,7 @@ export class SessionManager {
// 409 CONFLICT from orphaned approval -- use SDK recovery first, fall back to API // 409 CONFLICT from orphaned approval -- use SDK recovery first, fall back to API
if (!retried && isApprovalConflictError(error) && this.store.agentId) { if (!retried && isApprovalConflictError(error) && this.store.agentId) {
log.info('CONFLICT detected - attempting SDK approval recovery...'); log.info('CONFLICT detected - attempting SDK approval recovery...');
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000);
if (sdkResult.recovered) { if (sdkResult.recovered) {
log.info('SDK approval recovery succeeded, retrying...'); log.info('SDK approval recovery succeeded, retrying...');
return this.runSession(message, { retried: true, canUseTool, convKey }); return this.runSession(message, { retried: true, canUseTool, convKey });

View File

@@ -0,0 +1,39 @@
import type { Session } from '@letta-ai/letta-code-sdk';
type ApprovalRecoveryResult = {
recovered: boolean;
detail?: string;
};
type SessionWithApprovalRecovery = Session & {
recoverPendingApprovals?: (options?: { timeoutMs?: number }) => Promise<ApprovalRecoveryResult>;
};
/**
* SDK compatibility shim for approval recovery.
*
* Some SDK versions expose `recoverPendingApprovals` at runtime before the
* TypeScript Session type includes it. This helper keeps compile-time safety
* while preserving the existing runtime fallback behavior.
*/
export async function recoverPendingApprovalsWithSdk(
session: Session,
timeoutMs = 10_000,
): Promise<ApprovalRecoveryResult> {
const recover = (session as SessionWithApprovalRecovery).recoverPendingApprovals;
if (typeof recover !== 'function') {
return {
recovered: false,
detail: 'Session.recoverPendingApprovals is unavailable in this SDK version',
};
}
try {
return await recover.call(session, { timeoutMs });
} catch (error) {
return {
recovered: false,
detail: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -23,6 +23,15 @@ function parseOptionalCsvList(value?: string): string[] | undefined {
return items.length > 0 ? items : undefined; return items.length > 0 ? items : undefined;
} }
function parseOptionalBoolean(value?: string): boolean | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (normalized === 'true') return true;
if (normalized === 'false') return false;
return undefined;
}
function readConfigFromEnv(existingConfig: any): any { function readConfigFromEnv(existingConfig: any): any {
return { return {
baseUrl: process.env.LETTA_BASE_URL || existingConfig.server?.baseUrl || 'https://api.letta.com', baseUrl: process.env.LETTA_BASE_URL || existingConfig.server?.baseUrl || 'https://api.letta.com',
@@ -34,7 +43,8 @@ function readConfigFromEnv(existingConfig: any): any {
enabled: !!process.env.TELEGRAM_BOT_TOKEN, enabled: !!process.env.TELEGRAM_BOT_TOKEN,
botToken: process.env.TELEGRAM_BOT_TOKEN || existingConfig.channels?.telegram?.token, botToken: process.env.TELEGRAM_BOT_TOKEN || existingConfig.channels?.telegram?.token,
dmPolicy: process.env.TELEGRAM_DM_POLICY || existingConfig.channels?.telegram?.dmPolicy || 'pairing', dmPolicy: process.env.TELEGRAM_DM_POLICY || existingConfig.channels?.telegram?.dmPolicy || 'pairing',
allowedUsers: process.env.TELEGRAM_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.telegram?.allowedUsers, allowedUsers: parseOptionalCsvList(process.env.TELEGRAM_ALLOWED_USERS)
?? existingConfig.channels?.telegram?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.TELEGRAM_GROUP_DEBOUNCE_SEC) groupDebounceSec: parseOptionalInt(process.env.TELEGRAM_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.telegram?.groupDebounceSec, ?? existingConfig.channels?.telegram?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN) groupPollIntervalMin: parseOptionalInt(process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN)
@@ -50,7 +60,8 @@ function readConfigFromEnv(existingConfig: any): any {
botToken: process.env.SLACK_BOT_TOKEN || existingConfig.channels?.slack?.botToken, botToken: process.env.SLACK_BOT_TOKEN || existingConfig.channels?.slack?.botToken,
appToken: process.env.SLACK_APP_TOKEN || existingConfig.channels?.slack?.appToken, appToken: process.env.SLACK_APP_TOKEN || existingConfig.channels?.slack?.appToken,
dmPolicy: process.env.SLACK_DM_POLICY || existingConfig.channels?.slack?.dmPolicy || 'pairing', dmPolicy: process.env.SLACK_DM_POLICY || existingConfig.channels?.slack?.dmPolicy || 'pairing',
allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.slack?.allowedUsers, allowedUsers: parseOptionalCsvList(process.env.SLACK_ALLOWED_USERS)
?? existingConfig.channels?.slack?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.SLACK_GROUP_DEBOUNCE_SEC) groupDebounceSec: parseOptionalInt(process.env.SLACK_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.slack?.groupDebounceSec, ?? existingConfig.channels?.slack?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.SLACK_GROUP_POLL_INTERVAL_MIN) groupPollIntervalMin: parseOptionalInt(process.env.SLACK_GROUP_POLL_INTERVAL_MIN)
@@ -65,7 +76,8 @@ function readConfigFromEnv(existingConfig: any): any {
enabled: !!process.env.DISCORD_BOT_TOKEN, enabled: !!process.env.DISCORD_BOT_TOKEN,
botToken: process.env.DISCORD_BOT_TOKEN || existingConfig.channels?.discord?.token, botToken: process.env.DISCORD_BOT_TOKEN || existingConfig.channels?.discord?.token,
dmPolicy: process.env.DISCORD_DM_POLICY || existingConfig.channels?.discord?.dmPolicy || 'pairing', dmPolicy: process.env.DISCORD_DM_POLICY || existingConfig.channels?.discord?.dmPolicy || 'pairing',
allowedUsers: process.env.DISCORD_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.discord?.allowedUsers, allowedUsers: parseOptionalCsvList(process.env.DISCORD_ALLOWED_USERS)
?? existingConfig.channels?.discord?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.DISCORD_GROUP_DEBOUNCE_SEC) groupDebounceSec: parseOptionalInt(process.env.DISCORD_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.discord?.groupDebounceSec, ?? existingConfig.channels?.discord?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.DISCORD_GROUP_POLL_INTERVAL_MIN) groupPollIntervalMin: parseOptionalInt(process.env.DISCORD_GROUP_POLL_INTERVAL_MIN)
@@ -77,10 +89,13 @@ function readConfigFromEnv(existingConfig: any): any {
}, },
whatsapp: { whatsapp: {
enabled: process.env.WHATSAPP_ENABLED === 'true' || !!existingConfig.channels?.whatsapp?.enabled, enabled: parseOptionalBoolean(process.env.WHATSAPP_ENABLED)
selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false' && (existingConfig.channels?.whatsapp?.selfChat !== false), ?? !!existingConfig.channels?.whatsapp?.enabled,
selfChat: parseOptionalBoolean(process.env.WHATSAPP_SELF_CHAT_MODE)
?? (existingConfig.channels?.whatsapp?.selfChat ?? true),
dmPolicy: process.env.WHATSAPP_DM_POLICY || existingConfig.channels?.whatsapp?.dmPolicy || 'pairing', dmPolicy: process.env.WHATSAPP_DM_POLICY || existingConfig.channels?.whatsapp?.dmPolicy || 'pairing',
allowedUsers: process.env.WHATSAPP_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.whatsapp?.allowedUsers, allowedUsers: parseOptionalCsvList(process.env.WHATSAPP_ALLOWED_USERS)
?? existingConfig.channels?.whatsapp?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.WHATSAPP_GROUP_DEBOUNCE_SEC) groupDebounceSec: parseOptionalInt(process.env.WHATSAPP_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.whatsapp?.groupDebounceSec, ?? existingConfig.channels?.whatsapp?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN) groupPollIntervalMin: parseOptionalInt(process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN)
@@ -94,9 +109,11 @@ function readConfigFromEnv(existingConfig: any): any {
signal: { signal: {
enabled: !!process.env.SIGNAL_PHONE_NUMBER, enabled: !!process.env.SIGNAL_PHONE_NUMBER,
phoneNumber: process.env.SIGNAL_PHONE_NUMBER || existingConfig.channels?.signal?.phoneNumber, phoneNumber: process.env.SIGNAL_PHONE_NUMBER || existingConfig.channels?.signal?.phoneNumber,
selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false' && (existingConfig.channels?.signal?.selfChat !== false), selfChat: parseOptionalBoolean(process.env.SIGNAL_SELF_CHAT_MODE)
?? (existingConfig.channels?.signal?.selfChat ?? true),
dmPolicy: process.env.SIGNAL_DM_POLICY || existingConfig.channels?.signal?.dmPolicy || 'pairing', dmPolicy: process.env.SIGNAL_DM_POLICY || existingConfig.channels?.signal?.dmPolicy || 'pairing',
allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.signal?.allowedUsers, allowedUsers: parseOptionalCsvList(process.env.SIGNAL_ALLOWED_USERS)
?? existingConfig.channels?.signal?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.SIGNAL_GROUP_DEBOUNCE_SEC) groupDebounceSec: parseOptionalInt(process.env.SIGNAL_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.signal?.groupDebounceSec, ?? existingConfig.channels?.signal?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN) groupPollIntervalMin: parseOptionalInt(process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN)

View File

@@ -63,8 +63,9 @@ describe('skills loader', () => {
}); });
describe('getAgentSkillsDir', () => { describe('getAgentSkillsDir', () => {
it('uses Railway volume path for agent-scoped skills when mounted', async () => { it('uses Railway volume for agent-scoped skills when mounted', async () => {
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume'; process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume';
delete process.env.WORKING_DIR;
const mod = await importFreshLoader(); const mod = await importFreshLoader();
const dir = mod.getAgentSkillsDir('agent-railway'); const dir = mod.getAgentSkillsDir('agent-railway');

View File

@@ -63,6 +63,14 @@ describe('cron path resolution', () => {
expect(getCronDataDir()).toBe('/custom/work'); expect(getCronDataDir()).toBe('/custom/work');
}); });
it('normalizes WORKING_DIR for cron data paths', () => {
process.env.WORKING_DIR = '~/cron-work';
expect(getCronDataDir()).toBe(resolve(homedir(), 'cron-work'));
process.env.WORKING_DIR = 'relative/cron-work';
expect(getCronDataDir()).toBe(resolve('relative/cron-work'));
});
it('falls back to /tmp/lettabot when no overrides are set', () => { it('falls back to /tmp/lettabot when no overrides are set', () => {
expect(getCronDataDir()).toBe('/tmp/lettabot'); expect(getCronDataDir()).toBe('/tmp/lettabot');
expect(getCronStorePath()).toBe('/tmp/lettabot/cron-jobs.json'); expect(getCronStorePath()).toBe('/tmp/lettabot/cron-jobs.json');

View File

@@ -91,7 +91,7 @@ export function getCronDataDir(): string {
} }
if (process.env.WORKING_DIR) { if (process.env.WORKING_DIR) {
return process.env.WORKING_DIR; return resolveWorkingDirPath(process.env.WORKING_DIR);
} }
return '/tmp/lettabot'; return '/tmp/lettabot';