跳到主要内容

数据截至 (上游 commit ee230f304a1a)

本地工具层:定义、按模型族换装、执行与截断

30 秒导读: Letta Code 的工具全部跑在你的机器上,后端只负责把「模型想调哪个工具」发回来。 这一章讲一个工具从「一份 JSON schema + 一段 Markdown 描述」出发,怎么被装进注册表、怎么按模型族 换上不同的名字和参数、怎么被执行,以及执行完的那坨输出怎么被裁剪、脱敏、最后塞回上下文。

本章的边界:不讲批准与沙箱(见 03-permissions.md)、不讲 memory 工具的内部 (见 04-memory.md)、Task/子 agent 只提一句(见 05-self-extension.md)。 一次回合怎么把工具调用串起来,见 01-stateful-turn.md


1. 先建立直觉:这一层到底在解决什么

一个终端里的编码 agent,工具层看起来只是「函数注册表 + 分发」。Letta Code 之所以把它写到近 3000 行 (src/tools/manager.ts),是因为它同时要扛四件互不相干的事:

要扛的事白话
同一份能力,三套「皮」换到 GPT/Codex 或 Gemini,工具的名字和参数形状必须跟着换,行为不能变
换装期间不能塞错工具用户中途 /model 切模型,注册表在异步重建,这时不能把旧工具清单发给后端
执行时的上下文不能靠全局变量一个进程里可能同时跑多个 agent / 会话,工作目录、权限模式各不相同
输出不能撑爆上下文,也不能漏密一次 find / 能吐几百万字符;$API_KEY 不能出现在回给模型的文本里

一句话直觉: 把这一层当成「翻译 + 调度 + 海关」——翻译模型族的方言,调度到本地实现,再在出关时 对结果做安检和限重。


2. 顶层全景:一次工具调用的完整链路

这张图从上到下读,左边是「装配期」(启动或换模型时发生一次),右边是「执行期」(每个工具调用发生一次)。

