跳到主要内容

数据截至 (上游 commit ee230f304a1a)

自我扩展:技能、子 agent 与改写 harness 的 mods

本章讲: 一个 agent 在运行期给自己加能力,有三条完全不同的路。三条路加的东西不同、执行者不同、活多久也不同——先把三者分清楚,剩下的代码就都好读了。

前面几章讲的是「跑一回合」的固定管线(一次回合是怎么跑的)、固定工具表(本地工具层)、固定权限规则(批准这件事)。本章讲的全是可变的那一层


1. 先分清三条路

三条路都叫「扩展」,但它们连改的东西都不是同一个:

机制加进去的是什么谁来执行活多久模型侧入口
技能 Skill一段 Markdown 里的程序性知识(怎么做这件事)还是当前这个模型一次工具调用注入,随上下文走Skill 工具 / /斜杠命令
子 agent Subagent一个独立进程里的另一个 agent新起的 letta 子进程一次任务,返回一份报告就结束Task 工具
mod一段受信任的本地 JS/TS 代码,直接接进 harnessharness 自己(进程内)常驻,直到 /reload 或退出mod 注册的新工具 / 新命令

一句话记法:

  • 技能改的是「上下文」 —— 模型知道得更多了。
  • 子 agent 改的是「进程」 —— 多了一个人干活,且他的上下文和你隔离。
  • mod 改的是「harness」 —— 连工具表、权限判定、模型 provider 都能换。
