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 |
|
| `model` | string | No | Model for agent creation |
|
||||||
| `conversations` | object | No | Conversation routing config (shared vs per-channel) |
|
| `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. |
|
| `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.) |
|
| `polling` | object | No | Per-agent polling config (Gmail, etc.) |
|
||||||
| `integrations` | object | No | Per-agent integrations (Google, etc.) |
|
| `integrations` | object | No | Per-agent integrations (Google, etc.) |
|
||||||
|
|
||||||
@@ -464,6 +464,39 @@ features:
|
|||||||
|
|
||||||
Enable scheduled tasks. See [Cron Setup](./cron-setup.md).
|
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)
|
### No-Reply (Opt-Out)
|
||||||
|
|
||||||
The agent can choose not to respond to a message by sending exactly:
|
The agent can choose not to respond to a message by sending exactly:
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ features:
|
|||||||
enabled: false
|
enabled: false
|
||||||
intervalMin: 30
|
intervalMin: 30
|
||||||
# skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables)
|
# 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)
|
# Attachment handling (defaults to 20MB if omitted)
|
||||||
# attachments:
|
# attachments:
|
||||||
|
|||||||
@@ -448,6 +448,35 @@ describe('normalizeAgents', () => {
|
|||||||
expect(agents[1].displayName).toBe('👾 DevOps');
|
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)', () => {
|
it('should normalize onboarding-generated agents[] config (no legacy agent/channels)', () => {
|
||||||
// This matches the shape that onboarding now writes: agents[] at top level,
|
// This matches the shape that onboarding now writes: agents[] at top level,
|
||||||
// with no legacy agent/channels/features fields.
|
// 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)
|
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
|
||||||
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
||||||
};
|
};
|
||||||
|
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
|
||||||
maxToolCalls?: number;
|
maxToolCalls?: number;
|
||||||
};
|
};
|
||||||
/** Polling config */
|
/** Polling config */
|
||||||
@@ -135,6 +136,7 @@ export interface LettaBotConfig {
|
|||||||
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
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.
|
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)
|
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,
|
displayName: config.agent?.displayName,
|
||||||
model,
|
model,
|
||||||
channels,
|
channels,
|
||||||
|
conversations: config.conversations,
|
||||||
features: config.features,
|
features: config.features,
|
||||||
polling: config.polling,
|
polling: config.polling,
|
||||||
integrations: config.integrations,
|
integrations: config.integrations,
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ export class LettaBot implements AgentSession {
|
|||||||
],
|
],
|
||||||
cwd: this.config.workingDir,
|
cwd: this.config.workingDir,
|
||||||
tools: [createManageTodoTool(this.getTodoAgentKey())],
|
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
|
// In bypassPermissions mode, canUseTool is only called for interactive
|
||||||
// tools (AskUserQuestion, ExitPlanMode). When no callback is provided
|
// tools (AskUserQuestion, ExitPlanMode). When no callback is provided
|
||||||
// (background triggers), the SDK auto-denies interactive tools.
|
// (background triggers), the SDK auto-denies interactive tools.
|
||||||
@@ -398,6 +400,7 @@ export class LettaBot implements AgentSession {
|
|||||||
const newAgentId = await createAgent({
|
const newAgentId = await createAgent({
|
||||||
systemPrompt: SYSTEM_PROMPT,
|
systemPrompt: SYSTEM_PROMPT,
|
||||||
memory: loadMemoryBlocks(this.config.agentName),
|
memory: loadMemoryBlocks(this.config.agentName),
|
||||||
|
...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}),
|
||||||
});
|
});
|
||||||
const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
|
const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
|
||||||
this.store.setAgent(newAgentId, currentBaseUrl);
|
this.store.setAgent(newAgentId, currentBaseUrl);
|
||||||
|
|||||||
@@ -164,4 +164,91 @@ describe('SDK session contract', () => {
|
|||||||
expect(secondSession.close).toHaveBeenCalledTimes(1);
|
expect(secondSession.close).toHaveBeenCalledTimes(1);
|
||||||
expect(vi.mocked(createSession)).toHaveBeenCalledTimes(2);
|
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
|
// Safety
|
||||||
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
|
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
|
// Security
|
||||||
allowedUsers?: string[]; // Empty = allow all
|
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) {
|
for (const agentConfig of agents) {
|
||||||
console.log(`\n[Setup] Configuring agent: ${agentConfig.name}`);
|
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
|
// Create LettaBot for this agent
|
||||||
const bot = new LettaBot({
|
const bot = new LettaBot({
|
||||||
workingDir: globalConfig.workingDir,
|
workingDir: globalConfig.workingDir,
|
||||||
@@ -527,6 +530,7 @@ async function main() {
|
|||||||
disallowedTools: globalConfig.disallowedTools,
|
disallowedTools: globalConfig.disallowedTools,
|
||||||
displayName: agentConfig.displayName,
|
displayName: agentConfig.displayName,
|
||||||
maxToolCalls: agentConfig.features?.maxToolCalls,
|
maxToolCalls: agentConfig.features?.maxToolCalls,
|
||||||
|
memfs: resolvedMemfs,
|
||||||
conversationMode: agentConfig.conversations?.mode || 'shared',
|
conversationMode: agentConfig.conversations?.mode || 'shared',
|
||||||
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
|
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
|
||||||
skills: {
|
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)
|
// Apply explicit agent ID from config (before store verification)
|
||||||
let initialStatus = bot.getStatus();
|
let initialStatus = bot.getStatus();
|
||||||
if (agentConfig.id && !initialStatus.agentId) {
|
if (agentConfig.id && !initialStatus.agentId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user