装配期(loadTools / prepareToolExecutionContextForModel)
──────────────────────────────────────────────────────
schemas/*.json descriptions/*.md impl/*.ts
\ | /
+---- defineTool() ----+ tool-definitions.ts
| (65 个工具资产)
v
① 选清单:按模型族挑 ANTHROPIC / OPENAI / GEMINI 预设
|
v
② 建注册表:补描述、算 modelForm、装 impl
|
v
③ 冻结快照:存成 ctx-<id>,同时序列化出 client_tools
|
+------------> 发给后端 / 模型

执行期(executeTool)
────────────────────
后端回传 tool_call(用的是「服务器名」)
|
v
④ 按 contextId 取回那份快照(而不是读全局注册表)
|
v
⑤ 名字还原 + 分派:mod 工具 / 外部工具 / 内建工具
|
v
⑥ 注入隐形参数(signal、onOutput、secretEnv、parentScope…)
|
v
⑦ 跑 impl → 规整成字符串 → 脱敏 → 截断 → 兜底夹紧
|
v
toolReturn 回上下文

各部件一句话职责:

部件干什么文件
defineTool把 schema/描述/实现打包成一份「工具资产」src/tools/define-tool.ts
TOOL_DEFINITIONS全部 65 个工具资产的静态清单src/tools/tool-definitions.ts
模型族预设五张「这个模型族装哪些工具」的名单src/tools/manager.ts
注册表 + 换装锁进程内当前生效的工具集,切换时上锁src/tools/manager.ts
执行上下文快照把注册表 + 工作目录 + 权限模式冻结成一个 idsrc/tools/manager.ts
executeTool分派、注入、执行、规整、脱敏、截断src/tools/manager.ts
截断三件套LIMITS / 溢出文件 / 兜底 clampsrc/tools/impl/truncation.ts
工具实现真正干活的函数(bash、edit、read、grep……)src/tools/impl/

3. 第一层:一个工具是怎么被「声明」出来的

3.1 三份资产,一次打包

Letta Code 把工具的描述参数 schema实现拆成三种文件,再用一个函数粘起来:

资产存在哪形态
描述src/tools/descriptions/Bash.mdMarkdown 文本,构建时按字符串 import
参数 schemasrc/tools/schemas/Bash.jsonJSON Schema draft-07
实现src/tools/impl/bash.tsasync (args) => 结果对象

.md 能被 import 是因为有一条模块声明:src/types/md.d.ts*.md 声明成 string 默认导出。 所以工具描述是纯文本资产,改提示词不用碰代码。

粘合发生在 defineTool(src/tools/define-tool.ts:21):

// src/tools/define-tool.ts:21 defineTool —— 只保留骨架
return {
schema: input.schema,
description: input.description,
modelForm: input.modelForm ?? functionToolForm({ ... }),
impl: (args) => input.impl(args as TArgs),
};

它做的唯一「聪明事」是:如果没显式给 modelForm,就用 functionToolForm 生成一个默认的 函数式工具形态。

注册表本体就是一个对象字面量,65 项(src/tools/tool-definitions.ts:176toolDefinitions, :510 导出为 TOOL_DEFINITIONS)。工具名的类型 ToolName 直接由这个对象的 key 推导(:508)—— 加工具即扩类型,漏配某个族的名单会在编译期就报出来。

3.2 modelForm:留给非 JSON-Schema 工具的口子

模型面前的工具不一定都是「函数 + JSON Schema」。src/tools/model-facing-tool.ts:30ModelFacingToolForm 是个联合类型:

形态长什么样用途
function{ type, description, parameters }常规 JSON Schema 工具
custom{ type, description, format, functionFallback }自由文本 / lark 或 regex 文法的工具

custom 形态额外带一个 functionFallback:后端只认函数式工具时,serializeFunctionOnlyToolPayload (src/tools/model-facing-tool.ts:49)会退化成 fallback 里的 description + parameters。

诚实说明: 到本 commit 为止,tool-definitions.ts 里没有任何工具显式传 modelForm,所以 65 个 工具全部走 function 形态;custom 分支目前只有类型和序列化路径在,没有生产使用者。

3.3 描述不是静态的

同一个 Bash 工具,在 Windows 上给模型看的描述会多一段安全须知。这类「装配期改描述」有三处:

改什么触发条件位置
Bash 描述追加 Windows 段process.platform === "win32"src/tools/tool-definitions.ts:160 buildBashDescriptionForPlatform
memory 类描述改写 MemFS 措辞后端 capabilities.localMemfssrc/tools/manager.ts:85 resolveBackendSpecificToolDescription
Task 描述里插入已发现的子 agent存在子 agent 配置src/tools/manager.ts:1628 injectSubagentsIntoTaskDescription

最后一条是本仓库里很典型的做法:子 agent 不是新工具,而是被写进 Task 工具描述的 ## Available Agents 小节(插在 ## Usage 之前)。细节归 05-self-extension.md


4. 第二层:按模型族「换装」

4.1 为什么同一能力要三套 schema

Letta Code 想让 Claude / GPT-Codex / Gemini 三族模型都在它们各自训练时熟悉的工具界面下工作: Claude 熟悉 Read/Edit/Bash,Codex 熟悉 read_file/apply_patch/exec_command, Gemini 熟悉 read_file/replace/run_shell_command

这不是审美问题——工具名和参数名会直接影响模型「敢不敢用、用得对不对」。所以做法是:

一份实现,三套 schema + 三套描述 + 三套名字。

配套的还有三份源版系统提示词,在 src/agent/prompt-assets.ts:70SYSTEM_PROMPTS 里注册为 source-claude / source-codex / source-gemini(:91:97:103),用途标注是 "for benchmarking"。选人格为 codex 时走 source-codex,其余默认走 source-claude (src/agent/personality-presets.ts:300-308 getPersonalityContent)。

提示词里的措辞和工具名是对得上的:source_codex.md:76 让模型用 apply_patch 改代码、 :93 提到 exec_command 会话;source_gemini.md:64 点名 replace/write_file/run_shell_command; source_claude.md:98 让模型优先用 Read/Edit/Write 而不是 cat/sed

一处不吻合值得知道: source_gemini.md 里还提到 grep_searchsave_memory 这些名字,而 Letta Code 的 Gemini 工具集提供的是 search_file_contentmemory。这三份提示词是照抄上游原版的,并没有 逐词对齐到本仓库的工具名。

4.2 五张名单

预设都是 ToolName[] 常量数组,定义在 src/tools/manager.ts:

常量行号规模风格代表工具
ANTHROPIC_DEFAULT_TOOLS:37118Claude 风,PascalCaseBash Read Edit Write Task Skill
OPENAI_DEFAULT_TOOLS:3936Codex 风,snake_caseexec_command write_stdin apply_patch
GEMINI_DEFAULT_TOOLS:40415Gemini 风,snake_caserun_shell_command replace write_file_gemini
OPENAI_PASCAL_TOOLS:42215Codex 风,PascalCase 别名ApplyPatch UpdatePlan ViewImage
GEMINI_PASCAL_TOOLS:44116Gemini 风,PascalCase 别名RunShellCommand Replace WriteFileGemini

PascalCase 那两套是后加的,注释写明动机:与 Skill 工具的命名保持一致(:421)。 toolset-types.ts:1ToolsetName 因此有六个取值:codex / codex_snake / default / gemini / gemini_snake / none,由 getToolNamesForToolset(src/tools/toolset.ts:192)映射到上表。

两个容易踩的观察:

  • ANTHROPIC_DEFAULT_TOOLS没有 GrepGlob(MultiEditLS 被注释掉了,见 manager.ts:380-381)。默认 Claude 工具集下,搜索靠 Bash + ripgrep。想要就得靠 include 或 allowlist 显式带进来——src/tools/toolset.ts:100resolveAllowlistedToolNames 注释直接举了 ["Read","LS","Glob","Grep"] 这个例子。
  • 三族名单里都塞了几个 Letta Code 自己的工具(SkillTaskSetWorkingDirectory、worktree 两件套), 它们不属于任何上游 CLI。

4.3 内部名 vs 服务器名:一套双向映射

同一个能力有多个实现文件,但同一时刻只有一套工具集生效,所以它们可以映射到同一个「模型可见名」。 这就是 TOOL_NAME_MAPPINGS(src/tools/manager.ts:158)。

内部名(注册表 key) 服务器名(模型看到的)
───────────────────── ──────────────────────
glob_gemini ──────> glob
write_file_gemini ──────> write_file
read_file_gemini ──────> read_file
run_shell_command ──────> run_shell_command (同名)
Task ──────> Agent (对齐 Claude Code)
其余 ──────> 原样

getServerToolName() 正向,发工具清单时用
getInternalToolName() 反向,收到 tool_call 时用

getServerToolName(:178)是查表,getInternalToolName(:186)是反查——遍历映射找 value 命中的 key,找不到就认为服务器名即内部名。

Task → Agent 这条是刻意的:内部实现名保持 Task 以兼容已有的 agent 状态,模型面前叫 Agent 以对齐 Claude Code(注释见 :169-172)。

执行时还多一层保险:resolveInternalToolName(:717)先看当前注册表里有没有同名工具,有就直接用; 没有才去查别名映射;都没有返回 undefined(于是报 "Tool not found" 并列出可用工具)。

4.4 怎么判断「这是哪个族的模型」

modelIdentifier ──> getModelInfo() ──> info.handle 前缀匹配

└─ 取不到 info 时,直接拿 modelIdentifier 当 handle 匹配
判定匹配的 handle 前缀位置
isOpenAIModelopenai/ openai-codex/ <OPENAI_CODEX_PROVIDER_NAME>/ chatgpt_oauth/manager.ts:1591
isGeminiModelgoogle/ google_ai/manager.ts:1611

上层还有一条更靠谱的路径:deriveToolsetFromModel(src/tools/toolset.ts:130)优先看 providerType(chatgpt_oauth / openai-codex 直接判 codex),拿不到才回落到 isOpenAIModel。 判定结果通过 options.resolvedToolset 传下来,resolveBaseToolNamesForModel优先采信它 而不是自己再推一遍(manager.ts:1427-1433)。

一个反直觉的现状: isGeminiModel 存在、被导出,但 resolveBaseToolNamesForModel 的 else 分支 里写着注释「Temporary rollback」——Gemini 模型走 ANTHROPIC_DEFAULT_TOOLS,不走 Gemini 工具集 (manager.ts:1435-1438)。Gemini 工具集要靠显式指定 toolset(toolset.ts:192gemini / gemini_snake 分支)才会被装上。

4.5 装配流水线与「换装锁」

两个入口,同一套锁:

入口干什么位置
loadTools(model)按模型族选预设,重建整个注册表manager.ts:1575
loadSpecificTools(names)按给定名字重建(恢复 agent 时用)manager.ts:1549

两者都是 acquireSwitchLock() → 异步构建 → replaceRegistry()finally releaseSwitchLock()

锁的形状很朴素但有讲究:

acquireSwitchLock() refCount++ ;第一次进入时创建一个 pending Promise

├─ 换装 A 进行中 ──┐
└─ 换装 B 又来了 ──┤ refCount 变 2,Promise 还是同一个

releaseSwitchLock() refCount-- ;只有归零才 resolve

waitForToolsetReady() ──── await 那个 Promise;没有换装时立刻返回

引用:acquireSwitchLock manager.ts:658releaseSwitchLock :674waitForToolsetReady :694、 同步版 isToolsetSwitchInProgress :705

为什么要引用计数: 用户连按两次 /model 会产生重叠的两次换装。若不计数,第一次完成就 resolve, 等待方会拿到一个「正在被第二次换装改写」的中间态注册表。

真正替换注册表的 replaceRegistry(:1334)刻意写成同步、无 await 的 clear + set 循环,注释里 点明了原因:中间不能有 yield,否则别人会读到半个注册表。

构建函数有两个:

  • buildRegistryForModel(:1467)——走预设名单,逐个补描述、算 modelForm、绑 impl;任何一个 工具缺实现就 throw,错误信息带 "could not be loaded from bundled assets"。
  • buildSpecificToolRegistry(:1363)——走给定名字,缺定义只 console.warn 跳过,缺实现才 throw

两者最后都调 maybeApplyLspReadOverride(:1342):设了 LETTA_ENABLE_LSP 且注册表里有 Read 时, 用 ReadLSP 的 schema/描述/实现顶掉 Read 这个 key——模型仍然看到 Read,底下换了实现。

4.6 --tools:一把粗暴但有用的过滤器

src/tools/filter.ts 是个单例(挂在 Symbol.for("@letta/toolFilter") 上,防止 bundler 产生副本), 只有三种状态:

enabledTools含义
null不过滤,全开(默认)
[]一个工具都不给
["Read","Bash"]只给这些

它有个副作用值得留意:resolveBaseToolNamesForModel 里,只要 toolFilter.isActive() 为真,基名单 就直接变成全部 65 个工具 TOOL_NAMES,再由过滤器筛(manager.ts:1439-1441)。也就是说 --tools 不是「在预设里筛」,而是「在全集里筛」。


5. 第三层:执行期

5.1 先冻结,再执行:执行上下文快照

它解决的小问题: 后端把 tool_call 发回来的时刻,可能距离「发出工具清单」已经过了几十秒; 这中间用户可能切了模型、改了工作目录、装了新的 mod。如果执行时才去读全局注册表,就会用错工具。

做法: 发清单之前把当时的一切冻结成一份快照,给它一个 id,执行时凭 id 取回。

快照里装了什么(ToolExecutionContextSnapshot,manager.ts:529):

字段装的东西
toolRegistry当时的内建工具注册表(拷贝)
externalTools过滤后的外部工具
externalExecutor外部工具的执行回调
modContext/modEvents/modPermissions/modToolsmod 侧的四件套
workingDirectory当时的工作目录
runtimeContextagentId / conversationId / skills 目录等
permissionModeState当时的权限模式(可变对象)

id 的形状是 ctx-<时间戳>-<自增计数>,存在一个上限 4096 的 Map 里,超了就淘汰最老的 (saveExecutionContext,manager.ts:559)。

三个入口都收敛到同一个 capturePreparedToolExecutionContext(:940):

入口场景行号
captureToolExecutionContext同步抓当前全局注册表:1019
prepareCurrentToolExecutionContextwaitForToolsetReady,再重建当前名单:1041
prepareToolExecutionContextForModel按模型族现建一套:1110

返回值同时给出 contextIdclientTools——同一份快照序列化出去、同一份快照执行回来, 这是这套设计的核心。序列化由 serializeClientTools(src/tools/client-tool-serialization.ts:19) 完成,顺序是「内建 → 外部 → mod」,同名时 mod 工具遮蔽外部工具(会打一条 debug 日志,:30-38)。

快照还能被原地改:updateToolExecutionContextWorkingDirectory(:622)让 SetWorkingDirectory / worktree 工具改掉本回合后续工具的工作目录;getExecutionContextPermissionModeState(:639)返回可变的 权限模式对象,供批准环路写回(见 03-permissions.md)。用完由 releaseToolExecutionContext(:649)删掉。

5.2 分派:三类工具,三条路

executeToolInner(manager.ts:2379)拿到 toolContextId 后按优先级分派:

executeToolInner(name, args, options)

├─ toolContextId 给了但查不到 ──> 直接返回 error

├─ ① activeModTools.has(name) ──> emitToolStart → mod 权限检查 → executeModTool

├─ ② activeExternalTools.has(name) ──> emitToolStart → 权限检查 → executeExternalTool

└─ ③ 内建:resolveInternalToolName → 取 tool → 注入参数 → tool.fn(...)

runWithRuntimeContext 包住

注意顺序:mod 工具优先于外部工具,外部工具优先于内建工具。三条路都先发 tool_start 事件, mod 的 handler 可以直接返回结果短路掉真正的执行(:2436-2450)。

5.3 隐形参数注入:schema 里没有,但实现要用

模型只能填 schema 里声明的字段。但实现需要 abort 信号、流式回调、密钥环境变量这些东西。做法是 执行前往 args 里塞额外键,而不改 schema:

注入的键注给谁行号(manager.ts)
signalSTREAMING_SHELL_TOOLS 里的工具、Task、worktree 工具:2603 :2630 :2656
onOutputshell 类工具(回调里先脱敏再 stripAnsi):2606-2616
secretEnvshell 类工具(命令里引用了 $SECRET 才注入):2617-2619
parentScopeshell 类、TaskSkill:2620 :2633 :2644
toolCallIdTaskSkill:2627 :2641
_executionContextIdworktree 两件套:2653-2655

STREAMING_SHELL_TOOLS(:137)是一张跨三族的名单——Bash BashOutput TaskOutput exec_command write_stdin shell_command ShellCommand shell Shell run_shell_command RunShellCommand Monitor。这类「跨族名单」在本仓库出现多次(还有 FILE_MUTATING_TOOLS :153),是「一份能力三套皮」 的必然税:每加一族别名,这些集合都得同步加

Skill 的注入有句注释值得抄:在 listener/desktop 模式下,一个进程里多个 agent 作用域会重叠, 依赖全局 agent 上下文是不安全的,所以显式注入(:2638-2640)。

5.4 hooks:执行前后的两道用户扩展点

  • runPreToolUseHooks——可以阻止执行(返回 blocked,工具直接以 error 收场),也可以 改写入参(preHookResult.updatedInput 会被 merge 进 args,:2580-2585)。
  • collectPostToolHookFeedback——成功路径和异常路径各调一次,反馈文本被追加到 toolReturn 末尾 (appendHookFeedbackToToolReturn :2036)。

5.5 结果规整:flattenToolResponse

各个工具的返回形状五花八门(有的 {message},有的 {content:[...]},有的 {files:[]})。 flattenToolResponse(manager.ts:1845)是一串按优先级的 if,把它们压成 string | Array<TextContent|ImageContent>:

输入形状输出
null / undefined""
多模态数组(text/image 块)原样透传(图片不被压成字符串)
{message} 且只有这一个 keymessage 本身
{message, ...其它}整个对象的 JSON
{content: string} / {output}那个字符串
{files: [...]}Found N files\n<逐行路径>
{todos: [...]}Updated N todos
其它JSON.stringify(result)

状态则单独取:recordResult?.status === "error" 才算 error,否则 success(:2690)。这就是 src/tools/README.md 里那条「返回 toolReturn / status」契约在管理器一侧的落点。

5.6 外壳 executeTool:给 mod 最后一次改结果的机会

executeTool(manager.ts:2850)只是 executeToolInner 的包装,但多做一件事:执行完发 tool_end 事件,mod 的 handler 可以替换模型看到的结果(第一个返回的 handler 胜出)。

两条限制写在注释里(:2838-2843):只对字符串结果生效(多模态原样透传),且投递受能力开关 events.tools 门控。

5.7 AbortSignal 一路穿到子进程

src/tools/README.md 的契约要求:「如果你 spawn 了子进程,把 signal 接上去,让 Esc 能干净地杀掉它」。 真正兑现这条的是 startShellProcess(src/tools/impl/shell-runner.ts:340):

options.signal
│ addEventListener("abort", abortHandler, {once:true}) :485
│ (若注册时已 aborted,立刻手动触发一次) :486-488
v
abortHandler ──> terminateProcess() :408 / :392

├─ Windows:直接 SIGKILL
└─ 其余:SIGTERM,FORCE_KILL_GRACE_MS 后没退再 SIGKILL
:392-406
close 事件时 :443-460
├─ timedOut ──> reject(buildTimeoutError)
├─ signal.aborted ──> reject(buildAbortError)
└─ 正常 ──> resolve({stdout, stderr, exitCode})

杀进程用的是 killChildProcessTree(:160):Unix 上 process.kill(-pid) 杀整个进程组, Windows 上调 taskkill.exe,失败再回落 SIGKILL杀进程组而不是杀进程,是让 npm run dev 这类会 fork 子进程的命令能被真正终止。

抛出的 abort 错误最终在 manager.ts:2778-2793 被识别(AbortError / "The operation was aborted" / code === "ABORT_ERR" 三种形态都认),统一换成常量 INTERRUPTED_BY_USER

5.8 串行还是并行?

src/tools/README.md 说「我们串行执行工具以避免竞态」。实际实现更细一档,在 src/agent/approval-execution.ts:

类别规则位置
并行安全只读类工具(Read/Grep/Glob/各族别名)、Task 可并发:43 PARALLEL_SAFE_TOOLS
单文件写file_path 归一化后的绝对路径做资源键:98 FILE_PATH_TOOLS
全局锁shell 类、memoryapply_patch:114 GLOBAL_LOCK_TOOLS
未知工具保守起见也给全局锁:164

判定入口是 isParallelSafe(:87)和 getResourceKey(:143)。调度细节属于回合层,见 01-stateful-turn.md


6. 同一能力的三套皮:对照读

6.1 对照表

能力Claude 风(内部名)Codex 风Gemini 风Gemini 侧是薄包装吗
读文件Read / read.tsread_file / read-file-codex.tsread_file_gemini / read-file-gemini.ts是(改 offset 基准)
写文件Write / write.tsapply_patch 里的 Add Filewrite_file_gemini / write-file-gemini.ts是(直接透传)
改文件Edit / edit.tsapply_patch / apply-patch.tsreplace / replace-gemini.ts是(换参数名)
找文件Glob / glob.tslist_dir / list-dir-codex.tsglob_gemini / glob-gemini.ts
搜内容Grep / grep.tsgrep_files / grep-files.tssearch_file_content / search-file-content-gemini.ts
跑命令Bash / bash.tsshell exec_commandrun_shell_command / run-shell-command-gemini.ts

6.2 Gemini 侧:全是几十行的适配器

replace-gemini.ts 全文只有 34 行,核心就是参数换名 + 调 edit:

// src/tools/impl/replace-gemini.ts:15 replace —— 节选
const lettaArgs = {
file_path: args.file_path,
old_string: args.old_string,
new_string: args.new_string,
replace_all: !!(args.expected_replacements && args.expected_replacements > 1),
expected_replacements: args.expected_replacements,
};
const result = await edit(lettaArgs);
return { message: result.message };

其余同理:glob_gemini(glob-gemini.ts:16)把 dir_path 换成 path 再调 glob; search_file_content(search-file-content-gemini.ts:14)把 include 换成 glob 并强制 output_mode: "content";write_file_gemini(write-file-gemini.ts:13)几乎是直接转发; read_file_gemini(read-file-gemini.ts:29)做了两件事——offset 加一(注释说明 Gemini 用 0 基、 Letta 用 1 基),以及把多模态结果里的 image 块滤掉、只留文本。

所以「三套 schema」的真实代价并不高: 大部分是 schema/描述/名字的三份副本,加一层几十行的参数适配。

6.3 Codex 侧:真的是另一套语义

Codex 风格不是换名字那么简单,它有三处行为不同的实现:

read_file 多一个 mode: "indentation" 对比三份 schema 就能看出来:

字段Read.jsonReadFileGemini.jsonReadFileCodex.json
file_path
offset有(明确 0 基)有(明确必须 ≥1)
limit
modeslice / indentation
indentation对象:锚点行、父级层数、是否带兄弟块/头注释

indentation 模式是「围绕锚点行按缩进展开一个代码块」的读法,由 read_file (src/tools/impl/read-file-codex.ts:39)自己实现,不复用 read

apply_patch 是一门小语言。 src/tools/impl/apply-patch.ts:39-47 定义了一整套标记:*** Begin Patch / *** Add File: / *** Delete File: / *** Update File: / *** Move to: / @@ / *** End of Fileapply_patch(:58)先 parsePatch 成 add/update/delete 三种 FileOperation,再逐个落盘, 支持一次补丁里改多个文件、还能顺带重命名(toPath 分支,:104-120)。

对比 Edit(src/tools/impl/edit.ts:120)的路子完全不同——它是单文件、字符串精确匹配, 但为「模型给的旧串对不上」做了大量容错:

容错做法位置
行尾差异old/new/文件内容统一 \r\n → \n:128-152
过度转义\\n 之类还原成真换行,再试一次匹配(只还原 old,不动 new):88 unescapeOverEscapedString
匹配失败的诊断提示是首尾空白差异 / 直弯引号差异 / 换行缩进差异 / 快照过期:42 buildNotFoundError
出现次数对不上expected_replacements 不符就报「找到 N 处」并要求更精确的锚点:174-181

MultiEdit(multi-edit.ts:26)则是「在内存里顺序应用多条 edit,最后一次性写盘」——任何一条失败 就整体抛错,天然是全有或全无。

exec_command 是会话式而不是一次性。 Bash 的模型是「跑完返回」;exec_command(src/tools/impl/exec-command.ts:566)的模型是 「跑一会儿(yield_time_ms),先把这段输出还给你,进程还活着就给你一个 session_id」:

exec_command(cmd, yield_time_ms)

├─ 起 session,等 yield 窗口 :574-585

├─ 进程还在跑 ──> 返回 output + sessionId
│ │
│ write_stdin(session_id, chars) :607
│ │ (chars 非空但 tty=false 会报错:stdin 已关)
│ └─> 再等一个窗口,继续取增量输出

└─ 已结束 ──> 返回 output,releaseExecSession

会话状态 ExecSession(:77)记着 readOffset,所以每次 write_stdin 只取新增的那段输出。 yield 窗口有上下限夹紧(:131-151):普通调用 250ms–30s,空 chars 的纯轮询 5s–300s。 ExecCommandArgs 的注释(:51-55)还明说:上游 Codex 在这里还有沙箱提权字段,Letta Code 故意不放进模型可见 schema,因为它的批准体系是另一套(见 03-permissions.md)。

shell(src/tools/impl/shell.ts:108)又是第三种形状:参数是字符串数组(execvp 语义, 典型是 ["bash","-lc","..."]),返回 {output, stdout[], stderr[]}

三者最终都汇到同一个 startShellProcess(shell-runner.ts:340)。Bash 额外走 spawnCommand(bash.ts:90),里面有一段真实世界的补丁:macOS 上优先用 /bin/zsh 而不是 bash, 注释写明原因是 bash 3.2 的 heredoc 遇到撇号会出问题(:109-110)。


7. 输出治理:回上下文之前的四道闸

要解决的小问题: 一次 rgcat 就能吐出几十万字符;这些字符会永久占住上下文预算。

7.1 四道闸的顺序

tool.fn() 的原始返回

① 工具自己的限额:truncateByChars / truncateByLines / truncateArray
│ (超限时顺手写一份完整输出到溢出文件)
v
② 规整:flattenToolResponse → string | 多模态数组

v
③ 脱敏:scrubSecretsFromString + stripAnsi(仅 shell 类工具)

v
④ 兜底:clampToolReturnContent(32K 硬顶)

v
toolReturn ──> 上下文

└─(另一条支线)clipToolReturn ──> 只给终端 UI 显示,不影响模型

对应位置:③ 在 manager.ts:2697-2725,④ 在 :2727,clipToolReturn:1669 (调用方是 ToolCallMessageRich.tsx:428BashCommandMessage.tsx:96,纯显示用)。

7.2 限额表

全部集中在 src/tools/impl/truncation.ts:11LIMITS:

常量管什么
BASH_OUTPUT_CHARS30,000bash/shell 输出
TASK_OUTPUT_CHARS30,000子 agent 输出
BASH_NOTIFICATION_CHARS10,000后台命令完成通知(更紧)
READ_MAX_LINES2,000单次读文件的行数
READ_MAX_CHARS_PER_LINE2,000单行字符数
READ_OUTPUT_CHARS30,000读文件的总字符数
GREP_OUTPUT_CHARS10,000grep 结果
GLOB_MAX_FILES2,000文件路径条数
LS_MAX_ENTRIES1,000目录项条数
TOOL_RETURN_MAX_CHARS32,000任何工具返回的兜底硬顶

背景通知比常规返回更紧,注释解释得很直白:后台命令完成通知是未经请求就塞进上下文的, 所以预算更少(truncation.ts:15-18)。

TOOL_RETURN_MAX_CHARS 取 32K 而不是 30K,也是刻意的:让已经被工具自己截到 30K + 截断提示的输出 原样通过,不被二次截断(truncation.ts:30-34)。

7.3 三种截断函数,各管一种形状

函数适用中间截断时的标记行号
truncateByChars一坨文本... [N characters omitted] ...:56
truncateByLines按行的输出... [N lines omitted] ...:122
truncateArray路径/目录项列表无法在数组中间插标记,只在末尾说明省略了多少:227

默认是中间截断(留头留尾),开关是 OVERFLOW_CONFIG.MIDDLE_TRUNCATE。为什么留尾很重要: 编译错误、测试失败摘要通常在输出末尾,只留头等于把最有用的部分扔了。

7.4 溢出文件:截断但不丢

超限时把完整内容写到磁盘,并在截断提示里给出路径:

~/.letta/projects/<把工作目录路径里的 / : 空格 换成 _>/agent-tools/<toolname>-<uuid>.txt

实现:getOverflowDirectory(src/tools/impl/overflow.ts:32)、writeOverflowFile(:82)。 两个细节:

  • 写盘前先脱敏。注释解释了原因:溢出文件是在管理器那一层脱敏之前抓的原始输出,不擦就会 把明文密钥落到磁盘上(overflow.ts:70-80)。
  • cleanupOldOverflowFiles(:111),默认清理 24 小时前的文件。
  • 全套可以用 LETTA_TOOL_OVERFLOW_TO_FILE=false 关掉(OVERFLOW_CONFIG,:17)。

7.5 密钥:不进命令行,也不出输出

src/tools/secret-substitution.ts 只有两个函数,但配合方式挺巧:

模型写: curl -H "Authorization: Bearer $API_KEY" ...

extractSecretEnvFromCommand() 扫描 /\$([A-Z_][A-Z0-9_]*)/g :19
│ 在密钥库里查到 API_KEY 才收进 env
v
作为 secretEnv 注入 → spawn 时并进 env → 由 shell 自己展开

│ (密钥值从未出现在命令字符串里)
v
输出回来: scrubSecretsFromString() 值 → "API_KEY=<REDACTED>" :54

两处实现细节:

  • 替换时先长后短排序(:60-62),避免短密钥值先命中、把长值切成两半。
  • 只擦「本次调用能访问到的那些密钥」——invocationSecrets 是按这条命令扫出来的,不是整个密钥库 (manager.ts:2594-2602 的注释点明了这个取舍)。

脱敏点不止一处:前台输出、流式回调、后台进程输出、溢出文件、exec_command 的格式化输出 (exec-command.ts:603 :655)各自都调了一次。


8. 外部工具:SDK / MCP 的接入口

内建工具之外,还有一类工具由外部执行器负责跑(典型是 MCP 进程)。接入口只有三个函数:

函数干什么行号(manager.ts)
registerExternalTools注册定义(按 registrationKey ?? name 入表):802
setExternalToolExecutor设一个全局执行回调:822
executeExternalTool调用回调,把结果规整成 ToolExecutionResult:872

ExternalToolDefinition(:745)里有几个字段决定「什么时候看得见这个工具」:

字段作用
scopeId有 scope 的工具默认隐藏,只在本回合被选中时才露出
runtime绑定到某个 agentId / conversationId,只在那个运行时可见
executor工具自带的执行器,优先于全局执行器(:2511)
registrationKey内部注册键,模型面前仍用 name

对应的过滤链在 capturePreparedToolExecutionContext 里串成一串:先按 runtime 过滤,再按 scopeIds, 再按 client allowlist(manager.ts:969-980)。

外部工具的结果同样要过闸:executeExternalTool 里直接调了 clampToolReturnContent(:897)—— 因为 MCP 工具不受本仓库的 LIMITS 约束,兜底 clamp 就是为它们这类工具准备的。


9. 契约与边界

9.1 工具契约(src/tools/README.md)

签名: (args, opts?) => Promise<{
toolReturn: string;
status: "success" | "error";
stdout?: string[];
stderr?: string[];
}>

三条硬要求:

  1. spawn 子进程的工具必须接 opts.signal,并把 abort 错误归一成 toolReturn: "User interrupted tool execution", status: "error"
  2. 不要在一个工具里跑多个子进程,除非同时暴露取消钩子——因为执行是串行的。
  3. 不要 console.error,UI 只显示返回值(在 Ink TUI 里乱打日志会把界面搞花)。

实际实现比契约宽一点:很多工具(edit/write/read)是抛异常而不是返回 status: "error", 由 executeToolInner 的 catch 兜住并转成 error 结果(manager.ts:2776-2831)。bash 则是两种都用。

9.2 已知边界

边界说明
Gemini 工具集默认不生效resolveBaseToolNamesForModel 有 "Temporary rollback" 注释(:1435-1438)
custom 工具形态无生产使用者类型和序列化路径都在,但 65 个工具全是 function 形态
跨族名单要手工同步STREAMING_SHELL_TOOLS / FILE_MUTATING_TOOLS / PARALLEL_SAFE_TOOLS 都是硬编码名单
快照上限 4096超了淘汰最老的;极长会话里理论上可能出现 "context not found"(:565-571)
提示词与工具名未逐词对齐三份 source_*.md 是上游原版照抄,措辞里有本仓库没有的工具名
密钥擦除是值匹配只能擦掉命令里显式引用过的密钥的值,模型自己拼出来的等价字符串擦不掉

10. 值得抄走的三个做法

  1. 「发清单」和「执行」用同一份冻结快照。 不是加锁保护全局状态,而是让状态不可变 + 带 idcapturePreparedToolExecutionContext(manager.ts:940)同时吐出 clientToolscontextId, 从根上消除了「发出去的清单和执行时的注册表不一致」这类竞态。

  2. 截断永远配一份完整落盘。 模型看到 30K + 一行「完整输出在 <path>」,需要时可以自己去读。 这比单纯截断少一次「重跑命令」的往返(truncation.ts:100-108)。

  3. 密钥靠 shell 展开,而不是字符串替换。 extractSecretEnvFromCommand (secret-substitution.ts:24)只把密钥放进子进程 env,命令字符串里始终是 $API_KEY 字面量—— 于是即使命令被日志、审批 UI、溢出文件记录下来,也不含明文。


11. 代码地图

主题文件符号
工具资产打包src/tools/define-tool.tsdefineTool ToolAssets
模型面向的工具形态src/tools/model-facing-tool.tsModelFacingToolForm functionToolForm serializeFunctionOnlyToolPayload
静态工具清单src/tools/tool-definitions.tsTOOL_DEFINITIONS ToolName buildBashDescriptionForPlatform
名字映射src/tools/manager.tsTOOL_NAME_MAPPINGS getServerToolName getInternalToolName resolveInternalToolName
模型族预设src/tools/manager.tsANTHROPIC_DEFAULT_TOOLS OPENAI_DEFAULT_TOOLS GEMINI_DEFAULT_TOOLS OPENAI_PASCAL_TOOLS GEMINI_PASCAL_TOOLS
模型族判定src/tools/manager.ts / src/tools/toolset.tsisOpenAIModel isGeminiModel deriveToolsetFromModel getToolNamesForToolset
注册表装配src/tools/manager.tsbuildRegistryForModel buildSpecificToolRegistry replaceRegistry maybeApplyLspReadOverride
换装与锁src/tools/manager.tsloadTools loadSpecificTools acquireSwitchLock releaseSwitchLock waitForToolsetReady
工具过滤(--tools)src/tools/filter.tstoolFilter ToolFilterManager
执行上下文快照src/tools/manager.tscapturePreparedToolExecutionContext captureToolExecutionContext prepareCurrentToolExecutionContext getExecutionContextById releaseToolExecutionContext
client_tools 序列化src/tools/client-tool-serialization.tsserializeClientTools
执行主体src/tools/manager.tsexecuteTool executeToolInner flattenToolResponse ToolExecutionResult
外部工具src/tools/manager.tsregisterExternalTools setExternalToolExecutor executeExternalTool ExternalToolDefinition
shell 执行内核src/tools/impl/shell-runner.tsstartShellProcess spawnWithLauncher killChildProcessTree
Claude 风实现src/tools/impl/bash edit multi_edit write read grep glob
Codex 风实现src/tools/impl/shell exec_command write_stdin apply_patch list_dir read_file
Gemini 风适配器src/tools/impl/glob_gemini replace write_file_gemini search_file_content read_file_gemini
截断与限额src/tools/impl/truncation.tsLIMITS truncateByChars truncateByLines truncateArray
溢出文件src/tools/impl/overflow.tsOVERFLOW_CONFIG writeOverflowFile getOverflowDirectory cleanupOldOverflowFiles
兜底夹紧src/tools/impl/tool-return-clamp.tsclampToolReturnContent
密钥处理src/tools/secret-substitution.tsextractSecretEnvFromCommand scrubSecretsFromString
UI 侧显示裁剪src/tools/manager.tsclipToolReturn
并行/串行调度src/agent/approval-execution.tsPARALLEL_SAFE_TOOLS GLOBAL_LOCK_TOOLS isParallelSafe getResourceKey
三族源版提示词src/agent/prompts/source_claude.md source_codex.md source_gemini.md