feat: expose memfs (memory filesystem) option in lettabot config and SDK session (#336)
Adds features.memfs config key that controls whether the Letta Code CLI receives --memfs or --no-memfs when creating/resuming SDK sessions. This enables lettabot users to opt into git-backed memory filesystem (context repositories) for persistent local memory sync. - Config types: memfs?: boolean on AgentConfig.features, LettaBotConfig.features, BotConfig - Bot wiring: baseSessionOptions() and createAgent() pass memfs to SDK when defined - Main wiring: YAML config takes precedence, LETTABOT_MEMFS env var as fallback - Legacy fix: conversations passthrough in single-agent normalization - Tests: 3 memfs wiring tests (true/false/undefined), 2 conversations passthrough tests - Docs: configuration.md section with known limitations, example YAML Fixes #335 Written by Cameron and Letta Code "The best way to predict the future is to implement it." -- David Heinemeier Hansson
This commit is contained in:
committed by
GitHub
parent
4e697001c0
commit
a3c944bd13
@@ -200,7 +200,7 @@ Each entry in `agents:` accepts:
|
||||
| `model` | string | No | Model for agent creation |
|
||||
| `conversations` | object | No | Conversation routing config (shared vs per-channel) |
|
||||
| `channels` | object | No | Channel configs (same schema as top-level `channels:`). At least one agent must have channels. |
|
||||
| `features` | object | No | Per-agent features (cron, heartbeat, maxToolCalls) |
|
||||
| `features` | object | No | Per-agent features (cron, heartbeat, memfs, maxToolCalls) |
|
||||
| `polling` | object | No | Per-agent polling config (Gmail, etc.) |
|
||||
| `integrations` | object | No | Per-agent integrations (Google, etc.) |
|
||||
|
||||
@@ -464,6 +464,39 @@ features:
|
||||
|
||||
Enable scheduled tasks. See [Cron Setup](./cron-setup.md).
|
||||
|
||||
### Memory Filesystem (memfs)
|
||||
|
||||
Memory filesystem (also known as **Context Repositories**) syncs your agent's memory blocks to local files in a git-backed directory. This enables:
|
||||
|
||||
- **Persistent local memory**: Memory blocks are synced to `~/.letta/agents/<agent-id>/memory/` as Markdown files
|
||||
- **Git versioning**: Every change to memory is automatically versioned with informative commit messages
|
||||
- **Direct editing**: Memory files can be edited with standard tools and synced back to the agent
|
||||
- **Multi-agent collaboration**: Subagents can work in git worktrees and merge changes back
|
||||
|
||||
```yaml
|
||||
features:
|
||||
memfs: true
|
||||
```
|
||||
|
||||
When `memfs` is enabled, the SDK passes `--memfs` to the Letta Code CLI on each session. When set to `false`, `--no-memfs` is passed to explicitly disable it. When omitted (default), the agent's existing memfs setting is left unchanged.
|
||||
|
||||
You can also enable memfs via environment variable (only `true` and `false` are recognized):
|
||||
|
||||
```bash
|
||||
LETTABOT_MEMFS=true npm start
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `features.memfs` | boolean | _(undefined)_ | Enable/disable memory filesystem. `true` enables, `false` disables, omit to leave unchanged. |
|
||||
|
||||
#### Known Limitations
|
||||
|
||||
- **Headless conflict resolution** ([letta-ai/letta-code#808](https://github.com/letta-ai/letta-code/issues/808)): If memory filesystem sync conflicts exist, the CLI exits with code 1 in headless mode (which is how lettabot runs). There is currently no way to resolve conflicts programmatically. **Workaround**: Run the agent interactively first (`letta --agent <agent-id>`) to resolve conflicts, then restart lettabot.
|
||||
- **Windows paths** ([letta-ai/letta-code#914](https://github.com/letta-ai/letta-code/issues/914)): Path separator issues on Windows have been fixed in Letta Code, but ensure you're on the latest version.
|
||||
|
||||
For more details, see the [Letta Code memory documentation](https://docs.letta.com/letta-code/memory/) and the [Context Repositories blog post](https://www.letta.com/blog/context-repositories).
|
||||
|
||||
### No-Reply (Opt-Out)
|
||||
|
||||
The agent can choose not to respond to a message by sending exactly:
|
||||
|
||||
@@ -73,6 +73,7 @@ features:
|
||||
enabled: false
|
||||
intervalMin: 30
|
||||
# skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables)
|
||||
# memfs: true # Enable memory filesystem (git-backed context repository). Syncs memory blocks to local files.
|
||||
|
||||
# Attachment handling (defaults to 20MB if omitted)
|
||||
# attachments:
|
||||
|
||||
@@ -448,6 +448,35 @@ describe('normalizeAgents', () => {
|
||||
expect(agents[1].displayName).toBe('👾 DevOps');
|
||||
});
|
||||
|
||||
it('should pass through conversations config in legacy mode', () => {
|
||||
const config: LettaBotConfig = {
|
||||
server: { mode: 'cloud' },
|
||||
agent: { name: 'TestBot' },
|
||||
channels: {},
|
||||
conversations: {
|
||||
mode: 'per-channel',
|
||||
heartbeat: 'dedicated',
|
||||
},
|
||||
};
|
||||
|
||||
const agents = normalizeAgents(config);
|
||||
|
||||
expect(agents[0].conversations?.mode).toBe('per-channel');
|
||||
expect(agents[0].conversations?.heartbeat).toBe('dedicated');
|
||||
});
|
||||
|
||||
it('should pass through conversations as undefined when not set', () => {
|
||||
const config: LettaBotConfig = {
|
||||
server: { mode: 'cloud' },
|
||||
agent: { name: 'TestBot' },
|
||||
channels: {},
|
||||
};
|
||||
|
||||
const agents = normalizeAgents(config);
|
||||
|
||||
expect(agents[0].conversations).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize onboarding-generated agents[] config (no legacy agent/channels)', () => {
|
||||
// This matches the shape that onboarding now writes: agents[] at top level,
|
||||
// with no legacy agent/channels/features fields.
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface AgentConfig {
|
||||
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
|
||||
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
||||
};
|
||||
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
|
||||
maxToolCalls?: number;
|
||||
};
|
||||
/** Polling config */
|
||||
@@ -135,6 +136,7 @@ export interface LettaBotConfig {
|
||||
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
||||
};
|
||||
inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths.
|
||||
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
|
||||
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
|
||||
};
|
||||
|
||||
@@ -549,6 +551,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
|
||||
displayName: config.agent?.displayName,
|
||||
model,
|
||||
channels,
|
||||
conversations: config.conversations,
|
||||
features: config.features,
|
||||
polling: config.polling,
|
||||
integrations: config.integrations,
|
||||
|
||||
@@ -261,6 +261,8 @@ export class LettaBot implements AgentSession {
|
||||
],
|
||||
cwd: this.config.workingDir,
|
||||
tools: [createManageTodoTool(this.getTodoAgentKey())],
|
||||
// Memory filesystem (context repository): true -> --memfs, false -> --no-memfs, undefined -> leave unchanged
|
||||
...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}),
|
||||
// In bypassPermissions mode, canUseTool is only called for interactive
|
||||
// tools (AskUserQuestion, ExitPlanMode). When no callback is provided
|
||||
// (background triggers), the SDK auto-denies interactive tools.
|
||||
@@ -398,6 +400,7 @@ export class LettaBot implements AgentSession {
|
||||
const newAgentId = await createAgent({
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
memory: loadMemoryBlocks(this.config.agentName),
|
||||
...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}),
|
||||
});
|
||||
const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
|
||||
this.store.setAgent(newAgentId, currentBaseUrl);
|
||||
|
||||
@@ -164,4 +164,91 @@ describe('SDK session contract', () => {
|
||||
expect(secondSession.close).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(createSession)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes memfs: true to createSession when config sets memfs true', async () => {
|
||||
const mockSession = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
send: vi.fn(async (_message: unknown) => undefined),
|
||||
stream: vi.fn(() =>
|
||||
(async function* () {
|
||||
yield { type: 'assistant', content: 'ack' };
|
||||
yield { type: 'result', success: true };
|
||||
})()
|
||||
),
|
||||
close: vi.fn(() => undefined),
|
||||
agentId: 'agent-contract-test',
|
||||
conversationId: 'conversation-contract-test',
|
||||
};
|
||||
|
||||
vi.mocked(createSession).mockReturnValue(mockSession as never);
|
||||
|
||||
const bot = new LettaBot({
|
||||
workingDir: join(dataDir, 'working'),
|
||||
allowedTools: [],
|
||||
memfs: true,
|
||||
});
|
||||
|
||||
await bot.sendToAgent('test');
|
||||
|
||||
const opts = vi.mocked(createSession).mock.calls[0][1];
|
||||
expect(opts).toHaveProperty('memfs', true);
|
||||
});
|
||||
|
||||
it('passes memfs: false to createSession when config sets memfs false', async () => {
|
||||
const mockSession = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
send: vi.fn(async (_message: unknown) => undefined),
|
||||
stream: vi.fn(() =>
|
||||
(async function* () {
|
||||
yield { type: 'assistant', content: 'ack' };
|
||||
yield { type: 'result', success: true };
|
||||
})()
|
||||
),
|
||||
close: vi.fn(() => undefined),
|
||||
agentId: 'agent-contract-test',
|
||||
conversationId: 'conversation-contract-test',
|
||||
};
|
||||
|
||||
vi.mocked(createSession).mockReturnValue(mockSession as never);
|
||||
|
||||
const bot = new LettaBot({
|
||||
workingDir: join(dataDir, 'working'),
|
||||
allowedTools: [],
|
||||
memfs: false,
|
||||
});
|
||||
|
||||
await bot.sendToAgent('test');
|
||||
|
||||
const opts = vi.mocked(createSession).mock.calls[0][1];
|
||||
expect(opts).toHaveProperty('memfs', false);
|
||||
});
|
||||
|
||||
it('omits memfs key from createSession options when config memfs is undefined', async () => {
|
||||
const mockSession = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
send: vi.fn(async (_message: unknown) => undefined),
|
||||
stream: vi.fn(() =>
|
||||
(async function* () {
|
||||
yield { type: 'assistant', content: 'ack' };
|
||||
yield { type: 'result', success: true };
|
||||
})()
|
||||
),
|
||||
close: vi.fn(() => undefined),
|
||||
agentId: 'agent-contract-test',
|
||||
conversationId: 'conversation-contract-test',
|
||||
};
|
||||
|
||||
vi.mocked(createSession).mockReturnValue(mockSession as never);
|
||||
|
||||
const bot = new LettaBot({
|
||||
workingDir: join(dataDir, 'working'),
|
||||
allowedTools: [],
|
||||
// memfs intentionally omitted
|
||||
});
|
||||
|
||||
await bot.sendToAgent('test');
|
||||
|
||||
const opts = vi.mocked(createSession).mock.calls[0][1];
|
||||
expect(opts).not.toHaveProperty('memfs');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,9 @@ export interface BotConfig {
|
||||
// Safety
|
||||
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
|
||||
|
||||
// Memory filesystem (context repository)
|
||||
memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged
|
||||
|
||||
// Security
|
||||
allowedUsers?: string[]; // Empty = allow all
|
||||
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@@ -519,6 +519,9 @@ async function main() {
|
||||
for (const agentConfig of agents) {
|
||||
console.log(`\n[Setup] Configuring agent: ${agentConfig.name}`);
|
||||
|
||||
// Resolve memfs: YAML config takes precedence, then env var, then undefined (leave unchanged)
|
||||
const resolvedMemfs = agentConfig.features?.memfs ?? (process.env.LETTABOT_MEMFS === 'true' ? true : process.env.LETTABOT_MEMFS === 'false' ? false : undefined);
|
||||
|
||||
// Create LettaBot for this agent
|
||||
const bot = new LettaBot({
|
||||
workingDir: globalConfig.workingDir,
|
||||
@@ -527,6 +530,7 @@ async function main() {
|
||||
disallowedTools: globalConfig.disallowedTools,
|
||||
displayName: agentConfig.displayName,
|
||||
maxToolCalls: agentConfig.features?.maxToolCalls,
|
||||
memfs: resolvedMemfs,
|
||||
conversationMode: agentConfig.conversations?.mode || 'shared',
|
||||
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
|
||||
skills: {
|
||||
@@ -535,6 +539,12 @@ 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)';
|
||||
console.log(`[Agent:${agentConfig.name}] memfs: ${resolvedMemfs ? 'enabled' : 'disabled'}${source}`);
|
||||
}
|
||||
|
||||
// Apply explicit agent ID from config (before store verification)
|
||||
let initialStatus = bot.getStatus();
|
||||
if (agentConfig.id && !initialStatus.agentId) {
|
||||
|
||||
Reference in New Issue
Block a user