fix: clarify stuck approval error message with /reset and consequences (#590)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 { wasLoadedFromFleetConfig, setLoadedFromFleetConfig } from './fleet-adapter.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', () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
|
||||
@@ -21,6 +21,13 @@ import { LETTA_API_URL } from '../auth/oauth.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
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)
|
||||
function getConfigPaths(): string[] {
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
* Detection: if the value contains a colon, it's raw YAML (every valid config
|
||||
* has key: value pairs). Otherwise it's base64 (which uses only [A-Za-z0-9+/=]).
|
||||
* Detection strategy:
|
||||
* 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 {
|
||||
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 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('normalizeAgents', () => {
|
||||
name: 'Bot1',
|
||||
channels: {
|
||||
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[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.discord).toBeUndefined();
|
||||
});
|
||||
@@ -173,7 +173,7 @@ describe('normalizeAgents', () => {
|
||||
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 = {
|
||||
server: { mode: 'cloud' },
|
||||
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', () => {
|
||||
@@ -613,6 +644,26 @@ describe('normalizeAgents', () => {
|
||||
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', () => {
|
||||
process.env.TELEGRAM_BOT_TOKEN = 'tg-token';
|
||||
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?.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', () => {
|
||||
|
||||
@@ -568,6 +568,9 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
|
||||
const normalized: AgentConfig['channels'] = {};
|
||||
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.
|
||||
// Without this, `signal: enabled: true` + SIGNAL_PHONE_NUMBER env var
|
||||
// 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) {
|
||||
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) {
|
||||
const telegram = { ...channels.telegram };
|
||||
normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`);
|
||||
normalized.telegram = telegram;
|
||||
}
|
||||
// telegram-mtproto: check apiId as the key credential
|
||||
if (channels['telegram-mtproto']?.enabled !== false && channels['telegram-mtproto']?.apiId) {
|
||||
if (
|
||||
channels['telegram-mtproto']?.enabled !== false
|
||||
&& hasValidMtprotoApiId(channels['telegram-mtproto']?.apiId)
|
||||
&& !!channels['telegram-mtproto']?.apiHash
|
||||
&& !!channels['telegram-mtproto']?.phoneNumber
|
||||
) {
|
||||
normalized['telegram-mtproto'] = channels['telegram-mtproto'];
|
||||
}
|
||||
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<[string, unknown, boolean]> = [
|
||||
['telegram', channels.telegram, !!normalized.telegram],
|
||||
['slack', channels.slack, !!normalized.slack],
|
||||
['signal', channels.signal, !!normalized.signal],
|
||||
['discord', channels.discord, !!normalized.discord],
|
||||
const channelCredentials: Array<{ name: string; raw: unknown; included: boolean; required: string }> = [
|
||||
{ name: 'telegram', raw: channels.telegram, included: !!normalized.telegram, required: 'token' },
|
||||
{ name: 'telegram-mtproto', raw: channels['telegram-mtproto'], included: !!normalized['telegram-mtproto'], required: 'apiId, apiHash, phoneNumber' },
|
||||
{ name: 'slack', raw: channels.slack, included: !!normalized.slack, required: 'botToken, appToken' },
|
||||
{ name: 'signal', raw: channels.signal, included: !!normalized.signal, required: 'phone' },
|
||||
{ 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) {
|
||||
log.warn(`Channel '${name}' is in ${sourcePath} but missing required credentials -- skipping. Check your lettabot.yaml or environment variables.`);
|
||||
}
|
||||
|
||||
const invalidChannels = channelCredentials
|
||||
.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;
|
||||
@@ -662,8 +689,20 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
|
||||
|
||||
// Env var fallback for container deploys without lettabot.yaml (e.g. Railway)
|
||||
// Helper: parse comma-separated env var into string array (or undefined)
|
||||
const parseList = (envVar?: string): string[] | undefined =>
|
||||
envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
||||
const parseList = (envVar?: string): string[] | 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) {
|
||||
channels.telegram = {
|
||||
@@ -699,7 +738,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
|
||||
if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') {
|
||||
channels.whatsapp = {
|
||||
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',
|
||||
allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS),
|
||||
};
|
||||
@@ -708,8 +747,8 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
|
||||
channels.signal = {
|
||||
enabled: true,
|
||||
phone: process.env.SIGNAL_PHONE_NUMBER,
|
||||
readReceipts: process.env.SIGNAL_READ_RECEIPTS !== 'false',
|
||||
selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false',
|
||||
readReceipts: parseOptionalBooleanEnv(process.env.SIGNAL_READ_RECEIPTS) ?? true,
|
||||
selfChat: parseOptionalBooleanEnv(process.env.SIGNAL_SELF_CHAT_MODE) ?? true,
|
||||
dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
|
||||
allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, re
|
||||
import { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js';
|
||||
import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js';
|
||||
import type { GroupBatcher } from './group-batcher.js';
|
||||
import { recoverPendingApprovalsWithSdk } from './session-sdk-compat.js';
|
||||
import { redactOutbound } from './redact.js';
|
||||
import {
|
||||
hasIncompleteActionsTag,
|
||||
@@ -1562,7 +1563,7 @@ export class LettaBot implements AgentSession {
|
||||
|
||||
// Try SDK-level recovery first (through CLI control protocol)
|
||||
if (session) {
|
||||
const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 });
|
||||
const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000);
|
||||
if (sdkResult.recovered) {
|
||||
log.info('SDK approval recovery succeeded, retrying message...');
|
||||
this.sessionManager.invalidateSession(retryConvKey);
|
||||
@@ -1886,7 +1887,7 @@ export class LettaBot implements AgentSession {
|
||||
&& (lastErrorDetail?.message?.toLowerCase().includes('waiting for approval') || false));
|
||||
if (isApprovalIssue && !retried) {
|
||||
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) {
|
||||
log.info('sendToAgent: SDK approval recovery succeeded');
|
||||
} else {
|
||||
|
||||
@@ -112,7 +112,7 @@ export function formatApiErrorForUser(error: { message: string; stopReason: stri
|
||||
|| apiMsg.includes('409')
|
||||
|| stopReason === 'requires_approval';
|
||||
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)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { loadMemoryBlocks } from './memory.js';
|
||||
import { SYSTEM_PROMPT } from './system-prompt.js';
|
||||
import { createManageTodoTool } from '../tools/todo.js';
|
||||
import { syncTodosFromTool } from '../todo/store.js';
|
||||
import { recoverPendingApprovalsWithSdk } from './session-sdk-compat.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
const log = createLogger('Session');
|
||||
@@ -376,7 +377,7 @@ export class SessionManager {
|
||||
log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`);
|
||||
|
||||
// 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) {
|
||||
log.info('Proactive SDK approval recovery succeeded');
|
||||
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
|
||||
if (!retried && isApprovalConflictError(error) && this.store.agentId) {
|
||||
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) {
|
||||
log.info('SDK approval recovery succeeded, retrying...');
|
||||
return this.runSession(message, { retried: true, canUseTool, convKey });
|
||||
|
||||
39
src/core/session-sdk-compat.ts
Normal file
39
src/core/session-sdk-compat.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,15 @@ function parseOptionalCsvList(value?: string): string[] | 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 {
|
||||
return {
|
||||
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,
|
||||
botToken: process.env.TELEGRAM_BOT_TOKEN || existingConfig.channels?.telegram?.token,
|
||||
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)
|
||||
?? existingConfig.channels?.telegram?.groupDebounceSec,
|
||||
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,
|
||||
appToken: process.env.SLACK_APP_TOKEN || existingConfig.channels?.slack?.appToken,
|
||||
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)
|
||||
?? existingConfig.channels?.slack?.groupDebounceSec,
|
||||
groupPollIntervalMin: parseOptionalInt(process.env.SLACK_GROUP_POLL_INTERVAL_MIN)
|
||||
@@ -65,7 +76,8 @@ function readConfigFromEnv(existingConfig: any): any {
|
||||
enabled: !!process.env.DISCORD_BOT_TOKEN,
|
||||
botToken: process.env.DISCORD_BOT_TOKEN || existingConfig.channels?.discord?.token,
|
||||
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)
|
||||
?? existingConfig.channels?.discord?.groupDebounceSec,
|
||||
groupPollIntervalMin: parseOptionalInt(process.env.DISCORD_GROUP_POLL_INTERVAL_MIN)
|
||||
@@ -77,10 +89,13 @@ function readConfigFromEnv(existingConfig: any): any {
|
||||
},
|
||||
|
||||
whatsapp: {
|
||||
enabled: process.env.WHATSAPP_ENABLED === 'true' || !!existingConfig.channels?.whatsapp?.enabled,
|
||||
selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false' && (existingConfig.channels?.whatsapp?.selfChat !== false),
|
||||
enabled: parseOptionalBoolean(process.env.WHATSAPP_ENABLED)
|
||||
?? !!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',
|
||||
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)
|
||||
?? existingConfig.channels?.whatsapp?.groupDebounceSec,
|
||||
groupPollIntervalMin: parseOptionalInt(process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN)
|
||||
@@ -94,9 +109,11 @@ function readConfigFromEnv(existingConfig: any): any {
|
||||
signal: {
|
||||
enabled: !!process.env.SIGNAL_PHONE_NUMBER,
|
||||
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',
|
||||
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)
|
||||
?? existingConfig.channels?.signal?.groupDebounceSec,
|
||||
groupPollIntervalMin: parseOptionalInt(process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN)
|
||||
|
||||
@@ -63,8 +63,9 @@ describe('skills loader', () => {
|
||||
});
|
||||
|
||||
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';
|
||||
delete process.env.WORKING_DIR;
|
||||
|
||||
const mod = await importFreshLoader();
|
||||
const dir = mod.getAgentSkillsDir('agent-railway');
|
||||
|
||||
@@ -63,6 +63,14 @@ describe('cron path resolution', () => {
|
||||
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', () => {
|
||||
expect(getCronDataDir()).toBe('/tmp/lettabot');
|
||||
expect(getCronStorePath()).toBe('/tmp/lettabot/cron-jobs.json');
|
||||
|
||||
@@ -91,7 +91,7 @@ export function getCronDataDir(): string {
|
||||
}
|
||||
|
||||
if (process.env.WORKING_DIR) {
|
||||
return process.env.WORKING_DIR;
|
||||
return resolveWorkingDirPath(process.env.WORKING_DIR);
|
||||
}
|
||||
|
||||
return '/tmp/lettabot';
|
||||
|
||||
Reference in New Issue
Block a user