┌──────────────── 一次回合 ────────────────┐
│ │
SKILL.md 文件 ─────┼─▶ ① 名字+描述常驻 system reminder │
(.agents/skills 等)│ ② Skill 工具调用 → 正文注入成 user 消息│
│ │
.letta/agents/*.md ┼─▶ Task 工具 ──▶ spawn("letta", headless) ─┼──▶ 子进程
(SubagentConfig) │ stream-json 回流 │ 独立上下文
│ │
~/.letta/mods/*.ts ┼─▶ import() → activate(letta) 注册 │
│ tools / commands / events / │
│ permissions / providers / ui │
└──────────────────────────────────────────┘

2. 技能:把「该怎么做」按需装进上下文

2.1 要解决的小问题

模型什么都懂一点,但不懂你们公司的发版流程。你可以每次都在 prompt 里贴一遍,也可以写成一份文件让它自己去读。

难点在于上下文是公共品——内置的 creating-skills 技能自己就是这么说的:技能要和 system prompt、历史消息、其它技能的元数据抢同一个窗口(src/skills/builtin/creating-skills/SKILL.md)。所以技能的设计核心不是「怎么写」,而是怎么让 99% 的时间里它只占一行

2.2 一个技能长什么样

一个技能 = 一个目录 + 目录里的 SKILL.md(可选再带 scripts/references/)。

---
name: scheduling-tasks
description: Schedules reminders and recurring tasks via the letta cron CLI. Use when...
---

# Scheduling Tasks

## When to Use This Skill
- User asks to be reminded of something ("remind me to X at Y")
...

frontmatter 里真正被解析的字段见 src/agent/skills.ts:416parseSkillFile:

字段作用源码
id不写则由目录路径推导(web/scraper/SKILL.MDweb/scraper)skills.ts:430-438
description不写则取正文第一段skills.ts:449-455
when_to_use拼到 description 后面,专门喂给模型做触发判断skills.ts:457-460
disable-model-invocation只许人用斜杠命令调,模型看不见skills.ts:471isModelInvocableSkill:148
user-invocable反过来:只许模型调skills.ts:152

细节一则:扫描时匹配的是 entry.name.toUpperCase() === "SKILL.MD"(skills.ts:380),大小写不敏感;符号链接会 stat 后跟进去,并用 visitedRealPaths 防环(skills.ts:337-368)。

2.3 四个来源,一条覆盖链

技能可以来自四个地方,同名后来者覆盖前者:

优先级来源 SkillSource目录典型用途
1(最高)project./.agents/skills/(旧路径 ./.skills/ 仍兼容)这个仓库特有的流程
2agent~/.letta/agents/{id}/memory/skills/这个 agent 自己学会的
3global~/.letta/skills/你个人的
4(最低)bundled打包进 npm 包的 src/skills/builtin/出厂默认

覆盖顺序就是 discoverSkills 里那四段 if,从 bundled 装起、一路 skillsById.set 覆盖上去(src/agent/skills.ts:265-321)。类型定义在 src/agent/skill-sources.ts:4,CLI 侧的 --skill-sources / --no-skills / --no-bundled-skills 收敛到 resolveSkillSourcesSelection(skill-sources.ts:76)。

还有一个第五处:agent 的 MemFS 记忆目录下的 skills/($MEMORY_DIR/skills)。它被 discoverMemorySkills 单独扫,然后故意把 source 改写成 "agent":

// src/agent/client-skills.ts:251
skillsById.set(skill.id, { ...skill, source: "agent" });

配合 collectClientSideSkills 里那句「已经是 project/agent 就不覆盖」(client-skills.ts:466),实际优先级是 project > agent > memory > global > bundled。这一段值得留意:记忆里的技能是 agent 记忆系统的一部分,会随 git 化的 MemFS 一起同步。

2.4 两段式披露:这是整个技能系统的关键

技能不是一次性全塞进上下文的。分两段:

第一段(便宜,常驻)
buildClientSkillsPayload ──▶ [{name, description, location}, ...]
client-skills.ts:493 随每次 messages.create 发给后端
→ 模型只看到「有哪些技能、各干嘛」

第二段(贵,按需)
模型调用 Skill 工具 ──▶ readSkillContent 找到 SKILL.md
renderSkillContent 替换 <SKILL_DIR>
queueSkillContent(toolCallId, 正文)
工具本身只返回一句 "Launching skill: X"


harness 消费队列,把正文当成
一条 user 消息附在工具结果旁边

工具不直接返回正文,这一手很妙。skill() 返回的只是一句话(src/tools/impl/skill.ts:322),真正的正文走旁路队列 queueSkillContent(src/tools/impl/skill-content-registry.ts:23),由各个入口自己消费:TUI 在 src/cli/app/use-conversation-loop.ts:757、headless 在 src/headless.ts:1964 等多处、常驻监听器在 src/websocket/listener/skill-injection.ts:22injectQueuedSkillContent

好处是正文以 user 消息的角色进入历史,而不是挤在工具返回值里被截断规则(本地工具层)砍掉。注册表文件的注释自己点破了这个模式:和 toolImageRegistry 同源——工具只返回字符串,富内容在 harness 层注入(skill-content-registry.ts:1-10)。

渲染时还做了两件小事(src/tools/impl/skill.ts:226-249 renderSkillContent):

  • <SKILL_DIR>${CLAUDE_SKILL_DIR} 替换成技能目录的绝对路径——所以 SKILL.md 里能写 python3 <SKILL_DIR>/scripts/show_config.py
  • 如果目录里除了 SKILL.md 还有别的文件,正文前面加一行 # Skill Directory: <路径>,提示模型「这儿还有配套脚本」。

子 agent 另有一条预加载通道:--pre-load-skills a,b 会在开场就把整份正文包成 <loaded_skills> 塞进第一条消息(src/headless.ts:2088-2118),因为子 agent 是一次性的,没机会慢慢按需取。

2.5 缓存与一次性文件监视

buildClientSkillsPayload 每回合都要跑,但技能目录很少变,所以有一层进程内缓存,key 是「agentId + sources + cwd + 各个技能根目录」(client-skills.ts:111 computeCacheKey)。

失效不靠轮询,靠 ClientSkillsWatcher。它的策略是一次性的:

第一次请求 ──▶ ensureRoots(roots) 给每个根目录装 fs.watch

任意一次文件变动


invalidate(): close() 关掉全部 watcher
+ 清空缓存

下一次请求 ──▶ 重新装 watcher + 重新扫盘

src/agent/client-skills-watcher.ts:100(类定义)、:108(ensureRoots)、:146-149(invalidate 里先 this.close()onChange())。装一次、响一次、全部拆掉——省掉了「防抖」和「重复事件」这两类麻烦。

两个边角处理得挺细:目录还不存在时,退而监视最近的已存在祖先目录,并只认那个待创建的子目录名(:136-143);平台不支持 fs.watch 时整段 try/catch 吞掉,保持当前快照直到进程重启(:172-178)。

2.6 内置技能里最有意思的:让 agent 改自己

src/skills/builtin/ 下有 21 个内置技能。多数是普通的领域知识,但有一批是元技能——教 agent 怎么改自己:

技能教 agent 做什么关键设计
creating-skills写新技能反复强调「上下文是公共品」,并按任务脆弱度分「高/中/低自由度」三档决定该写散文还是写脚本
creating-mods写 mod(本章 §4)开头就是一张「用户想要什么 → 该建工具还是命令还是事件」的决策表
self-configuration改模型、上下文长度、system prompt、权限、mods、技能、渠道、定时见下
initializing-memory初始化/重组自己的记忆记忆系统
scheduling-tasksletta cron 给自己排定时任务多入口与常驻

self-configuration 是这一批里最值得抄的一份。 它没有一上来教命令,而是先要求「选层」——因为同一个诉求放错层就会出事:

放什么怎么改
记忆与身份持久事实、风格偏好、人设、项目知识$MEMORY_DIR 文件并同步记忆仓库
服务端 agent 字段默认模型、上下文上限、system prompt、压缩策略PATCH /v1/agents/{agent_id}
服务端 conversation 字段只针对这一次会话的临时实验PATCH /v1/conversations/{conversation_id}
本地 settings权限、环境变量、UI 偏好、工具集覆盖~/.letta/settings.json
mods新的确定性工具、命令、provider、statusline加载 creating-mods
技能可复用的程序性知识加载 creating-skills

判定规则一句话(原文):要模型「记住并推理」的,进记忆;要运行时在模型决定之前就「强制执行或路由」的,进 settings / API 字段 / mods / 渠道 / 定时。

同一份技能还写了两条硬话,值得单独拎出来:

  • 「护栏不是安全边界」——它自带的那些 Python/TS 辅助脚本只是降低误伤,对一个拥有无限制 Bash、curl、API key 的 agent 不构成防线(src/skills/builtin/self-configuration/SKILL.md:40)。真正的边界在批准这件事
  • 「别指望坏掉的模型自我修复」——如果改坏了模型/prompt 导致跑不完一个回合,去另一个 shell 用 CLI 带外恢复。

2.7 letta skills 子命令:装进 MemFS 并 git 提交

CLI 侧的三个动作在 src/cli/subcommands/skills.ts:

动作入口干了什么
letta install <spec>installSkillDirectory:711下载 → 拷进 $MEMORY_DIR/skills/<name> → git 提交
letta skills listlistSkillDirectories:748$MEMORY_DIR/skills/*/SKILL.md,读 frontmatter
letta skills delete <name>deleteSkillDirectory:787删目录 → git 提交

<spec> 支持五种写法,由 resolveSkillSourceSpecifier 分派(skills.ts:412):ClawHub 注册表(clawhub/<slug>)、Hermes 官方技能(official/<path>,落到 NousResearch/hermes-agent 仓库)、GitHub 仓库/tree/blob URL、owner/repo/path 简写、直连 https://.../SKILL.md同一个 letta install 还兼管 mod 包:npm: 前缀或可识别的 git 包直接转去装 mod(skills.ts:1050:1082)。

装进去之后要提交,这是它和普通「拷文件」的差别:commitSkillMemoryChange(skills.ts:909)用 agent 名字当 author、{agentId}@letta.com 当 email,pathspec 精确到 skills/<name>,云端 agent 还会接着 push(syncCommittedRemoteSkillMemoryChange:815)。也就是说——agent 装一个技能,等于给自己的记忆仓库打一个 commit,可回滚、可审计。细节见记忆系统

