refactor: extract memfs resolution with server-mode awareness (#560)

This commit is contained in:
Cameron
2026-03-11 14:51:41 -07:00
committed by GitHub
parent ef1504bd9a
commit 08ee846b71
6 changed files with 459 additions and 8 deletions

58
src/config/memfs.test.ts Normal file
View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { resolveSessionMemfs } from './memfs.js';
describe('resolveSessionMemfs', () => {
it('uses explicit agent config first', () => {
const result = resolveSessionMemfs({
configuredMemfs: true,
envMemfs: 'false',
serverMode: 'api',
});
expect(result).toEqual({ value: true, source: 'config' });
});
it('uses LETTABOT_MEMFS env override when config is unset', () => {
const result = resolveSessionMemfs({
envMemfs: 'false',
serverMode: 'api',
});
expect(result).toEqual({ value: false, source: 'env' });
});
it('defaults to memfs false in docker/selfhosted mode when unset', () => {
const result = resolveSessionMemfs({
serverMode: 'selfhosted',
});
expect(result).toEqual({ value: false, source: 'default-docker' });
});
it('leaves memfs unchanged in api/cloud mode when unset', () => {
const result = resolveSessionMemfs({
serverMode: 'cloud',
});
expect(result).toEqual({ value: undefined, source: 'unset' });
});
it('ignores invalid LETTABOT_MEMFS values', () => {
const result = resolveSessionMemfs({
envMemfs: 'yes',
serverMode: 'api',
});
expect(result).toEqual({ value: undefined, source: 'unset' });
});
it('treats null configured memfs as unset and applies docker default', () => {
const result = resolveSessionMemfs({
configuredMemfs: null as unknown as boolean,
serverMode: 'selfhosted',
});
expect(result).toEqual({ value: false, source: 'default-docker' });
});
});

48
src/config/memfs.ts Normal file
View File

@@ -0,0 +1,48 @@
import { isDockerServerMode, type ServerMode } from './types.js';
export type ResolvedMemfsSource = 'config' | 'env' | 'default-docker' | 'unset';
export interface ResolveSessionMemfsInput {
configuredMemfs?: boolean;
envMemfs?: string;
serverMode?: ServerMode;
}
export interface ResolveSessionMemfsResult {
value: boolean | undefined;
source: ResolvedMemfsSource;
}
function parseBooleanEnv(value?: string): boolean | undefined {
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
/**
* Resolve the memfs value forwarded to SDK session options.
*
* Precedence:
* 1) Per-agent config (`features.memfs`)
* 2) `LETTABOT_MEMFS` env var (`true`/`false`)
* 3) Default `false` in docker/self-hosted mode (safety)
* 4) `undefined` in API mode (leave agent memfs unchanged)
*/
export function resolveSessionMemfs(input: ResolveSessionMemfsInput): ResolveSessionMemfsResult {
// Runtime config parsing can surface non-boolean values (e.g. YAML `memfs:` -> null).
// Only treat explicit booleans as configured; everything else falls through.
if (typeof input.configuredMemfs === 'boolean') {
return { value: input.configuredMemfs, source: 'config' };
}
const envMemfs = parseBooleanEnv(input.envMemfs);
if (envMemfs !== undefined) {
return { value: envMemfs, source: 'env' };
}
if (isDockerServerMode(input.serverMode)) {
return { value: false, source: 'default-docker' };
}
return { value: undefined, source: 'unset' };
}

View File

@@ -20,6 +20,23 @@ import { createLogger } from '../logger.js';
const log = createLogger('Session');
function formatMemfsStartupOption(memfs: boolean | undefined): string {
if (memfs === true) return 'enabled (--memfs)';
if (memfs === false) return 'disabled (--no-memfs)';
return 'unchanged (omitted)';
}
function formatSleeptimeStartupOption(
sleeptime: BotConfig['sleeptime'],
): string {
if (!sleeptime) return 'none';
const parts: string[] = [];
if (sleeptime.trigger) parts.push(`trigger=${sleeptime.trigger}`);
if (sleeptime.behavior) parts.push(`behavior=${sleeptime.behavior}`);
if (sleeptime.stepCount !== undefined) parts.push(`stepCount=${sleeptime.stepCount}`);
return parts.length > 0 ? parts.join(', ') : 'configured';
}
function toConcreteConversationId(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
@@ -313,6 +330,11 @@ export class SessionManager {
}
// Initialize eagerly so the subprocess is ready before the first send()
log.info(
`Session startup options (key=${key}): ` +
`memfs=${formatMemfsStartupOption(this.config.memfs)}, ` +
`sleeptime=${formatSleeptimeStartupOption(this.config.sleeptime)}`,
);
log.info(`Initializing session subprocess (key=${key})...`);
try {
await this.withSessionTimeout(session.initialize(), `Session initialize (key=${key})`);

View File

@@ -24,6 +24,7 @@ import {
serverModeLabel,
wasLoadedFromFleetConfig,
} from './config/index.js';
import { resolveSessionMemfs } from './config/memfs.js';
import { getCronDataDir, getDataDir, getWorkingDir, hasRailwayVolume, resolveWorkingDirPath } from './utils/paths.js';
import { parseCsvList, parseNonNegativeNumber } from './utils/parse.js';
import { createLogger, setLogLevel } from './logger.js';
@@ -299,21 +300,23 @@ async function main() {
for (const agentConfig of agents) {
log.info(`Configuring agent: ${agentConfig.name}`);
// Resolve memfs: YAML config takes precedence, then env var, then default false.
// Default false prevents the SDK from auto-enabling memfs, which crashes on
// self-hosted Letta servers that don't have the git endpoint.
const resolvedMemfs = agentConfig.features?.memfs ?? (process.env.LETTABOT_MEMFS === 'true' ? true : false);
const resolvedMemfsResult = resolveSessionMemfs({
configuredMemfs: agentConfig.features?.memfs,
envMemfs: process.env.LETTABOT_MEMFS,
serverMode: yamlConfig.server.mode,
});
const resolvedMemfs = resolvedMemfsResult.value;
const configuredSleeptime = agentConfig.features?.sleeptime;
// Treat missing trigger as active (conservative): only `trigger: 'off'` explicitly disables.
const sleeptimeRequiresMemfs = !!configuredSleeptime && configuredSleeptime.trigger !== 'off';
const effectiveSleeptime = !resolvedMemfs && sleeptimeRequiresMemfs
const effectiveSleeptime = resolvedMemfs === false && sleeptimeRequiresMemfs
? undefined
: configuredSleeptime;
if (!resolvedMemfs && sleeptimeRequiresMemfs) {
if (resolvedMemfs === false && sleeptimeRequiresMemfs) {
log.warn(
`Agent ${agentConfig.name}: sleeptime is configured but memfs is disabled; ` +
`sleeptime will be ignored. Enable features.memfs (or LETTABOT_MEMFS=true) to use sleeptime.`
`sleeptime will be ignored. Enable features.memfs (or set LETTABOT_MEMFS=true) to use sleeptime.`
);
}
@@ -359,8 +362,14 @@ async function main() {
// Log memfs config (from either YAML or env var)
if (resolvedMemfs !== undefined) {
const source = agentConfig.features?.memfs !== undefined ? '' : ' (from LETTABOT_MEMFS env)';
const source = resolvedMemfsResult.source === 'config'
? ''
: resolvedMemfsResult.source === 'env'
? ' (from LETTABOT_MEMFS env)'
: ' (default for docker/selfhosted mode)';
log.info(`Agent ${agentConfig.name}: memfs ${resolvedMemfs ? 'enabled' : 'disabled'}${source}`);
} else {
log.info(`Agent ${agentConfig.name}: memfs unchanged (not explicitly configured)`);
}
// Apply explicit agent ID from config (before store verification)