跳到主要内容

数据截至 (上游 commit ee230f304a1a)

记忆系统:memory blocks、git 化的 MemFS 与做梦

30 秒导读: 别的编码 agent 把"记忆"做成一张数据库表或一个 JSON 文件;Letta Code 把它做成一个真的 git 仓库——上下文窗口里那几段"我是谁、用户是谁"的文字,就是这个仓库里 system/*.md 的投影;agent 每改一次记忆都必须给出理由,那句理由直接当 commit message;后台还有个"做梦"的 agent 在独立 worktree 里替它整理记忆,整理完 merge 回主分支。

本章分三层递进讲:

  1. memory blocks —— 记忆在上下文里长什么样,系统提示怎么拼出来。
  2. MemFS —— 记忆在磁盘上长什么样,agent 用什么工具改它。
  3. git 层 —— 为什么每次写都要提交,权限、脱敏、隔离怎么做。

最后收尾讲维护回路:reflection(反思)与 letta dream

上下文压缩(把变长的消息历史压成摘要)不在本章,见 01-stateful-turn.md


1. 先建立直觉:记忆的三种形态

先记住一句话:同一份记忆,同时存在于三个地方,形态各不相同。

形态在哪谁能看见怎么改
memory block编译好的系统提示里(<memory> 段)模型每回合都看见不能直接改,改的是下面那层
文件~/.letta/agents/<agentId>/memory/**.md用 bash / 文件工具能列能读memory 工具或直接编辑
commit同一目录的 .gitgit log / git diff每次写入自动产生一次提交

三者的关系是投影 + 提交:文件是 block 的载体,commit 是文件变更的唯一合法出口。

一个关键的时序事实,教学上必须先说,否则后面全乱:

改记忆不会改变"这一回合"的行为。 这一回合用的系统提示是回合开始时编译好的那份;一次记忆写入要等到下一次重编译(新会话、显式 recompile、或提交版本变化)才生效。源码把这条写进了系统提示原文(src/agent/prompts/letta.md:52)。

所以 Letta Code 的记忆语义是写给未来的自己,不是"改完立刻管用"。


2. 顶层全景

怎么读这张图:左边是,右边是,中间那个方框(MemFS)是唯一的真相源;箭头向下表示"必须落到 git 才算数"。

写 读
┌────────────────┐ ┌──────────────────────┐
│ memory 工具 │ │ 编译系统提示 │
│ / 直接改文件 │ │ (每回合开始时) │
└───────┬────────┘ └──────────▲───────────┘
│ │ 投影
▼ │
┌────────────────────────────────────────────────────────┐
│ ② MemFS ~/.letta/agents/<agentId>/memory/ │
│ system/*.md → 进上下文(= memory block) │
│ skills/** → 按需加载 │
│ 其它 *.md → 只有描述进上下文,内容按需读 │
└───────┬────────────────────────────────────────────────┘
│ 每次写入必须 commit(pre-commit 钩子把关)

┌────────────────┐ 回合结束 push ┌──────────────────────────┐
│ ③ .git 本地库 │ ───────────────────> │ 云端 /v1/git/<id>/state │
└───────┬────────┘ └──────────────────────────┘
│ git worktree(临时分支)

┌─────────────────────────────────────┐
│ ④ reflection 子 agent(后台"做梦") │ ── merge 回 main
└─────────────────────────────────────┘

部件职责一览:

部件干什么主文件
memory blocks定义上下文里的 persona / human 等段落src/agent/memory.ts
提示装配按 MemFS 模式挑模板、拼系统提示src/agent/prompt-assets.tssrc/agent/system-prompt-resolution.ts
MemFS 路径层算出"当前作用域的记忆目录在哪"src/agent/memory-filesystem.ts
写入面六个记忆命令 + patch 应用src/tools/impl/memory.tsmemory-apply-patch.ts
git 层提交、鉴权、推送、脱敏、重试src/agent/memory-git.ts
隔离层子 agent 的分支隔离与内核级沙箱src/agent/memory-worktree.tssrc/memory-confinement.ts
维护回路触发反思、等待完成、letta dreamsrc/cli/helpers/reflection-*.tssrc/cli/subcommands/dream.ts

3. 第一层:memory blocks

3.1 它要解决的小问题

模型每次调用都是无状态的。要让它"记得你",最朴素的办法就是:把该记住的东西塞进系统提示。memory block 就是系统提示里一段有名字、有描述、可被 agent 自己改写的文字

3.2 默认只有两块

出乎意料地少 —— 新 agent 出生时只带两个 block:

export const MEMORY_BLOCK_LABELS = ["persona", "human"] as const;

src/agent/memory.ts:16MEMORY_BLOCK_LABELS。注释里说明了原因:曾经还有 skills/loaded_skills 两块,后来技能改走 system reminder 注入,这两块就被删了(LET-7353)。

  • persona = 我是谁。
  • human = 我在跟谁工作。

两块的初值来自同名 .mdx 模板。src/agent/prompts/persona.mdx 的正文只有一行:"I'm a coding assistant, ready to be shaped by how we work together."(我是个编码助手,准备好被我们的协作方式塑造)。human.mdx 更长一点,写的是"我还不认识这个人",然后列出一串它想弄清楚的问题。

这是个刻意的设计选择:初值是空白 + 好奇心,而不是一份人设

3.3 模板怎么变成 block

.mdx 文件头部是 YAML frontmatter,尾部是正文:

---
label: persona
description: Who I am, what I value, and how I approach working with people. ...
---

I'm a coding assistant, ready to be shaped by how we work together.

解析靠一个正则(src/agent/memory.ts:26parseMdxFrontmatter),只支持最简单的 key: value 行,不是完整 YAML。

装配在 src/agent/memory.ts:101getDefaultMemoryBlocks()(内部调 loadMemoryBlocksFromMdx,结果缓存在模块级变量里):frontmatter 的 label/description 变成 block 的字段,正文变成 value

模板本身不是从磁盘读的,而是编译期 import 进来的字符串常量,集中登记在 src/agent/prompt-assets.ts:38MEMORY_PROMPTS

3.4 只读块:memory_filesystem

有一类 block 是给 agent 看的、但不许 agent 改的:

export const READ_ONLY_BLOCK_LABELS = ["memory_filesystem"] as const;

整个 src/agent/memory-constants.ts 就这一行。命中这个名单的 block 会被打上 read_only: true(src/agent/memory.ts:82)。

memory_filesystem 装的是记忆文件系统的目录树——模板 src/agent/prompts/memory_filesystem.mdx 正文只有一个 /memory/,真正的树是运行时渲染填进去的。让 agent 改这块没有意义(它是派生数据),所以锁死。

诚实说明:仓库里渲染这棵树的函数 renderMemoryFilesystemTree(src/agent/memory-filesystem.ts:289)在本 commit 的 src/只有测试在调;反思路径用的是另一套树构造(src/cli/helpers/reflection-transcript.ts:517buildParentMemorySnapshot)。给主 agent 的那棵树在这份代码里看不出是谁渲染的,推测由服务端编译系统提示时填充 (inferred)。

不过这个函数本身值得看,因为它演示了上下文预算怎么守:

  • 每层目录最多显示 memfsTreeMaxChildrenPerDir 个孩子,超出的折成 … (N more entries);
  • 整棵树受 maxLines / maxChars 双重上限;
  • 超了不是硬截断,而是往回弹:一行行 pop,直到能塞下一句 [Tree truncated: showing X of Y entries. Z omitted.] 为止(src/agent/memory-filesystem.ts:412-428)。

默认值在 src/utils/directory-limits.ts:17-19DIRECTORY_LIMIT_DEFAULTS:500 行 / 20000 字符 / 每目录 50 个孩子,三个都能用 LETTA_MEMFS_TREE_MAX_* 环境变量覆盖。

3.5 系统提示是怎么拼出来的

这里有个容易被忽略的分叉:同一个 preset,按 MemFS 模式有三份不同的模板

MemoryPromptMode用哪份模板什么时候用
standardprompts/letta_no_memfs.md没开 MemFS
memfsprompts/letta.md云端 MemFS
local-memfsprompts/letta_local_memfs.md本地 backend

类型定义在 src/agent/prompt-assets.ts:110(MemoryPromptMode),preset 表在同文件 :70(SYSTEM_PROMPTS,每项带 content / memfsContent / localMemfsContent 三个字段)。

选取逻辑是 src/agent/prompt-assets.ts:131buildSystemPrompt(presetId, memoryMode),带逐级回落:local-memfs 找不到就退 memfs,再找不到就退 content。preset ID 不认识直接抛错,注释写明了原因——防止改名后的旧 preset 悄悄把提示写坏。

src/agent/system-prompt-resolution.ts:53resolveAndBuildSystemPrompt 再包一层:已知 preset 走确定性重建,未知 ID 当成子 agent 名字去查(resolveSystemPrompt,同文件 :77)。这样"反思子 agent 的系统提示"和"主 agent 的 preset"共用一条解析路径。

拆成两个文件是有讲究的:prompt-assets.ts 保持纯数据(可打进浏览器安全的 agent-presets 包),要碰文件系统/后端的子 agent 查询被隔离进 system-prompt-resolution.ts——文件头注释直接说了这一点。

3.6 人格(personality)= 覆写这两块的初值

/personality 这类预设不是新机制,就是把 persona/human 的初值换掉

  • 建 agent 时:src/agent/personality.ts:104buildCreateAgentOptionsForPersonalitygetDefaultMemoryBlocks() 的结果,交给 buildPersonalityMemoryBlocks(src/agent/personality-presets.ts:426)按 label 逐个替换 persona/humanvalue
  • 给已有 agent 换人格时:src/agent/personality.ts:313applyPersonalityToMemory 改的是文件,不是 API 的 block —— 目标路径就两条,还带旧布局兼容:
常量路径说明
PRIMARY_PERSONA_RELATIVE_PATHsystem/persona.md当前布局
LEGACY_PERSONA_RELATIVE_PATHmemory/system/persona.md旧布局
PRIMARY_HUMAN_RELATIVE_PATHsystem/human.md当前布局
LEGACY_HUMAN_RELATIVE_PATHmemory/system/human.md旧布局

定义在 src/agent/personality.ts:42-45,选路由 getMemoryFileRelativePathForRepo(同文件 :65)按"文件存在性 → 有没有顶层 memory/ 目录"三级判断。

换人格前会先 git status --porcelain,仓库脏就直接报错拒绝(src/agent/personality.ts:328-336)。这是第 3 层那条铁律的第一次出现:记忆仓库脏,任何自动化写入都不许动手。

还有个细节值得学:改文件时用 replaceBodyPreservingFrontmatter(src/agent/personality.ts:204)只换正文、保留 frontmatter,而且如果没有合法 frontmatter 就抛错而不是硬写——避免把一个损坏的记忆文件写得更坏。


4. 第二层:MemFS(记忆文件系统)

4.1 目录长什么样

根常量只有一个词:

export const MEMORY_FS_ROOT = ".letta";

src/agent/memory-filesystem.ts:26。拼起来的完整路径由 getMemoryFilesystemRoot(agentId)(同文件 :43)给出:

~/.letta/
└── agents/
└── <agentId>/
├── memory/ ← MemFS 根,也是 git 仓库根
│ ├── .git/
│ ├── system/ ← 这里的 .md = 在上下文里的 block
│ │ ├── persona.md
│ │ └── human.md
│ ├── skills/<name>/SKILL.md
│ └── reference/… ← 只有描述进上下文
└── memory-worktrees/ ← 反思子 agent 的临时 worktree
└── reflection-<id>/

system/ 之外的文件不是"次要记忆",而是另一个层级:主 agent 只看得到它们的路径和 description,内容要显式去读。这是典型的渐进式披露。

4.2 "当前记忆目录"怎么算出来

这是个比看上去麻烦的问题:同一个进程里可能有主 agent、子 agent、worktree 三种作用域。src/agent/memory-filesystem.ts:97resolveScopedMemoryDir 定了一条运行时优先的四级优先级:

① 调用方显式传的 agentId
↓ 没有
② 进程内的 agent 上下文 getCurrentAgentId()
↓ 取不到(抛异常也算)
③ 环境变量 LETTA_MEMORY_DIR / MEMORY_DIR ← 直接是路径
↓ 没有
④ 环境变量 LETTA_AGENT_ID / AGENT_ID ← 再拼路径
↓ 都没有
返回 null(调用方报错)

顺序是有意的("Precedence is intentionally runtime-first",源码注释)。第 ③ 级那个 MEMORY_DIR 就是子 agent 被喂进来的东西——后面沙箱那节会用到。

另外还有个横切分叉:本地 backend 模式下路径要换到 backend 自己的存储目录,由 getScopedMemoryFilesystemRoot(:63)统一处理,所以业务代码只调它、不调 getMemoryFilesystemRoot

4.3 开关:MemFS 怎么被启用

src/agent/memory-filesystem.ts:486applyMemfsFlags 是唯一的启用入口(交互式、headless、/memfs enable 三条路共用)。函数注释明确写了一句反直觉的话:

MemFS cannot be disabled —— 在支持 MemFS 的 backend 上,agent 出生即启用,这个函数只负责"启用或同步"。

启用做五件事,顺序不能乱:

① 校验 MemFS 端点可用(必须是 api.letta.com 或本地测试开关)
② 把系统提示切到 memfs 模板 + 强制 recompile
③ 本地记下 memfs 已启用
④ detachMemoryTools() —— 卸掉老的、走 API 的记忆工具
⑤ 打 GIT_MEMORY_ENABLED_TAG 标签 + clone / pull 仓库

第 ④ 步是理解整个设计的钥匙:开了 MemFS 之后,agent 不再通过 API 改 block,而是改文件。两套写入面不共存。

判断"这个 agent 到底开没开",三个函数分工不同:

函数位置判断依据
isMemfsEnabledOnServermemory-filesystem.ts:208云端看 agent tag;本地 backend 看能力+进程开关
enableMemfsIfCloudmemory-filesystem.ts:653新建 agent 后best-effort 启用,失败只 warn
isActiveMemfsEnabledmemory-runtime.ts:13UI 层的快速判断

enableMemfsIfCloud 失败不抛异常(:667-671console.warn),因为"记忆没同步上"不该让整个 agent 起不来。

还有一个防御性设计值得单独点出:prepareRawCreateAgentBodyForMemfs(:177)在创建请求发出前就把 GIT_MEMORY_ENABLED_TAG 盖进 body。注释解释了为什么必须原子化:协议直通路径(listener 的 agent_create)会把客户端 body 原样转发,如果不在这里盖标签,agent 出生时就没标签,而所有下游检查都基于标签——结果是"在每台机器上、永远"被当成非 MemFS agent。

4.4 写入面:memory 工具的六个命令

src/tools/impl/memory.ts:98memory(args) 是 agent 改记忆的快捷入口。命令枚举在同文件 :21:

命令必填参数(除 reason)干什么
createfile_path, description新建记忆文件;已存在则报错
str_replacefile_path, old_string, new_string替换正文里第一处匹配
insertfile_path, insert_line, insert_text按行号插入(1-indexed,越界会夹到边界)
deletefile_path删文件;若是目录则递归删
renameold_path, new_path纯改路径;目标已存在则报错
update_descriptionfile_path, description只改 frontmatter 的描述

每个命令都必须带 reason,而且它不是日志——它就是 commit message。 JSON schema 写得很直白:"Required commit message for this memory change. Used as the git commit message."(src/tools/schemas/Memory.json,required: ["command","reason"])。实现里先 validateRequiredParams(args, ["command","reason"], "memory"),再单独 trim 一次防空串(src/tools/impl/memory.ts:99-104)。

这个设计的效果:agent 的记忆历史天然是一份可读的变更日志,git log 就能看出它为什么这么想。

一次调用的完整流程:

resolveMemoryDir() ← 算作用域(§4.2)
→ ensureMemoryRepo() ← 目录在吗?.git 在吗?
→ getAgentIdentity() ← 拿 agentId + agentName 做提交作者
→ assertMemoryRepoCleanForWrite() ← 仓库必须干净,否则拒绝
→ applyMemoryCommand() ← 真正改文件,返回受影响路径
→ 路径为空 → 报错("made no changes")
→ commitMemoryWrite() ← 提交
→ 没提交成 → 报错("no effective changes")
→ emitMemoryUpdated() ← 推 WS 事件给 web UI

对应 src/tools/impl/memory.ts:106-150。两处"改了个寂寞就报错"很关键:它逼 agent 面对"我以为改了其实没改"这种沉默失败,而不是拿到一句成功回执继续往下走。

提交作者是 <agentName> <agentId@letta.com>(:126-130)。也就是说,记忆仓库里每个 commit 的作者是 agent 自己,不是用户。

4.5 路径不能越狱

记忆工具允许 agent 传路径,所以必须防越狱。normalizeMemoryLabel(src/tools/impl/memory.ts:346)+ normalizeRelativeMemoryLabel(:381)+ resolveMemoryPath(:437)组成三道闸:

输入结果
~/x.md$HOME/x.md拒绝(明确提示"要记忆相对路径,不是家目录路径")
绝对路径,且在 memoryDir转成相对 label 放行
绝对路径,在外面拒绝
. / ..拒绝("invalid path traversal segment")
\0拒绝
前缀 memory/剥掉(兼容旧布局)
后缀 .md剥掉(内部按 label 处理,写盘时再加回)

最后 resolveMemoryPath 再算一次 relative() 兜底:结果以 .. 开头或仍是绝对路径,直接抛 "resolved path escapes memory directory"。归一化之后再校验一次,而不是只信归一化——这是路径安全的标准写法。

4.6 frontmatter 是硬要求

每个记忆 .md 必须以 YAML frontmatter 开头,且 description 非空。工具侧的 parseMemoryFile(src/tools/impl/memory.ts:472)在缺 frontmatter 或缺 description 时直接抛错;写回时 renderMemoryFile(:509)重新序列化,并且只保留 descriptionread_only 两个键,同时把描述里的换行压成空格(sanitizeFrontmatterValue,:535)。

只读保护在 loadEditableMemoryFile(:454):读到 read_only: true 就拒绝修改。注意这只是第一道,真正的强制在 git 钩子里(见 §5.3)。

4.7 大改动:memory_apply_patch

六个命令适合小手术。大重构走 src/tools/impl/memory-apply-patch.ts:104memory_apply_patch,吃一段 patch 文本,支持 add / update / delete 三种操作(类型定义在同文件 :14-32ParsedPatchOp),update 还能顺带改目标路径(等于"改内容 + 重命名"一步到位)。

它和 memory 工具共享同一套后置流程:同样的 resolveMemoryDir / ensureMemoryRepo / assertMemoryRepoCleanForWrite / commitMemoryWrite,同样强制 reason。差别只在"怎么算出新内容"。

一个用心的细节:hunk 上下文匹配失败时,formatHunkContextNotFoundError(:770)会把失败的 hunk 和当前文件内容一起回给模型,并且用 markdownFenceFor(:804)动态计算围栏长度——因为记忆文件本身可能含三反引号,固定用 ``` 会把输出撑破。预览还带字符上限(MAX_FAILED_HUNK_PREVIEW_CHARS = 2000MAX_CURRENT_FILE_PREVIEW_CHARS = 4000,:61-62)。


5. 第三层:git 化

5.1 为什么是 git,不是数据库

三个理由,都能在代码里找到对应:

  1. 可审计 —— 每条记忆都有作者、时间、理由。letta.md:102 直接告诉 agent:"你可以用 git log 看你的记忆是怎么演化的"。
  2. 可携带 —— 记忆跟着 agent 走,不跟着机器走。远端是 ${base}/v1/git/<agentId>/state.git(src/agent/memory-git.ts:235getGitRemoteUrl),换台机器 clone 一下就还是那个 agent。
  3. 可并发 —— 后台反思 agent 用 worktree 开分支改记忆,冲突了就 abort,主线不受影响(§6)。

5.2 一次提交的真实路径

src/agent/memory-git.ts:1348commitMemoryWritesyncMode 分两条路:

syncMode = "local" syncMode = "remote"
│ │
▼ ▼
prepareLocalOnlyMemoryRepoForGitOps getAuthToken()
│ │
│ prepareMemoryRepoForGitOps
│ (校验/修复 origin、装钩子、配凭据)
└───────────────┬───────────────────────┘

commitMemoryPaths()
git add -A -- <paths>
git status --porcelain -- <paths> ← 没变化就返回 committed:false
git -c user.name=… -c user.email=… commit -m "<reason>"
git rev-parse HEAD ← 回传 sha

commitMemoryPaths(:1221)有个细节:commit 失败会回滚暂存区(unstageMemoryPaths,:1256)再把异常抛出去,不留半截状态。

提交前的守门员是 assertMemoryRepoCleanForWrite(:1271)。它不只说"仓库脏了",还会诊断一类具体病因:遍历脏的 .md 文件,检查是不是 UTF-16 BOM 或含 NUL 字节(describeMarkdownEncodingIssue,:1326),然后把诊断拼进错误信息。这是从真实事故里长出来的代码——Windows 上某些编辑器保存成 UTF-16,git 认为文件变了,agent 却完全不知道为什么写不进去。

作者身份被强制成 -c user.name=… -c user.email=…单次调用级配置,不写进仓库 config。这样同一个仓库被不同身份提交(agent 本人、反思子 agent、人格切换)不会互相污染。

5.3 pre-commit 钩子:最后一道防线

工具层的 frontmatter 校验只管住"走工具的写入";agent 完全可以用 bash 直接改文件再 git commit。所以真正的强制在钩子里。

src/agent/memory-git-hooks.ts:27PRE_COMMIT_HOOK_SCRIPT 是一段内嵌的 bash,由 installPreCommitHook(:169)在 clone/pull/init 时写进 .git/hooks/pre-commit。它检查什么:

检查项规则
技能路径skills/<name>.md 这种扁平文件一律拒绝,必须是 skills/<name>/SKILL.md
frontmatter 存在首行必须是 ---,且必须有闭合的 ---
必填字段description 必须存在且非空
已知键只允许 description / read_only / limit(limit 是历史遗留)
只读保护HEAD 版本里 read_only: true 的文件,任何修改都拒绝
只读键防篡改read_only 的值不许改、不许新增、不许删除(逐项和 HEAD 比对)

检查范围是 system/reference/ 下的 .md(带可选 memory/ 前缀),SKILL.md 走另一套格式所以跳过。

值得注意的是它把"agent 不能改的东西"下沉到了 git 层:哪怕 agent 绕过所有 TypeScript 工具、直接用 shell 操作,也过不了这一关。

5.4 post-commit 钩子:可选的第二远端

src/agent/memory-git-hooks.ts:194POST_COMMIT_HOOK_SCRIPT 支持把记忆额外镜像到用户自己的 git 仓库(比如私有 GitHub repo)。

设计上有四个克制之处:

  1. URL 存在仓库本地 git config 的 letta.memoryRepository.url 键里(:928MEMORY_REPOSITORY_CONFIG_KEY)。每个 agent 的记忆仓库有自己的 .git/config,所以配置天然按 agent 隔离——注释里写明这是有意的。
  2. 键没设时钩子直接 exit 0,零成本。
  3. push 在后台子 shell 里跑并 disown,commit 不会被网络卡住;失败写进 .git/memory-repository-push.log,不打断用户。
  4. 只有 main 分支才推(memory-git-hooks.ts:203)。注释解释得很清楚:反思和其它 harness worktree 在临时分支上提交,不该把半成品推到用户仓库。

配套的 CLI 面:setMemoryRepositoryUrl(src/agent/memory-git.ts:947,设 URL 时顺手重装一次钩子,防止手工改坏导致静默丢 push)、pushToMemoryRepository(:982,一次性补推)、readMemoryRepositoryPushLog(:1054,给 /memory-repository status 看日志尾巴)。

pushToMemoryRepository 的错误处理是教科书式的——三种失败各给一句人话:没配 URL、仓库还没有 commit、HEAD 处于 detached 状态。

5.5 鉴权与脱敏

MemFS 的 git 认证走 HTTP header,不走凭据文件。buildGitAuthArgs(src/agent/memory-git.ts:507)拼出来的是:

-c credential.helper= ← 清空,不让系统 helper 插手
-c core.askPass= ← 清空
-c http.extraHeader=Authorization: Basic base64("letta:<token>")

问题来了: Node 的 child_process 报错时会把整条命令行塞进 error.message。一次 clone 失败就能把可复用的 API key 打进日志。

对策是 redactGitAuthInText(:149),四条正则:

匹配替换成
http.extraHeader=Authorization: Basic/Bearer <x>…<redacted>
Authorization: Basic/Bearer <x>…<redacted>
password=<x>password=<redacted>
sk-let-<x>sk-let-<redacted>

配套的 redactGitAuthError(:163)不只擦 message,还把 cmd / command / stack / stdout / stderr 五个字段挨个擦一遍。runGit(:573)在 catch 里统一 throw redactGitAuthError(error),所以没有任何一条 git 错误能绕过脱敏

另外两个环境层面的防护:

  • buildNonInteractiveGitEnv(:554)设 GIT_TERMINAL_PROMPT=0 / GCM_INTERACTIVE=never / GIT_ASKPASS="" / SSH_ASKPASS=""。后台的记忆同步绝不能弹出一个等输入的提示框把进程挂死。
  • GIT_DISABLE_COMMIT_SIGNING_ARGS(整个 src/agent/memory-git-signing.ts 就这一个常量)在每次 git 调用最前面加 -c commit.gpgsign=false。文件注释说明了动机:全局开了 commit.gpgsign=true 的用户,没有 <agentId>@letta.com 这个身份的签名密钥,任何提交都会以 "gpg: signing failed: No secret key" 失败,连记忆初始化都做不了。放在最高优先级的 -c 上,是为了连 git 内部产生的提交(rebase、merge)也一起覆盖。

5.6 网络容错

isRetryableGitTransientError(src/agent/memory-git.ts:626)专门识别 Cloudflare 的临时 52x:除了直接匹配 HTTP 错误码,还处理 git 常见的两行一起出现的形态——"error: RPC failed; HTTP 520" 加 "fatal: the remote end hung up unexpectedly"。runGitWithRetry(:655)据此重试。

超时分档:普通 git 操作 60 秒,clone 单独给 180 秒(:570-571GIT_DEFAULT_TIMEOUT_MS / GIT_CLONE_TIMEOUT_MS),注释理由是冷启动的 CI 机器上 clone 会很慢。

5.7 回合结束的自动同步

syncPendingMemoryCommitsAfterTurn(src/agent/memory-git.ts:1957)在每个回合结束后跑,状态机是这样的:

没有 .git ────────────────────────────> skipped
有冲突标记 ──────────────────────────> conflict
工作区脏 ────────────────────────────> dirty("N 个未提交变更")
backend 不支持远端 ──────────────────> skipped
ahead == 0 ─────────────────────────> clean
push 成功 ──────────────────────────> pushed
push 失败:
├ 非 fast-forward → pull --rebase → 再 push
└ 其它错误 ──────────────────────> push_failed

注意它永远不替 agent 提交:工作区脏就报 dirty 让 agent 自己处理。系统提示里也是这么教的("The system reminds you when memory has uncommitted changes. Commit when convenient." —— letta.md:80)。这条边界很重要:自动提交等于替 agent 决定"这个半成品状态值得记住",而那是 agent 的判断,不是 harness 的。


6. 隔离:worktree 与内核沙箱

6.1 问题

后台反思 agent 要改主 agent 的记忆。如果它直接在主目录上动手,会撞上两件事:主 agent 同时也可能在写;反思写到一半崩了,主记忆就残了。

6.2 解法一:git worktree

src/agent/memory-worktree.ts:119createReflectionMemoryWorktree 干三件事:

父记忆目录: ~/.letta/agents/<id>/memory/

├─ 记下 baseHead = 当前 HEAD

└─ git worktree add \
~/.letta/agents/<id>/memory-worktrees/reflection-<ts>/ \
-b letta/reflection/<ts> <baseHead>

分支名 letta/reflection/<id>,目录是父目录的兄弟(memory-worktrees/),不是子目录——这样它不会被父仓库的 git status 看见。

反思 agent 的可写范围由 buildReflectionMemoryScope(:184)界定:

角色primaryRoot可写只读
反思子 agentworktree 目录worktree 目录 + git common dir父目录的上一级
集成阶段(explicit 模式)worktree 目录worktree + 父目录 + git common dir

buildReflectionIntegrationMemoryScope(:194)是第二行——只有到了"把结果合回去"这一步,父目录才变成可写。权限按阶段收放,不是一开始就全给。

6.3 收尾:六种结局

finalizeReflectionMemoryWorktree(:569,实现在 :306)是整个反思回路里最值得读的函数。它把所有可能的结局显式枚举:

status触发条件事务语义
no_changes反思没产生 commit清理 worktree,transcript 算已消费
dirty_uncommittedworktree 里有没提交的改动强制清理,transcript 可重试
failed子 agent 没成功完成强制清理,可重试
parent_dirty有成果,但父仓库脏了强制清理,可重试
merge_conflictmerge 冲突merge --abort + 强制清理,可重试
merged正常合并清理,transcript 消费 + 触发重编译

两个配套判定函数把"这个结局意味着什么"变成可复用的谓词:reflectionIntegrationConsumesTranscript(:228,只有 merged/no_changes 算消费)和 reflectionIntegrationShouldRecompile(:234,只有 merged 触发重编译)。

设计哲学一句话:除了成功和"确实没东西可写",其它一律回滚 + 允许重试。 宁可反思白跑一次,不要把主记忆搞脏。

还有个边界情况处理得很仔细:worktree 目录已经不在了、调用方又说"我知道它没改动"(knownNoChanges),这时会先去查分支 HEAD 有没有从 baseHead 前进过——前进过就抛错拒绝当无事发生(:307-323)。这防的是"目录被误删导致成果被静默丢弃"。

6.4 解法二:内核级文件系统沙箱

worktree 挡的是 git 层的互相踩踏;还有一层挡的是进程层

src/memory-confinement.ts:22createMemoryConfinementLauncher 把一个进程包进沙箱,实现在 src/permissions/memory-confinement-launcher.ts:59。策略由 buildMemorySubagentSandboxPolicy(src/permissions/sandbox-policy.ts:255)构造,分三步搭:

① 基础可写:~/.letta 整个(设置、日志、会话、transcript 都在下面)

② 拒绝:两个 agents 树(~/.letta/agents 和 lc-local-backend/memfs)
↓ ← 这一步把 ① 在这些子树里的授权覆盖掉
③ 重新挖开:自己的记忆目录(memoryRoots)

结果就是:一个记忆子 agent 能写自己的记忆和 harness 元数据,但读不到也写不到别的 agent 的记忆,更碰不到代码仓库、家目录、临时目录。

注释里解释了为什么第 ① 步要整个 ~/.letta 而不是逐个文件授权:新的写入者(设置、日志…)随时会出现,逐个授权等于埋定时炸弹。

可写根的推导也考虑到了 worktree:resolveWritableMemoryRoots(memory-confinement-launcher.ts:40)发现 MEMORY_DIR 的最后一段是 memory 时,自动把兄弟目录 memory-worktrees 也加进可写集合。

两个 fail-closed 的选择:

  • 没有 MEMORY_DIR / LETTA_MEMORY_DIR → 抛错,不给一个"无限制"的默认值。
  • 宿主机没有可用的沙箱后端 → 抛错,而不是降级成弱策略(:73-77)。函数文档明说了这一点。

挂接点在 src/agent/subagents/sandbox.ts:83wrapSubagentLauncher:只有声明了 launchProfile: "memory-subagent" 的子 agent 才被包(reflection、memory、init、history-analyzer 四个)。这一层默认开启,LETTA_FS_SANDBOX=0 才关——和跨 agent 的 shell 沙箱(默认关、需要显式开)相反。理由写在注释里:记忆子 agent 非交互运行,没有"弹出来问用户批不批"这个兜底,所以只能默认收紧。

关于权限体系的全貌见 03-permissions.md


7. 维护回路:反思与做梦

7.1 直觉

前面三层解决的是"记忆怎么存、怎么改、怎么保护"。还剩一个问题:谁负责整理?

主 agent 在回合里忙着干活,顺手写下的记忆会重复、会过时、会散落。Letta Code 的答案是让另一个 agent 在回合之间干这件事,系统提示里用的比喻是人睡觉时的记忆巩固(letta.md:144)。CLI 里这个功能叫 /sleeptime,子命令叫 letta dream,内部实现叫 reflection。

7.2 什么时候触发

触发器只有三种(src/reflection-settings.ts:1ReflectionTrigger):

trigger含义默认阈值
off不自动反思
step-count距上次成功反思的步数达标就跑25 步(src/cli/helpers/memory-reminder.ts:15DEFAULT_STEP_COUNT)
compaction-event上下文压缩发生时跑

判定在 src/cli/helpers/post-turn-reflection.ts:22maybeLaunchPostTurnReflection,回合末调用。前置条件两个:有 agentId、MemFS 已启用(:33-35)——没开 MemFS 就没有可改的文件,反思无从谈起。

设置的解析顺序在 getReflectionSettings(src/cli/helpers/memory-reminder.ts:192):全局 → 项目本地 → 按 agent 持久化,后者覆盖前者,还兼容一个旧的 memoryReminderInterval 字段。

还有个合并策略 ReflectionMergeMode(src/reflection-settings.ts:3):

  • auto —— harness 直接 git merge 反思分支;
  • explicit —— 先跑一次"集成对话"让主 agent 自己决定怎么合,harness 只负责验证"确实已经合进来了"(finalizeReflectionMemoryWorktreeLaunch 里的 requireAlreadyMerged,src/cli/helpers/reflection-launcher.ts:485)。

7.3 一次反思的完整生命周期

maybeLaunchPostTurnReflection ← 回合末,判定触发器


launchReflectionSubagent (reflection-launcher.ts:632)

├─ tryReserveReflectionLaunch ← 同一 agent 同时只许一个,抢不到就排队
├─ 父记忆脏? → 直接放弃(reason: parent_dirty)
├─ 有新 transcript 吗? → 没有就放弃(reason: no_payload)

├─ prepareReflectionMemoryWorktreeLaunch (:455)
│ ├─ 建 worktree + 临时分支
│ └─ 打包父记忆快照进提示(buildParentMemorySnapshot)

├─ spawnBackgroundSubagentTask(subagentType: "reflection")
│ memoryScope = buildReflectionMemoryScope(worktree)

└─ onComplete:
finalizeReflectionMemoryWorktreeLaunch (:486)
├─ explicit 模式 → 跑集成对话
├─ finalizeReflectionMemoryWorktree → 六种结局之一
└─ merged → 触发重编译 + 记 transcript 已消费

排他锁是 tryReserveReflectionLaunch / releaseReflectionLaunch(:266 / :277);抢不到时不是丢弃,而是排进队列等下一次机会。

喂给反思 agent 的提示里有一份父记忆快照:buildParentMemorySnapshot(src/cli/helpers/reflection-transcript.ts:517)拼出 <parent_memory> 块,里面是 <memory_filesystem> 目录树 + 每个 system/ 文件的完整内容。超预算时不是硬砍,而是逐个降级:先截断单个文件并留一句"完整文件在 $MEMORY_DIR/<path>,需要就自己去读"(buildMemoryPreviewNotice,:508),实在装不下再整段缩水(shrinkParentMemorySection,src/agent/subagents/manager.ts:117,优先保住目录树)。

7.4 反思 agent 被教了什么

提示在 src/agent/subagents/builtin/reflection.md,frontmatter 声明只给两个工具:tools: Bash, Edit,launchProfile: memory-subagent(即 §6.4 的沙箱)。

它的工作流分五个阶段,前两个最值得学:

  • Phase 1 调查 —— 先看清现有记忆结构再动手。提示里那句话很直白:"你没法把新学到的东西整合进你不了解的结构里"。
  • Phase 2 提取 —— 候选学习点按优先级排序(错误与纠正 > 偏好与模式 > 新事实 > 矛盾 > 可复用流程),然后过四道筛子:
筛子问什么
持久还是一次性?具体行号、错误原文、临时路径 → 丢弃
已经记过了?记过就跳过
能泛化吗?"用户偏好短章节 + 悬念结尾"留下;"用户周二改了第三章第二段"丢弃
时间指代?"昨天""上周"必须换成绝对日期再写

最后还有一条兜底:全部没通过筛子,就什么都不提交

技能生成有一条明确的保守规则:优先级从"改现有技能"到"新建技能"递减,拿不准就选 none

另一个内置子 agent src/agent/subagents/builtin/memory.md 分工不同——它是碎片整理:把多主题文件拆开、用 / 建层级(如 project/tooling/bun.md)、消除冗余。它明确规定"没有硬性文件数目标",优化目标是检索质量而不是配额。

7.5 letta dream:手动触发

src/cli/subcommands/dream.ts:92runDreamSubcommand 是同一条回路的命令行入口,但多了几个能力:

选项作用
--memory <agent-id>指定给谁做梦(默认 $LETTA_AGENT_ID → 上次用的 agent)
--from <conv-id|type:path>反思对象:自己的会话,或外部来源(如 openhands:<dir>transcript:./rows.jsonl)
--to <path>让 agent 顺手维护一份外部文档(比如 ./AGENTS.md)
--timeout <s>默认 1500 秒(:71)
--json机器可读输出

两个设计点:

外部来源会被 stageFromSource 转换并暂存进一个合成会话,反思跑在这个合成会话上,但完成后的重编译指向 agent 真实的 default 历史(:245-250)。等于说,别的 agent 框架的对话记录也能被"消化"成 Letta agent 的记忆。

--to 的实现是绕道记忆:先把磁盘上的目标文档同步进 MemFS,把"维护这份文档"的指令拼进反思指令,反思结束再从 MemFS 里把结果读出来写回磁盘(:211-234:333-348)。也就是说 agent 全程只在自己的记忆里操作,不直接改工作区文件。

还有个稳健性细节:完成回调可能因为下游异常永远不触发,所以 dreamPromise.race 给等待加了硬超时——注释写明"一个无人值守的调用必须总能退出"(:312-315)。


8. 巧妙之处(可以借鉴的)

  1. 把 commit message 做成工具的必填参数。 reason 不是可选的日志字段,是 schema 里的 required(src/tools/schemas/Memory.json)。副作用是记忆演化史天然可读,而且强迫模型在写之前先想清楚"我为什么要记这个"。

  2. "改了个寂寞"要报错,不能报成功。 两处检查:受影响路径为空、提交后 committed === false,都抛错(src/tools/impl/memory.ts:114-140)。沉默的无操作是 agent 幻觉的温床。

  3. 规则下沉到 git hook,而不是只放在工具里。 agent 能绕过 TypeScript 工具直接用 shell,但绕不过 pre-commit(src/agent/memory-git-hooks.ts:27)。保护要放在所有写入路径的交汇处。

  4. 脱敏做在错误对象的每个字段上。 不只 message,还有 cmd/command/stack/stdout/stderr(redactGitAuthError,src/agent/memory-git.ts:163),并且统一在 runGit 的 catch 里执行,没有漏网路径。

  5. 权限按阶段收放。 反思阶段父目录只读,集成阶段才可写(buildReflectionMemoryScope vs buildReflectionIntegrationMemoryScope,src/agent/memory-worktree.ts:184/:194)。

  6. fail-closed 而不是降级。 没有沙箱后端时抛错,不"退化成弱策略继续跑"(src/permissions/memory-confinement-launcher.ts:73-77)。

  7. 截断要留导航信息。 目录树截断留 [Tree truncated: showing X of Y entries],文件预览截断留绝对路径(renderMemoryFilesystemTree / buildMemoryPreviewNotice)。截断是压缩上下文,不是丢弃线索。

  8. 动态围栏长度。 markdownFenceFor(src/tools/impl/memory-apply-patch.ts:804)按内容里最长的反引号串决定外层围栏——记忆文件里带代码块是常态。

  9. 禁签名放在最高优先级的 -c 上。 这样连 git 内部 rebase/merge 产生的提交也覆盖到(src/agent/memory-git-signing.ts:13)。这是"配置注入点选在哪"的一个好范例。


9. 边界与局限

诚实列一下这套设计做不到什么:

  • 改记忆不能立刻改行为。 本回合的提示已编译,必须等重编译。这是刻意的取舍(避免回合中途人格漂移),但也意味着 agent 无法"当场纠正自己"。
  • 仓库脏就全线停摆。 assertMemoryRepoCleanForWrite 是硬门:工具写不了、人格切不了、反思不启动(parent_dirty)。好处是永不产生混合状态,代价是用户/agent 必须先手工收拾。
  • str_replace 只替换第一处匹配(src/tools/impl/memory.ts:204-211),没有"必须唯一"的校验。同一段文字出现多次时,替换的是哪一处取决于位置。
  • MemFS 一旦启用不能关。 applyMemfsFlags 的注释明说 "MemFS cannot be disabled"。
  • frontmatter 解析是简化版 YAML。 parseMdxFrontmatter(src/agent/memory.ts:26)只认 key: value 单行,pre-commit 钩子里的 bash 解析同样朴素(会跳过缩进的续行)。复杂 YAML 会被误读。
  • 远端强绑 Letta 云。 isLettaMemfsServer(src/agent/memory-filesystem.ts:624)要求 MemFS 同步端点是 api.letta.com(或本地测试开关),自建服务端不支持远端 MemFS 同步。letta.memoryRepository.url 只是单向镜像,不是替代远端。
  • 反思冲突不解决,只回滚。 merge_conflict 的处理是 merge --abort + 清理 + 等下次重试,没有任何自动解冲突。

10. 代码地图

主题文件关键符号
默认 block 定义src/agent/memory.tsMEMORY_BLOCK_LABELSgetDefaultMemoryBlocksparseMdxFrontmatter
只读 block 名单src/agent/memory-constants.tsREAD_ONLY_BLOCK_LABELS
block 模板src/agent/prompts/persona.mdxhuman.mdxmemory_filesystem.mdx
系统提示模板src/agent/prompts/letta.mdletta_no_memfs.mdletta_local_memfs.md
提示装配src/agent/prompt-assets.tsMEMORY_PROMPTSSYSTEM_PROMPTSMemoryPromptModebuildSystemPrompt
提示解析(含子 agent)src/agent/system-prompt-resolution.tsresolveAndBuildSystemPromptresolveSystemPrompt
人格覆写src/agent/personality.tsapplyPersonalityToMemoryreplaceBodyPreservingFrontmatterbuildCreateAgentOptionsForPersonality
MemFS 路径与开关src/agent/memory-filesystem.tsMEMORY_FS_ROOTgetMemoryFilesystemRootresolveScopedMemoryDirapplyMemfsFlagsisMemfsEnabledOnServerenableMemfsIfCloud
目录树渲染与截断src/agent/memory-filesystem.tsrenderMemoryFilesystemTreeMEMORY_TREE_MAX_LINES
记忆工具(六命令)src/tools/impl/memory.tsmemoryapplyMemoryCommandnormalizeMemoryLabelloadEditableMemoryFile
记忆工具 schemasrc/tools/schemas/Memory.jsonrequired: ["command","reason"]
大改动 patchsrc/tools/impl/memory-apply-patch.tsmemory_apply_patchparsePatchOperationsmarkdownFenceFor
git 提交与同步src/agent/memory-git.tscommitMemoryWriteassertMemoryRepoCleanForWriteinitializeLocalMemoryReposyncPendingMemoryCommitsAfterTurngetGitRemoteUrl
git 鉴权与脱敏src/agent/memory-git.tsbuildGitAuthArgsbuildNonInteractiveGitEnvredactGitAuthInTextisRetryableGitTransientError
第二远端src/agent/memory-git.tssetMemoryRepositoryUrlpushToMemoryRepositoryreadMemoryRepositoryPushLog
git 钩子src/agent/memory-git-hooks.tsPRE_COMMIT_HOOK_SCRIPTPOST_COMMIT_HOOK_SCRIPTinstallPreCommitHookinstallPostCommitHook
禁签名src/agent/memory-git-signing.tsGIT_DISABLE_COMMIT_SIGNING_ARGS
worktree 隔离src/agent/memory-worktree.tscreateReflectionMemoryWorktreebuildReflectionMemoryScopefinalizeReflectionMemoryWorktree
内核沙箱src/memory-confinement.tssrc/permissions/memory-confinement-launcher.tscreateMemoryConfinementLaunchercreateMemoryConfinementLauncherWithAvailability
沙箱策略src/permissions/sandbox-policy.tssrc/agent/subagents/sandbox.tsbuildMemorySubagentSandboxPolicywrapSubagentLauncher
反思触发src/cli/helpers/post-turn-reflection.tssrc/cli/helpers/memory-reminder.tsmaybeLaunchPostTurnReflectiongetReflectionSettingsshouldFireStepCountTrigger
反思启动与收尾src/cli/helpers/reflection-launcher.tslaunchReflectionSubagentprepareReflectionMemoryWorktreeLaunchfinalizeReflectionMemoryWorktreeLaunch
反思完成同步src/cli/helpers/reflection-completion.tssyncReflectionCompletionToCloudfinalizeAutoReflectionCompletion
父记忆快照src/cli/helpers/reflection-transcript.tsbuildParentMemorySnapshotbuildMemoryPreviewNotice
设置类型src/reflection-settings.tsReflectionTriggerReflectionMergeMode
子 agent 提示src/agent/subagents/builtin/reflection.mdmemory.md
做梦子命令src/cli/subcommands/dream.tsrunDreamSubcommandparseDreamArgs

相关章节: index.md · 01-stateful-turn.md(回合与上下文压缩) · 02-tools.md(工具层) · 03-permissions.md(权限与沙箱) · 05-self-extension.md(技能与子 agent) · 06-surfaces.md(多入口与定时)