下载路径上的三道防线值得一提:sanitizeSkillName 只放行 [A-Za-z0-9._-](:688)、assertInside 保证解析后的路径没跑出目标目录(:677)、ClawHub 的 zip 逐个成员做 assertSafeZipMember 防 zip-slip(:625)。


3. 子 agent:把一段活外包给另一个进程

3.1 要解决的小问题

有些活会污染上下文:翻 200 个文件找一个符号、把一年的历史会话读一遍、把记忆文件重排一遍。做完之后你只想要一句结论,不想要过程中那 30 万 token 的垃圾。

Letta Code 的答案很直白——开一个子进程。子 agent 不是同进程里的一个循环,是真的 spawn("letta", [...]) 跑一个 headless 实例,父进程只读它 stdout 的最后一行报告。

3.2 SubagentConfig 与 7 个内置定义

一个子 agent 的定义就是一份带 frontmatter 的 Markdown。7 份内置定义放在 src/agent/subagents/builtin/,在构建期被内联成字符串——build.js:93 给 bundler 配了 ".md": "text" 的 loader,所以 index.ts 顶上那七行 import forkAgentMd from "./builtin/fork.md" 拿到的直接是文件内容(src/agent/subagents/index.ts:20-26)。

内置子 agent分工toolsmodel特别之处
general-purpose研究、规划、实现,什么都能干Bash/Edit/Read/Write/Task*inherit唯一允许「部署到已有 agent」的类型
fork带着父会话的完整历史去干一件事allinheritfork: truebackground: true
recall翻历史会话,只返回检索报告Bash/Read/TaskOutputinheritfork: true,但工具被砍到三个
history-analyzerletta trajectories export 出来的历史轨迹,直接改记忆Read/Write/BashautolaunchProfile: memory-subagent
init快速扫项目,建记忆骨架Read/Write/Edit/Bashauto-fast挑最快的模型,追求少调用
memory记忆碎片整理,把大文件拆成单一职责小文件Bash/TaskOutputautomemory-subagent
reflection后台复盘最近会话,更新记忆与技能Bash/Editinheritmemory-subagent + 独有的上下文闸门

SubagentConfig 类型在 index.ts:80。三个字段决定了运行形态:fork(要不要继承父会话)、background(默认是否后台跑)、launchProfile("default" 还是 "memory-subagent")。

用户可以在 ~/.letta/agents/*.md./.letta/agents/*.md 里加自己的(discoverSubagents:459,项目级覆盖全局级)。这里有个巧妙设计:正文为空的定义 = 覆盖层(overlay),只改 frontmatter 里写到的字段,其余从低优先级配置继承(parseSubagentContent:305applySubagentOverlay:248)。想把内置 reflection 换个模型,写四行就够,不用抄那 231 行 prompt。

3.3 启动一次子 agent 的全流程

模型调用 Task 工具
src/tools/impl/task.ts:655
│ 校验类型;若 config.fork → forkConversation(hidden:true)

spawnSubagent manager.ts:803
│ 解析父 agent/会话 → resolveSubagentModel 选模型
│ 部署已有 agent 时不换模型,只前置一段 system reminder

executeSubagent manager.ts:340
│ ① buildSubagentArgs → 一串 letta CLI 参数
│ ② resolveSubagentLauncher → 用哪个二进制/脚本跑
│ ③ composeSubagentChildEnv → 子进程的环境变量
│ ④ wrapSubagentLauncher → 需要的话套一层 OS 沙箱

spawn(cmd, args, {cwd, env}) manager.ts:481
│ prompt 走 stdin(不进 argv,避免长度限制)

子进程 stdout 逐行 JSON ──▶ processStreamEvent subagent-stream.ts:154
│ init / message / result / error

最后一行 result 事件 ──▶ SubagentResult { report, success, totalTokens, ... }

提示词不在命令行里。 buildSubagentArgs 只有当 promptTransport !== "stdin" 时才 push -p <prompt>(manager.ts:285-287),而实际执行路径恒设 promptTransport: "stdin"(:400),真正的投递是 proc.stdin.end(boundedUserPrompt)(:486)。所以 ps 里看到的子 agent 命令行只有 --output-format stream-json --permission-mode unrestricted 这类开关,没有提示词——既绕开 argv 长度上限,也不把整段上下文晾在进程列表里。

3.4 命令行怎么拼

buildSubagentArgs(manager.ts:214)是这条链上最值得读的一个纯函数——它把「子 agent 该长什么样」全部翻译成 CLI flag:

拼出来的 flag条件源码
--conv <id> / --agent <id> --new部署已有 agent/会话:232-241
--new-agent --system <type>新建(--system-custom 可覆盖人设):246-252
--tags type:X,parent:Y新建时打标签,便于事后查:253-258
--no-system-info-reminder --no-skillsreflection,且非 Windows:270-276
--base-tools nonereflection/memory/history-analyzer/init(纯本地活,不要联网工具):78:281
--output-format stream-json恒定:288
--permission-mode unrestricted恒定:289
--allowedTools ...父进程 CLI 规则 ∪ 会话规则 ∪ 该子 agent 的工具表:293-304
--tools ...allowedTools !== "all" 时把工具集裁到列表:308-317
--pre-load-skills ...config 里声明了 skills::331

注意 --permission-mode unrestricted 这一行:子 agent 内部不再做交互式审批。原因很实在——它非交互跑,没有人能按 y。取而代之的是把父进程的允许/拒绝规则整体传下去,再叠上该子 agent 声明的工具白名单(:293-306),并且对 memory 类子 agent 上 OS 级沙箱(下一节)。权限模型的全貌见批准这件事

3.5 memory-subagent:换工作目录、换 MEMORY_DIR、上沙箱

reflection / memory / init / history-analyzer 这四个的工作对象不是代码仓库,是父 agent 的记忆文件系统。所以它们声明 launchProfile: memory-subagent,触发三处特判:

  1. 工作目录改成记忆根目录而不是仓库(resolveSubagentWorkingDirectory,src/agent/subagents/subagent-launcher.ts:35)。
  2. 环境变量 MEMORY_DIR / LETTA_MEMORY_DIR 指向父 agent 的 memfs 仓库;非 memory-subagent 一律不覆盖(composeSubagentChildEnv:173,判定在 :214-224)。
  3. 整个子进程套进 OS 文件系统沙箱(wrapSubagentLauncher,src/agent/subagents/sandbox.ts:83)。

第 3 点的思路值得学:与其在进程内逐个工具校验路径,不如把整个子进程包起来——这样它的 Write/Edit 工具、它的 Bash 命令、以及 Bash 再拉起来的任何东西,全部一并受限(sandbox.ts:17-22 的注释就是这么写的)。

策略本身(~/.letta 全放行 → 两棵 agents 树禁读写 → 只把自己那一份重新挖开)由 buildMemorySubagentSandboxPolicy 构造,不在本章展开:三步搭法见记忆系统的内核沙箱一节,策略模型与两个后端见批准这件事。本章只关心 wrapSubagentLauncher 这一层的收权范围,它有三个短路条件(sandbox.ts:88-101):沙箱开关关掉、launchProfile 不是 memory-subagent、或者算不出任何可写记忆根——最后一条是为了别把子进程关进一个连记忆都写不了的笼子。真正传进策略的可写集合是「记忆作用域的 writableRoots + primaryRoot + ~/.letta 之外的 harness 根(自定义 transcript 根、迁走的本地存储目录)」(sandbox.ts:109-128)。

而且它默认开启——与那个 opt-in 的跨 agent shell 沙箱相反。理由写在注释里:memory 子 agent 非交互运行,没有 approve/deny 兜底可退(sandbox.ts:24-28)。宿主机没有沙箱后端时才 no-op。

顺带一提,reflection 的子进程还被强制降级 mod 能力:LETTA_MOD_CAPABILITY_PROFILE=providers-only(subagent-launcher.ts:194-197),即只留 provider 注册,工具/命令/事件/UI/权限全关——见 §4.3。

3.6 reflection 独有的上下文闸门

reflection 开场就要带上父 agent 的记忆预览,很容易一开局就撑爆。于是有一套逐级缩水逻辑(manager.ts:147 capReflectionStartupPrompt):

估算 system prompt + user prompt 的 token 数
estimateStartupContextTokens = ceil(chars / 4) context-budget.ts:20

≤ 16,000 ? ──是──▶ 原样发
│否

① 只保留 <parent_memory> 里的 <memory_filesystem> 目录树 + 一句截断说明
shrinkParentMemorySection manager.ts:117
│仍超

② <parent_memory> 只剩那句截断说明
buildMinimalParentMemorySection manager.ts:108
│仍超

③ 整段 prompt 硬截断,末尾补上截断说明
hardTruncateReflectionPrompt manager.ts:136

两个可借鉴的点:估算故意不引 tokenizer,用 4 字符/token 的保守常数换来一个同步、零依赖的代码路径(context-budget.ts:1-8);每一级截断都会留下一句给模型看的说明,告诉它「内容被裁过,需要就自己去 $MEMORY_DIR 读文件」(getReflectionStartupNotice:104)。

3.7 回传:stream-json 与截断重试

子进程 stdout 是逐行 JSON。父进程手工按行切(不用 readline,注释说是为了避开 Bun 嵌套子进程行读取的不稳定,manager.ts:517-520),逐行喂给 processStreamEvent(subagent-stream.ts:154):

事件 type处理
init / system(subtype=init)记下 agent_id / conversation_id,生成可点击的 agentURL
message(tool_call_message)记一次工具调用,同时转发给 WS 前端
result记下最终报告、耗时、token、步数
error记下错误

有个很细的可靠性判断:looksLikeTruncatedStreamJson(subagent-stream.ts:202)。它只在最后一行非空、无换行结尾、且解析不出 JSON 时才判定为传输截断——因为「完整但意料之外的输出」说明子进程可能已经产生了副作用,重试会重复执行;只有明确的半截行才值得重试(注释里点了 issue #3257)。

3.8 暴露给模型的工具:TaskTask* 家族

这里有个容易混淆的命名,先说结论:

工具干什么实现
Task派子 agent(本节的主角)src/tools/impl/task.ts:655
TaskCreate / TaskGet / TaskList / TaskUpdate进程内的待办清单,TodoWrite 的替代品task-create.ts:11 等,共用 tasks/store.ts
TaskStop两者都管:先按 id 找后台子 agent,找不到再当 Bash 后台进程去杀task-stop.ts:16
TaskOutput取后台任务的输出task-output.ts

Task* CRUD 家族背后只是一个进程内 Map,带稳定 id、blocks/blockedBy 依赖边和自由 metadata(src/tools/impl/tasks/store.ts:12-32),生命周期是进程级——注释自己承认「未来可能改成按会话隔离」(store.ts:8-9)。

TaskStop 那段「先查子 agent、再退回杀 shell」的分派(task-stop.ts:20-37)是有意为之:对模型只暴露一个统一的 task_id 概念,后台子 agent 和后台 shell 共用同一个 id 空间。

Task 工具本身还有两个模式外的动作:command: "refresh" 清缓存重扫 .letta/agents/(task.ts:659-682),以及 fork: true 类型的会话分叉——分叉出来的会话被标成 hidden: true,免得把父 agent 的会话列表刷爆,但仍可按 id 直达(task.ts:743-747)。分叉后还要 inheritForkToolset 把父会话的工具集抄过去(task.ts:633)。

fork 型子 agent 开场会被塞一段 system reminder,核心是一句反复强调的话:你只是被 fork 出来看历史的,不是主 agent,别去接手它没干完的事(buildForkSystemReminder,manager.ts:752-786)。recall 还额外声明工具被砍到 Bash/Read/TaskOutput 三个。


4. mods:直接改 harness 本身

4.1 设计立场:不做语义化 SDK,用「可恢复」替代「兼容性」

src/mods/README.md 开门见山,这是全仓库最值得读的一份设计文档:

因为界面是 agent,不是人类插件作者,mods 不需要从一套强版本化的语义 SDK 起步。mods 是可信的本地代码,agent 可以检视、编辑、重载、修复它。(src/mods/README.md:7)

传统 API 稳定性的替代品是可恢复性:清晰的诊断、safe mode、reload,以及 agent 重写坏掉的 mod 代码的能力。(README.md:11)

这个立场推出几条反常识的规矩:

常见插件系统的做法Letta Code 的做法出处
提供 fs / git / shell / 日志 / 存储的封装 API不提供,让 mod 直接用 node:fsfetch、普通 JSREADME.md:17-19:49-57
保留旧 API 别名,双路径兼容直接替换,旧写法大声报错并指向新 APIREADME.md:63-75
破坏性变更要避免破坏性变更受欢迎,只要 agent 能凭诊断修好README.md:79
靠版本矩阵保证稳定靠 JS 特性检测 + safe modeREADME.md:64-68

它甚至给了「好诊断」的正反例(README.md:90-101):

好:letta.ui.setStatuslineRenderer was removed. Use letta.ui.openPanel({ id, order, render }) instead.
坏:mod failed

判断一个新 API 该不该加,有一张四问清单(README.md:15-37):这事儿为什么不能就写成普通代码?→ 这是不是宿主必须守住的不变量?→ 是不是已经有多个真实 mod 反复喊疼?→ 在这里「可恢复」是不是比「兼容」更划算?

4.2 一个 mod 长什么样

一个 .ts/.js 文件,默认导出(或 activate 导出)一个函数,收到一个 letta 句柄,返回一个可选的清理函数:

// 示意,非源码(形状取自 src/skills/builtin/creating-mods/references/tools.md)
export default function activate(letta) {
if (!letta.capabilities.tools) return; // 能力位没开就别注册

return letta.tools.register({ // 返回值就是 disposer
name: "branch_summary",
description: "Summarize the current git branch and recent commits.",
parameters: { type: "object", properties: {}, additionalProperties: false },
requiresApproval: false,
parallelSafe: true,
async run(ctx) { // 动态状态从 ctx 拿,不读全局
return await gitStatus(ctx.cwd);
},
});
}

重点看两处:第一行的能力位检查,和 run(ctx) 而不是读全局上下文。后者是硬要求——creating-mods 技能反复强调「模型可调用的行为不许读可变全局上下文」,因为常驻监听器可能同时在跑另一个 agent 的回合。旧的 letta.getContext() / ctx.getContext() 已被移除,调用会触发一条带迁移提示的诊断(src/mods/mod-diagnostics.ts:55-62)。

mod 从三个 scope 加载,优先级 legacy_global < bundled < global < agent(src/mods/mod-engine.ts:250-260),目录解析见 src/mods/mod-sources.ts:44 resolveLocalModSources:~/.letta/mods/(global,含 npm/git 装的托管包)和 $MEMORY_DIR/mods/(agent,随记忆走)。没有 project scope——creating-mods 明确写着 "Do not create project mods",毕竟这是一段无审批直接执行的本地代码。

4.3 六类能力

letta 句柄(LettaModApi,mod-engine.ts:132)上挂着六组注册函数,每组对应一个能力位:

能力letta.*能做什么注册表
toolstools.register加一个模型可自主调用的本地工具src/mods/tool-registry.ts:79
commandscommands.register加一个 /foo 斜杠命令registry 内 commands
events.*events.on挂 10 种生命周期事件registry 内 events
permissionspermissions.register加一层动态 allow/ask/deny 判定src/mods/permission-registry.ts:90
providersproviders.register注册自定义模型 providersrc/backend/dev/pi-provider-mod-registry.ts:68
ui.panelsui.openPanel在输入框上方开一个瞬态面板registry 内 ui.panels

能力位本身是一份可裁剪的结构(ModCapabilities,src/mods/types.ts:82),预置三档(src/mods/capabilities.ts):

档位内容用在哪
DEFAULT_MOD_CAPABILITIES:24全开TUI / headless
DISABLED_MOD_CAPABILITIES:41全关safe mode
PROVIDERS_ONLY_MOD_CAPABILITIES:58只留 providersreflection 子进程(见 §3.5)

档位可由环境变量强制:LETTA_MOD_CAPABILITY_PROFILE=providers-only(resolveProcessModCapabilities:68)。不同入口天然能力不同——creating-mods 里那句「TUI/headless 能装 tools/commands/events/UI/providers,桌面监听器不装面板 UI」,就是要求 mod 作者每次注册前都自己 guard 一下。各入口见多入口与常驻

4.4 加载:import + 归属 + 阶段化诊断

resolveLocalModSources ──▶ [{scope, root, files[], trusted}]
│ 按 scope 排序:legacy_global < bundled < global < agent

loadLocalMods mod-engine.ts:1373
逐个文件:
createModOwner(path, source, generation) ← 每个文件一个 owner + 一个 AbortController
┌─ phase: package_manifest 包清单本身有问题
├─ phase: transpile .ts 需要先转译
├─ phase: import await import(url + "?mod=" + mtimeMs) ← mtime 破缓存
├─ phase: activate factory(letta) 执行;返回值是 disposer
└─ 任一步抛错:removeOwnerCapabilities + abort + 记一条诊断,继续下一个

LocalModRegistry mod-engine.ts:201
{ tools, commands, events, permissions, ui.panels,
owners, ownerAbortControllers, diagnostics, generation, ... }

两个关键设计:

  • 一个 mod 挂了不影响别的。 失败被 catch 在单文件粒度,撤掉它已注册的东西、abort 它的 signal、记一条带 phase 的诊断,然后继续(mod-engine.ts:1479-1496)。
  • 每一份能力都记名。 owner 带 id / path / scope / generation,注销时按 owner 批量清(unregisterModToolsForOwner,tool-registry.ts:92)。诊断也带 owner,因为「agent 要能知道是哪个文件坏了」。

?mod=${mtimeMs} 那一手(mod-engine.ts:1452)是热重载的关键——ESM 的模块缓存按 URL 去重,改文件时 mtime 变,URL 就变,于是拿到新模块。

4.5 全局注册表与 safe mode

工具、权限、provider 三张表挂在 globalThis 的 Symbol key 上(Symbol.for("@letta/modTools"),tool-registry.ts:14;Symbol.for("@letta/modPermissions"),permission-registry.ts:13),理由和技能缓存一样:Bun 打包会去重模块,挂 globalThis 才能保证全进程一份。

safe mode 是贯穿式的:areModsDisabled()(src/mods/disable.ts:10,认 LETTA_DISABLE_MODS 和遗留的 LETTA_DISABLE_EXTENSIONS)被塞进了每一个读接口——getAvailableModToolsRegistrygetModToolDefinitionmodToolRequiresApprovalisModToolParallelSafecheckModPermissions,全都在开头 return 空。CLI 侧的 --no-modsshouldDisableMods:17。所以不存在「半开」状态:关了就是每一条读路径都读不到。

工具还有第二道门:activationSignal.aborted 为真就当它不存在(tool-registry.ts:50:141)。这是 reload 后旧世代残留句柄的兜底——recordStaleHandleUse(mod-diagnostics.ts:184)会把这种「用了过期句柄」也记成一条诊断。

4.6 事件:mod 能改写一次回合

十个事件名(src/mods/types.ts:173):conversation_open/closeturn_start/endtool_start/endcompact_start/endllm_start/end

emitLocalModEvent(mod-engine.ts:1505)不只是广播——handler 的返回值能改写正在进行的回合:

事件返回效果
turn_start{ input }改写这一回合要发出去的消息
turn_start{ cancel: { reason } }直接取消这一回合
tool_start{ args }改写工具入参
tool_start / tool_end{ result }合成一个工具结果(等于拦截执行)
turn_end{ continue }让回合继续,塞一条后续消息

因为返回值有这么大权力,写入前逐个做了形状校验,校验不过就整体回滚到 handler 执行前的快照(mod-engine.ts:1536-1541 存快照,:1621-1634 回滚;抛异常路径同样回滚,:1637-1643)。turn_start 还有一条额外不变量:审批消息必须排在最前,改写完再用 preserveApprovalFirstOrdering 拉回来(:1662)——审批环路的语义见一次回合是怎么跑的

每个 handler 收到的 ctx 里都带一个按事件自身 agentId/conversationId 作用域化的会话句柄(:1567-1578),而不是全局的「当前会话」。这就是前面说的「scoped handle 优先于全局状态」的落地。

4.7 权限叠加:合成顺序

checkModPermissions(permission-registry.ts:155)把所有已启用的 mod 权限跑一遍,然后按固定优先级合成:

所有 mod 的 check(event, ctx) 结果


deny > alwaysAsk > ask > allow permission-registry.ts:144-153


{ decision, matchedRule: "mod permission:<id>", reason }

外加一条失败即拒绝:某个 mod 的 check 抛异常,立刻返回 deny,理由里点名是哪个 mod 挂了(:209-220)。这和 mods 的整体立场一致——宁可大声坏掉,也不要静默降级。这一层怎么和 CLI 规则、会话规则、shell 分析叠在一起,见批准这件事

4.8 会话句柄:宿主必须守的那几件事

createModConversationHandle(src/mods/conversation-handle.ts:21)是「什么该做成宿主 API」的正面例子。它只暴露五个动作,每一个都是宿主必须协调的:

方法为什么不能让 mod 自己写
fork(opts)要走后端的会话分叉,且返回的是新句柄
getHistory(opts)要按 backend 差异归一化消息
sendMessageStream(...)要接进 harness 的回合流水线
updateTitle(title)写后端之后还要广播给本地 UI(publishConversationTitleChange)
updateLlmConfig({scope})agent 级还是 conversation 级,两条不同的写路径

文件系统、git、shell、日志——README 明确列为「通常不该做成宿主 API」(README.md:49-57)。

4.9 诊断:写给 agent 看的错误

诊断是这套设计的承重件,所以它有自己的结构(src/mods/mod-diagnostics.ts):

  • 分级按 phase 推:command_override 恒为 warning,deprecated_api / legacy_extension 默认 warning,其余默认 error(getModDiagnosticSeverity:35)。
  • 自动附迁移提示:识别到 getContext 相关错误就贴上对应的迁移说明(getModDiagnosticHint:80,三条常量在 :52-62)。
  • 环形缓冲:超过 200 条就砍到最近 50 条(MOD_DIAGNOSTICS_MAX_COUNT:10appendModDiagnostic:157)。
  • 落盘给 agent 读:writeModDiagnosticsLatestFile(src/mods/mod-diagnostics-file.ts:41)。

这就是「用可恢复替代兼容」的具体兑现方式:agent 看诊断 → 编辑 mod → /reload → 继续,全程不需要人来调试(README.md:103)。

4.10 reload:按 generation 丢弃旧世代

createModEngine(mod-engine.ts:1716)在 loadLocalMods 外面包了一层状态机,核心是一个单调递增的 generation:

reload()
├─ disposeLocalMods(当前 registry) ← 先拆
├─ generation += 1 → loadGeneration
├─ 立刻发一个空 registry 出去(UI 不卡)
└─ await loadLocalMods({ generation: loadGeneration, ... })
每次回调都先问:loadGeneration === generation ?
否 → 说明期间又 reload 了一次,丢弃这一批
是 → 换上并 publish

拆卸顺序是逆序的(disposeLocalMods:1683.reverse()),先 abort 所有 owner 的 signal,再逐个调 disposer,disposer 抛错也只是记一条 phase: "dispose" 的诊断。最后按 owner 清掉全局的 provider / permission / tool 注册并清空模型缓存(:1694-1700)。

4.11 打包、安装与 provider

打包是升级路径,不是默认写法(creating-mods 原话)。先有一个能跑的单文件 mod,再 letta mods package <file> --name <pkg>:

环节做什么源码
脚手架生成 package.json(含 letta 字段)、README、mod 指南src/mods/package-scaffolder.ts:127
清单{ manifestVersion: 1, mods: [...], capabilities?, engines? },未知 key 一律报错src/mods/package-manifest.ts:15parseLettaPackageManifest:250
路径校验mod 入口路径不许绝对、不许越界、扩展名要对isSafeLettaPackageModEntryPath:70
安装本地目录 / npm / git 三条路package-installer.ts:1038:1045:1073

清单里的 capabilities 用的正是 MOD_CAPABILITY_IDS 那 10 个 id(capabilities.ts:3),所以「这个包会碰什么」在装之前就能读出来。

provider mod 是唯一能改「模型从哪来」的能力。registerPiProvider(src/backend/dev/pi-provider-mod-registry.ts:68)除了存注册项,还维护一个按 provider 名分别递增的 revision(:35getRegisteredPiProviderRevision:45)——各后端的 Models 运行时靠比对 revision 判断「只有这一个 provider 变了」,从而只重建它一个,而不是整表重建。注册表监听器的异常被吞掉,注释写明「UI 刷新失败不该让 provider 注册失败」(:50-57)。

4.12 learning harness:让 agent 自己迭代 mod

src/mods/learning-harness.ts(2434 行)把「agent 写 mod」变成一个可评测的闭环:

ModLearningSpec (objective / requirements / examples / evaluation)


buildModLearningPrompt:791 ──▶ 让模型产出候选 mod 文件
│ (--candidates N 可以并行出多份候选)

runModLearningCandidate ──▶ 真的 createModEngine 加载这份候选
│ 再跑 headless 场景

evaluateModLearningRun:903 ──▶ 逐条断言:
│ mod_loads / turn_start_injects_message /
│ tool_start_rewrites_args / tool_start_preserves_args ...

写出 history.md / history.json / proposer-guide.md
│ 下一轮候选能读到前几轮为什么失败

runModLearning:2212 选出最好的一份

断言类型是结构化的(learning-harness.ts:51-74),不是「让另一个模型打分」——turn_start_injects_message 会去检查事件返回的 input 里到底有没有那段文本。这样评测结果可复现。

配套有一个内置技能 generating-mod-envs,专门教 agent 怎么写这份 spec JSON。也就是说:写 mod、评 mod、迭代 mod,三步都在 agent 手里。


5. 三者怎么选

同一个诉求,放错层的代价不一样。self-configuration 技能里那张表是 agent 版;下面是给读者的工程版:

你想要的该用为什么不是别的
「按我们团队的规范提 PR」技能是知识不是代码;写死成工具反而不灵活
「查一下这个符号在哪定义」但不想污染上下文子 agent隔离上下文正是它的全部意义
「每次发消息前自动附上当前分支」mod(turn_start 事件)必须在模型决定之前就发生
「加一个只读的 branch_summary 工具」mod tool要确定性执行,且要进权限体系
/standup 一键生成日报」技能 + 一个薄 mod 命令知识在技能里,触发在命令里
「接一个自建的模型服务」mod provider只有它能改模型来源
「禁止在 main 分支上跑 git pushmod permission(或 settings 规则)是运行时强制,不是模型的自觉

6. 巧妙之处(可借鉴)

  1. 工具只返回一句话,正文走旁路队列。 Skill 工具返回 "Launching skill: X",真正的正文经 queueSkillContent 由 harness 注入成 user 消息(src/tools/impl/skill.ts:319-322 + skill-content-registry.ts:23)。绕开了工具返回值的截断规则,也让正文以正确的角色进入历史。

  2. 一次性 watcher。 装一次、响一次、全部关掉,下次请求再装(client-skills-watcher.ts:146-149)。免了防抖、免了重复事件,代价是「变更后第一次请求要重扫」——对这个场景完全划算。

  3. 正文为空 = 覆盖层。 想改内置子 agent 的一个字段,写四行 frontmatter 就够,不必抄 231 行 prompt(subagents/index.ts:305 + applySubagentOverlay:248)。

  4. 沙箱包整个子进程,而不是逐个工具校验。 一次 kernel 级约束覆盖它的 Write/Edit、它的 Bash、以及 Bash 拉起的一切(subagents/sandbox.ts:17-22)。且对非交互的 memory 子 agent 默认开启,因为没有 approve/deny 可退。

  5. 截断判定只认「明确的半截行」。 完整但意外的输出不重试,因为子进程可能已产生副作用(subagent-stream.ts:193-212)。这是一条真正想清楚了幂等性的重试策略。

  6. 事件返回值先校验再写入,不合格整体回滚。 mod 能改写回合,但改坏了不会污染状态(mod-engine.ts:1536-1541:1621-1643)。

  7. 每一份 mod 能力都记 owner。 卸载、safe mode、reload 全部按 owner 批量清;诊断也带 owner 和 path,agent 才知道去改哪个文件。

  8. 诊断即 API 契约。 破坏性变更的验收标准不是「不破坏」,而是「诊断信息足够让 agent 自己修好」,README 甚至要求为过时写法补一个回归测试来验证诊断够不够具体(src/mods/README.md:88)。


7. 边界与局限

  • mod 是完全受信任的代码。 它进程内执行、没有审批环节、没有沙箱。所以刻意不支持 project scope 的 mod——不能让 git clone 一个仓库就带进一段自动执行的代码。装第三方 mod 包等价于装一个 npm 包并直接跑。
  • 子 agent 内部 --permission-mode unrestricted 约束靠父进程传下去的 allow/deny 列表和工具裁剪,memory 类另加 OS 沙箱;其余类型的子 agent 不套沙箱。
  • Task* 待办清单是进程级的,不按会话隔离,源码注释自己标了这是待办项(tools/impl/tasks/store.ts:8-9)。
  • 技能缓存靠 fs.watch 平台不支持时整段吞掉,快照会一直用到进程重启(client-skills-watcher.ts:172-178)。
  • readSkillContent 的兜底路径较多。 五级查找之后还有一条 process.cwd()/skills/skills 的遗留兜底(tools/impl/skill.ts:183-193),路径优先级不算一眼可懂。
  • mods 明确不保证向后兼容。 上游改 API 时旧 mod 就是会坏,赌的是「agent 看诊断能修好」。对不想让 agent 自动改自己配置的人来说,这是个需要明确接受的前提。
  • STANDARD_BUILTIN_SOURCESLOCAL_MEMFS_BUILTIN_SOURCES 当前内容完全相同(subagents/index.ts:28-46),代码里看不出这个分叉现在还起什么作用——像是留给后端差异化 prompt 的预留位。

8. 代码地图

主题文件关键符号
技能类型与四源发现src/agent/skills.tsSkilldiscoverSkillsparseSkillFileformatSkillsAsSystemReminderisModelInvocableSkill
技能来源选择src/agent/skill-sources.tsSkillSourceALL_SKILL_SOURCESresolveSkillSourcesSelection
发给后端的技能清单 + 缓存src/agent/client-skills.tsbuildClientSkillsPayloaddiscoverClientSideSkillscollectClientSideSkillsinvalidateClientSkillsPayloadCache
技能目录监视src/agent/client-skills-watcher.tsClientSkillsWatcherensureRoots
Skill 工具src/tools/impl/skill.tsskillreadSkillContentrenderSkillContentwrapSkillContent
技能正文旁路注入src/tools/impl/skill-content-registry.tsqueueSkillContentconsumeQueuedSkillContent
注入点(常驻监听器)src/websocket/listener/skill-injection.tsinjectQueuedSkillContent
内置技能(元技能)src/skills/builtin/creating-skillscreating-modsself-configurationinitializing-memoryscheduling-tasks
技能安装 CLIsrc/cli/subcommands/skills.tsinstallSkillDirectorylistSkillDirectoriesdeleteSkillDirectoryresolveSkillSourceSpecifiercommitSkillMemoryChange
子 agent 配置与发现src/agent/subagents/index.tsSubagentConfiggetBuiltinSubagentsdiscoverSubagentsgetAllSubagentConfigsapplySubagentOverlay
内置子 agent 定义src/agent/subagents/builtin/general-purpose.mdfork.mdrecall.mdhistory-analyzer.mdinit.mdmemory.mdreflection.md
子 agent 编排src/agent/subagents/manager.tsspawnSubagentexecuteSubagentbuildSubagentArgsbuildSubagentPromptbuildForkSystemReminder
子进程启动参数src/agent/subagents/subagent-launcher.tsresolveSubagentLaunchercomposeSubagentChildEnvresolveSubagentWorkingDirectory
子 agent 输出解析src/agent/subagents/subagent-stream.tsprocessStreamEventparseResultFromStdoutlooksLikeTruncatedStreamJson
启动上下文预算src/agent/subagents/context-budget.tsestimateStartupContextTokensREFLECTION_STARTUP_CONTEXT_TOKEN_LIMIT
子 agent 沙箱src/agent/subagents/sandbox.tswrapSubagentLauncher
子 agent 选模型src/agent/subagents/subagent-model.tsresolveSubagentModelgetPrimaryAgentModelHandle
Task 工具(派子 agent)src/tools/impl/task.tstaskspawnBackgroundSubagentTaskinheritForkToolset
Task* 待办工具src/tools/impl/task-create.ts 等 + src/tools/impl/tasks/store.tstask_createtask_gettask_listtask_updatetask_stopTaskRecord
mods 设计立场src/mods/README.md「核心论点」「设计清单」「兼容性立场」三节
mod 运行时src/mods/mod-engine.tscreateModEngineloadLocalModsemitLocalModEventdisposeLocalModsLettaModApiLocalModRegistry
mod 类型与事件名src/mods/types.tsModCapabilitiesModEventNameModToolModPermissionCheckEvent
能力档位src/mods/capabilities.tsMOD_CAPABILITY_IDSDEFAULT_MOD_CAPABILITIESPROVIDERS_ONLY_MOD_CAPABILITIESresolveProcessModCapabilities
mod 来源解析src/mods/mod-sources.tsresolveLocalModSourcesLocalModSource
mod 工具注册表src/mods/tool-registry.tsregisterModToolrunModToolunregisterModToolsForOwner
mod 权限叠加src/mods/permission-registry.tscheckModPermissionscomposePermissionDecision
mod 会话句柄src/mods/conversation-handle.tscreateModConversationHandle
mod 诊断src/mods/mod-diagnostics.tsmod-diagnostics-file.tsrecordModDiagnosticgetModDiagnosticHintrecordStaleHandleUsewriteModDiagnosticsLatestFile
safe modesrc/mods/disable.tsareModsDisabledshouldDisableMods
mod 打包与安装src/mods/package-manifest.tspackage-scaffolder.tspackage-installer.tsLettaPackageManifestparseLettaPackageManifestscaffoldLocalModPackageinstallNpmManagedModPackage
mod 自迭代评测src/mods/learning-harness.tsrunModLearningbuildModLearningPromptevaluateModLearningRunModLearningAssertion
mod provider 注册表src/backend/dev/pi-provider-mod-registry.tsregisterPiProvidergetRegisteredPiProviderRevisionsubscribePiProviderRegistry

这三条扩展路在不同入口(TUI、headless、远程环境、消息渠道、定时任务)上的能力并不相同——比如桌面监听器不加载面板 UI,reflection 子进程只留 provider。各入口的差异见多入口与常驻;技能与 mod 落进 agent 记忆仓库后的 git 化行为见记忆系统

相邻章节: 架构与原理总览 · 一次回合是怎么跑的 · 本地工具层 · 批准这件事 · 记忆系统 · 多入口与常驻