跳到主要内容

数据截至 (上游 commit 25aa2735dabb)

可插拔后端:文件与执行到底落在哪

30 秒导读: Deep Agents 让 agent 用 read_file / write_file 这样的工具操作"文件"。 但这些文件可以根本不在磁盘上——可能在 LangGraph 的 state 里、在跨线程的 store 里、 在一台远程沙箱机器上。这一层抽象就叫 backend。本章只讲存储与执行这条轴: 契约长什么样、六种内置实现各自的取舍、路由怎么拼、沙箱怎么把文件操作翻译成 shell。 调用它的工具层(ls 工具、权限闸门、可见性)是 第 03 章 的事。

引用约定: 本章正文里的 path:line 一律相对包目录 libs/deepagents/deepagents/—— 例如 backends/protocol.py:378 的完整路径是 libs/deepagents/deepagents/backends/protocol.py:378。 以 libs/ 开头的(§8 的 partners 包、§9 的代码地图)是相对克隆根的完整路径。 同一张表或同一段里重复引用同一个文件时,省写成 :行号

术语约定: 上游对"把大内容搬出上下文"这件事有 offload 和 eviction 两个叫法 (execute_with_offload_message_eviction.py 说的是同一件事)。 本书统一:offload 译「卸载」、eviction 译「驱逐」,指的都是"内容写进文件系统, 上下文里只留预览 + 路径"。本章讲沙箱侧的卸载,中间件侧的驱逐见 第 05 章


1. 为什么需要这层抽象

1.1 一个具体的困境

假设你写了一个 agent,它会 write_file("/notes/plan.md", ...)。这句话该落到哪?

答案取决于你在做什么产品:

你在做什么文件应该落到哪为什么
Demo / 多租户 Web 服务对话的 state 里不碰宿主机,随 checkpoint 走,会话结束即散
本地编码 CLI真实磁盘用户就是要你改他仓库里的代码
跨会话的长期记忆数据库(store)下一个 thread 也要读得到
跑不可信代码远程沙箱崩了、被投毒了都不影响宿主机

四类落点的物理性质完全不同:state 是内存字典 + reducer,磁盘是 POSIX,store 是键值表, 沙箱只给你一根 execute(command) 管子。

1.2 收敛点:七个动词

Deep Agents 的做法是:承认它们不同,但强制它们对外暴露同一套七个动词—— ls / read / write / edit / glob / grep / delete(外加批量的 upload_files / download_files)。

工具层只认这七个动词,一行都不用知道文件真实躺在哪。

工具层(第 03 章:read_file / write_file / ls / grep ...)

│ 只调这七个动词

┌──────────────────────────┐
│ BackendProtocol │ ← 本章主角
└────────────┬─────────────┘

┌──────────┬─────────────┼─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
StateBackend Filesystem StoreBackend ContextHub BaseSandbox
(graph state) (真实磁盘) (跨线程持久) (Hub 仓库) (远程,靠 shell)

┌─────────────┴──────────┐
▼ ▼
LangSmithSandbox partners 三方包
(Daytona/Modal/…)

上面这些实现之上还有一层 CompositeBackend,它本身也是一个 backend, 按路径前缀把请求分派给上面任意几个(见 §4)。

别把「动词」和「工具」的数字混起来。 全书出现过 6 / 7 / 8 三个数字,数的不是同一样东西:

数字数的是什么依据
7后端动词,也是 _FS_TOOL_ORDER 里的内置文件工具名middleware/filesystem.py:1339
8模型可能看到的文件工具全集 = 七件套 + execute_ALL_FS_TOOL_NAMES,middleware/filesystem.py:1340
6create_deep_agent docstring 里列的文件操作(漏了 delete)graph.py:291-299

execute 不是后端动词而是另一条协议上的方法(§2.6);deleteexecute 都要后端支持才会 出现在模型面前(§2.4),所以那份 docstring 只列了"必然存在"的六个。


2. BackendProtocol:契约长什么样

这节讲契约本身:它有哪些方法、返回什么、为什么不抛异常。

2.1 一个"故意不严格"的抽象基类

BackendProtocolabc.ABC,但一个 @abstractmethod 都没有 (backends/protocol.py:378,类定义那行还挂了 # noqa: B024 承认这点)。 所有方法的默认实现都是 raise NotImplementedError

这是刻意的:子类只实现自己支持的子集就行,不会因为漏了 delete 就实例化失败。 代价是"能力"变成了运行时才知道的东西——所以框架另配了探测函数,见 §2.4。

2.2 结构化返回类型族

每个动词都有一个专属的结果 dataclass,共同点是都带一个 error: str | None 字段 (下面两张表的"位置"列均相对 backends/protocol.py):

动词返回类型成功时带什么位置
lsLsResultentries: list[FileInfo]:322
readReadResultfile_data: FileData:195
writeWriteResultpath:269
editEditResultpathoccurrences:286
deleteDeleteResultpath:305
grepGrepResultmatches: list[GrepMatch]truncated:335
globGlobResultmatches: list[FileInfo]truncated:360

被这些结果包着的三个数据结构都是 TypedDict:

类型字段说明位置
FileInfopath 必填;is_dir / size / modified_at 可选目录项。只有 path 是硬要求,其余"尽力而为":120
GrepMatchpathline(1-indexed)、text一条命中行:150
FileDatacontentencoding;created_at / modified_at 可选文件本体。encoding"utf-8""base64":178

ReadResult 的分页元数据值得注意:total_lines / start_line / end_line / next_offset 四个字段把"读的窗口在哪、还有多少"一并带回(backends/protocol.py:204-214),构造时还有 一道 __post_init__ 校验窗口字段成对出现(backends/protocol.py:225-230)。这是 0.7.x 给 分页读加的契约,各后端都要遵守。

2.3 关键设计:返回结构体,而不是抛异常

这是整层抽象最值得抄的一条。

问题: 后端的失败是给谁看的?——是给模型看的。read 一个不存在的文件,不是程序 bug, 是模型下一步该纠正的动作。异常穿透到工具层,只会变成一坨 traceback 或者被 except 吞掉。

做法: 失败塞进 result.error,让工具层原样转成 ToolMessage

# 示意,非源码 —— 工具层拿到结果后怎么用
result = backend.read(file_path, offset, limit)
if result.error: # 失败:直接把人话错误还给模型
return ToolMessage(content=f"Error: {result.error}")
text = format_content_with_line_numbers(result.file_data["content"]) # 成功:格式化
return ToolMessage(content=text)

真实的消费点就长这样:_handle_read_resultif read_result.error: content=f"Error: {read_result.error}"(middleware/filesystem.py:1845-1993,嵌在 _create_read_file_tool 里), grep 那边还先过一道 truncate_if_too_long(result.error)(在 _create_grep_tool 内部,middleware/filesystem.py:2460-2570)。 重点看:错误文本是后端写的,工具层一个字都不改 ——这样"文件不存在"在各个后端下措辞一致。

于是各后端的错误串都刻意写成给模型看的句子。最典型的是编辑冲突: perform_string_replacementold_string 出现多次时,返回的不是 ValueError,而是一句"appears N times in file. Use replace_all=True..." (backends/utils.py:502-558)。

2.4 可选能力怎么表达

不抛异常的原则有两个例外——能力缺失仍然走 NotImplementedError,因为那不是模型该修的错。 框架用两个"看类不看实例"的探测函数把它挡在调用之前:

探测什么函数怎么判断位置
后端支不支持 delete_supports_deletetype(backend).delete is not BackendProtocol.delete,即有没有覆写backends/protocol.py:939-954
沙箱的 execute 收不收 timeoutexecute_accepts_timeoutinspect.signature 里有没有 timeout 参数,@lru_cache 缓存backends/protocol.py:917

_supports_delete 的用法是动态摘掉工具:中间件在处理请求时发现后端不支持删除,就不把 delete 工具暴露给模型(_create_delete_tool 内的能力探测,middleware/filesystem.py:2192-2286)——比让模型调一个必然失败的工具干净得多。

execute_accepts_timeout 解决的是版本错配:早期的 partner 包没给 SDK 打下界,可能压根不认 timeout 这个 kwarg,所以调用前先探测,不支持就退化成不带 timeout 的调用 (backends/protocol.py:893-895)。

2.5 同步 / 异步成对,默认用线程兜底

每个动词都有 a 前缀的异步版(als / aread / agrep …),基类的默认实现统统是 asyncio.to_thread(同步版)(如 backends/protocol.py:424-426als)。子类想要真异步就自己覆写。

agrep 是唯一多加了一层保护的:它用 asyncio.wait_for 包了个 ASYNC_GREP_TIMEOUT = 2 * 15 + 5 = 35 秒的上限(backends/protocol.py:23:532-570)。 文档注释诚实地说了这层的局限——超时只约束调用方等多久,并不会真的停掉那个工作线程

2.6 还有一条平行的协议:执行

SandboxBackendProtocol 继承 BackendProtocol,加了两样东西 (backends/protocol.py:840-898):

  • id 属性——沙箱实例的唯一标识。
  • execute(command, *, timeout) -> ExecuteResponse——跑一条 shell 命令。

ExecuteResponse 只有三个字段:output(stdout + stderr 合并)、exit_codetruncated (backends/protocol.py:780-799)。文档里写明这是 "optimized for LLM consumption" —— 不给模型分 stdout/stderr,就一坨文本加一个退出码。


3. 六种内置实现:落点各自的取舍

这节讲具体实现。六个内置后端归成 §1.1 那四类落点(state / 磁盘 / store / 远程), 外加一个只做路由、不存东西的 CompositeBackend(§4)。先看全表,再逐个说各自最有意思的那一点。

后端数据落在哪生命周期支持 execute主要取舍文件
StateBackendLangGraph files channel单个 thread 内,随 checkpoint零外部依赖,但会把文件塞进 checkpointbackends/state.py:37
FilesystemBackend真实磁盘永久最直接;virtual_mode(默认开)给路径护栏backends/filesystem.py:91
StoreBackendLangGraph BaseStore跨 thread 持久真正的长期记忆;要管 namespace 隔离backends/store.py:89
LocalShellBackend真实磁盘 + 本机 shell永久本地 CLI 的全能形态;零隔离backends/local_shell.py:26
ContextHubBackendLangSmith Hub agent repo永久,带 commit文件即版本化的 prompt 资产backends/context_hub.py:97
LangSmithSandbox远程沙箱容器沙箱生命周期真隔离;每次操作一次网络往返backends/langsmith.py:56

3.1 StateBackend:文件就是一个 channel

它要解决的小问题: 让"文件"随对话 checkpoint 一起存,不落任何外部介质。

早期版本让 write 返回一个 files_update 字典,由调用方负责合并进 state——耦合难受。 现在的做法是后端自己去写 channel,调用方完全不用管 (旧版的 WriteResult.files_update 已在 0.7.0 如期移除,backends/protocol.py 里已无此字段)。

关键在两个 LangGraph 内部 config key(backends/state.py:7):

┌─────────────────────────────────────┐
backend.read ───►│ CONFIG_KEY_READ("files", fresh=True)│──► 当前 files 字典
└─────────────────────────────────────┘

│ fresh=True 会先把本超步内
│ 挂起的写经 reducer 应用一遍

┌───────────────┴─────────────────────┐
backend.write ──►│ CONFIG_KEY_SEND([("files", update)])│──► 排队一次 channel write
└─────────────────────────────────────┘
在节点边界提交进 state

两个细节值得记:

  • 读己所写(read-your-writes)。 _read_filesfresh=True (backends/state.py:80-97),所以同一个超步里"先写再读"能看到自己刚写的东西—— 比如一个 code interpreter 在一次 eval 里连发好几个子工具调用。
  • 删除靠 None 标记。 delete 不是"从字典里 pop",而是把每个要删的 key 都 send 一个 None 值,由 files channel 的 reducer 解释成删除 (backends/state.py:249-274)。删目录 = 删 base 这个 key 本身 + 所有 base + "/" 前缀的 key。

_send_files_update 只发变化的那几个文件,不用发全量——因为 files channel 用的是 dict-merge reducer(backends/state.py:98-118)。

3.2 FilesystemBackend:root_dir 到底约不约束

这里有个非常容易误解的点,源码自己也反复强调:root_dir 本身不是安全边界。

行为由 virtual_mode 决定(backends/filesystem.py:139-144 的构造参数,语义说明在类 docstring :130-137_resolve_path :182-242)。0.7.x 起默认翻转为 True——虚拟路径成了默认语义,要宿主机裸路径得显式 opt-out:

virtual_mode绝对路径怎么处理.. / ~结论
True(当前默认)视作以 root_dir 为根的虚拟路径直接 ValueError: Path traversal not allowed有路径护栏,但仍非沙箱
False(仅限可信本地开发)原样使用,/etc/passwd 直接穿透允许,可以逃出 root_dirroot_dir 只影响相对路径解析,无任何防护

virtual_mode=True 的检查是三步:先拒绝含 .. 或以 ~ 开头的路径,再 (self.cwd / vpath).resolve(),最后用 full.relative_to(self.cwd) 确认没跑出根 (_resolve_path 内部,backends/filesystem.py:203-242)。

配套的还有 _display_path:虚拟模式下,报错信息里也不能泄漏真实的 root_dir, 所以先把路径转回虚拟形式再显示(backends/filesystem.py:243-264)。

源码里 virtual_mode首要用途其实不是安全,而是路径语义——给 CompositeBackend 用的:composite 剥掉路由前缀后转发一个 /xxx 形式的路径,虚拟模式让这个路径落到 root_dir 下而不是文件系统真正的根(类 docstring 特意点名,backends/filesystem.py:132-136)。

grep 这块它做了两级实现:先试 ripgrep(_ripgrep_search,backends/filesystem.py:865), 不可用或超时就退回纯 Python 遍历(_python_search,backends/filesystem.py:1119);glob 遍历带 5 秒预算 _DEFAULT_GLOB_TIMEOUT(backends/filesystem.py:53),超了就置 truncated=True 返回半份结果——这正是 GlobResult.truncated 这个字段存在的理由。

3.3 StoreBackend:跨线程持久,难点在 namespace

它把文件存进 LangGraph 的 BaseStore(backends/store.py:89)。store 是个带命名空间的键值表, 所以关键问题变成:这次调用该用哪个 namespace?

答案由 NamespaceFactory 给——一个 Callable[[Runtime], tuple[str, ...]] (backends/store.py:41)。典型写法就是文档里那行:

# 示意,非源码 —— 按用户身份隔离记忆
StoreBackend(namespace=lambda rt: (rt.server_info.user.identity, "filesystem"))

工厂的返回值一律过 _validate_namespace(backends/store.py:48-88):每个分量必须是非空字符串, 且只允许 ^[A-Za-z0-9\-_.@+:~]+$被拒的是 *?[{ 这些——理由写在注释里: 防止在 store 查询里发生通配符注入。多租户场景下这条很要紧。

其余几处工程细节:

  • store 来源两条路。 构造时传了 store 就直接用;没传就在调用时 get_store() 从 graph 执行上下文取,取不到给一句明确的 RuntimeError(_get_store,backends/store.py:121-139)。
  • 搜索要翻页。 _search_store_paginated 以 100 条为一页循环拉完 (backends/store.py:229-304),ls / glob / delete 都建立在它之上。
  • 删除同样是递归前缀删。 拉出全部 key,筛出等于 base 或以 base + "/" 开头的, 再 store.batch([PutOp(ns, key, None) ...])(backends/store.py:547-573)—— 语义和 StateBackend.delete 对齐,只是落点不同。
  • BackendContext 老式工厂签名的遗留已在 0.7 随 backend 工厂一起移除——FilesystemMiddleware 构造期直接拒绝可调用的 backend(middleware/filesystem.py:1673-1679),NamespaceFactory 只收 Runtime

3.4 ContextHubBackendLangSmithSandbox

这两个是"外部系统"方向的代表,各点一句:

ContextHubBackend(backends/context_hub.py:97)把 LangSmith Hub 上的一个 agent repo 当文件系统。它整棵树先拉到本地缓存(_fetch_tree / _load_tree_locked,backends/context_hub.py:120-142),写操作进 _MutationQueue 排队(:88-95),由 _push_batch 把一批改动作为一次 commit push_agent(..., parent_commit=...) 上去,None 值即删除标记;远端冲突(LangSmithConflictError) 最多重试 _MAX_CONFLICT_RETRIES = 3 次,commit hash 从返回 URL 的路径里解析 (_push_batch_extract_commit_hash,backends/context_hub.py:326-376)。也就是说,这个后端的"文件"是带版本历史的 prompt 资产

LangSmithSandbox(backends/langsmith.py:56)是 BaseSandbox 的官方实现, 下一节详说基类。它自己只覆写了两个方法,理由都很实在:

  • write 改走 SDK 的原生 write(),因为基类把内容塞进 shell 命令会撞 ARG_MAX (backends/langsmith.py:141-148)。
  • read 改走 SDK 直接取字节,因为基类把文件内容从 execute() 的 stdout 里捞出来, 大文件会挂或超传输上限(backends/langsmith.py:169-180)。

它还是唯一把 enable_capture_offload = True 打开的内置实现(backends/langsmith.py:61), 理由写在注释里:LangSmith 的沙箱镜像保证有兼容的 POSIX shell 和 coreutils(见 §6)。


4. CompositeBackend:前缀路由与双向重映射

4.1 它解决什么

真实 agent 常常要"混着来":临时草稿放 state(便宜、随会话散),长期记忆放 store(跨会话)。 CompositeBackend 让你用路径前缀表达这件事(backends/composite.py:180-205):

# 示意,非源码
composite = CompositeBackend(
default=StateBackend(),
routes={"/memories/": StoreBackend()},
)
composite.write("/temp.txt", "草稿") # → StateBackend,键 "/temp.txt"
composite.write("/memories/note.md", "长期") # → StoreBackend,键 "/note.md"

重点看第二行:传给 StoreBackend 的键是 /note.md,前缀被剥掉了。 被路由的后端完全不知道自己挂在 /memories/ 下——它只看见自己的根。

4.2 路由怎么选

路由表按前缀长度降序排序(sorted_routes,backends/composite.py:233), 保证 /memories/private/ 能盖过 /memories/。选路逻辑在 _route_for_path (backends/composite.py:147-178),三条规则:

输入路径结果 backend传给它的路径说明
/memories(无尾斜杠,正好是路由根)路由后端/路由根本身
/memories/notes.txt路由后端/notes.txt剥前缀,补回开头的 /
/tmp/a.txt(不匹配任何路由)default/tmp/a.txt原样

4.3 双向重映射:进去要剥,出来要贴

这是本节的核心。路径后端时剥前缀,结果后端时必须把前缀贴回去, 否则模型看到的路径根本回不来。

模型说 /memories/notes.txt

│ ① 剥前缀 _route_for_path

StoreBackend 看到 /notes.txt

│ ② 后端返回结果(路径全是自己视角的)

{ path: "/notes.txt", line: 12, ... }

│ ③ 贴回前缀 _remap_* 系列

模型看到 /memories/notes.txt

三个映射函数各管一类载荷(位置均相对 backends/composite.py):

函数管什么位置
_remap_grep_pathgrep 命中的 path 贴回前缀:36-46
_remap_file_info_pathls / glob 结果里 FileInfo.path 贴回前缀:83-93
_strip_route_from_patternglob 模式本身也要剥前缀:59-82

第三个最容易被忽略:模型给的是 /memories/**/*.md,而 StoreBackend 内部只有 **/*.md_strip_route_from_pattern 把两边都 lstrip("/") 后比一遍,重叠的前缀切掉; 不匹配就原样返回(backends/composite.py:75-82)。缺了这一步,跨路由的 glob 会静默匹配到零个文件。

写操作侧则用另一种方式还原:write / edit / delete 拿到子后端的结果后, 直接把 res.path 改写回调用方给的原始路径(backends/composite.py:659-663:691-699:731-738)。

4.4 三个边界行为

根目录聚合。 ls("/") 会把 default 后端的条目 + 每条路由本身(伪装成一个 is_dir=True 的目录项)合并排序返回(backends/composite.py:282-301)。所以模型在根目录 ls 时能看见 /memories/ 这个"文件夹"。grep(path=None or "/") 同理, 会扇出到所有后端再合并,任一后端报错就整体返回错误——刻意不把错误吞成"部分成功" (backends/composite.py:411-502)。

delete 的降级。 CompositeBackend 永远覆写 delete,所以 §2.4 的 _supports_delete 探测对它一律返回 True,delete 工具永远不会被摘掉。但某条路由背后的后端可能压根没实现 delete——于是它 try/except NotImplementedError,把异常翻译成一句结构化错误 (backends/composite.py:715-738):

_DELETE_UNSUPPORTED_ERROR = "Error: deletion is not supported for '{file_path}'."

backends/composite.py:33又是同一条原则:能力缺失在这里从异常降级成了给模型看的 error 字段。

执行不走路由。 execute 永远交给 default 后端,理由写在 docstring 里: 执行不是路径可路由的行为(backends/composite.py:751-787)。default 不是 SandboxBackendProtocol 时,它才抛 NotImplementedError 作为兜底。

4.5 artifacts_root:中间件往哪儿倒东西

CompositeBackend 还多带一个和路由无关的构造参数:artifacts_root,默认 "/" (backends/composite.py:212,存在 :235)。它是中间件卸载产物的落点前缀—— 中间件拿它拼出两个目录,别的什么都不做:

前缀谁写进去拼装位置
{artifacts_root}/large_tool_results超大工具结果middleware/filesystem.py:1689-1692
{artifacts_root}/conversation_history摘要归档与内联媒体middleware/summarization.py:598-602

两处的取法完全一样:backend.artifacts_root if isinstance(backend, CompositeBackend) else "/"换句话说,只有 CompositeBackend 能改这个前缀,其余后端一律落在根下。 旧的 history_path_prefix 参数已在 0.7 移除——现在再传直接 TypeError,报错文案教你改用 CompositeBackend(artifacts_root=...)(middleware/summarization.py:574-575)。 这些目录里的内容长什么样、谁来读回去,是 第 05 章 的事。


5. 沙箱执行:把文件操作翻译成 shell

5.1 BaseSandbox 的核心巧思

远程沙箱只肯给你一根管子:execute(command) -> ExecuteResponseBaseSandbox(backends/sandbox.py:1275)的做法是——七个动词全部翻译成 shell 命令, 再解析它的 stdout

要实现几个抽象成员?上游自己说了两个数。 类 docstring 写的是四样: execute()upload_files()download_files()id 属性(backends/sandbox.py:1293-1294); 而模块 docstring 只提了前两样(backends/sandbox.py:11-12)。 以代码为准是四样——@abstractmethod 分别挂在 execute(:1309)、id(:1804)、 upload_files(:1808)、download_files(:1819)上,少写一个就实例化不了。 模块 docstring 说的其实是"派生逻辑只依赖 executeupload_files",两句都对,但容易读岔。

于是每个动词都拆成对称的一对函数:一个 _build_*_cmd 造命令,一个 _parse_*_output 读结果 (下表位置均相对 backends/sandbox.py):

动词造命令命令长什么样解析
ls_build_ls_cmd(:800)python3 -cos.scandir,每个条目打一行 JSON_parse_ls_output(:826)
read_build_read_cmd(:845)python3 -c,服务端分页,输出单行 JSON_parse_read_output(:863)
grep_build_grep_cmd(:906)grep -rHnFZ;含 / 的 glob 改走 Python 模板_parse_grep_output(:949)
glob_build_glob_cmd(:975)python3 -c 跑 glob,逐行 JSON_parse_glob_output(:1029)
write_build_write_preflight_cmd(:894)只建父目录,内容走 upload_files_check_preflight_result(:899)
edit_build_edit_inline_cmd(:1090)python3 -c + base64 载荷,服务端替换_parse_edit_output(:1109)
delete直接拼test -e ‖ test -L 探测,再 rm -rfexit_code(:1691-1732)

几处翻译上的讲究:

  • 一律 base64 传参。 路径、pattern、编辑载荷都先 base64 再嵌进 python3 -c 的字符串里 (如 _build_ls_cmd / _build_edit_inline_cmd 内部,backends/sandbox.py:800-824:1090-1095),彻底绕开引号与转义地狱。
  • grep 的分隔符是 \0 不是 : -Z 让文件名和行数据之间用 NUL 分隔, 这样文件名里含 : 也不会把输出解析弄歧义(backends/sandbox.py:908-910)。
  • GNU grep --include 只匹配 basename。 所以 src/**/*.py 这种含 / 的 glob 会被静默匹配到零个文件——源码专门为这类模式改走一段 Python 模板 (backends/sandbox.py:913-916)。
  • read 只回一页。 分页在沙箱侧做完,文本输出封顶约 500 KiB (MAX_OUTPUT_BYTES,backends/sandbox.py:431),二进制走 base64 且封顶 MAX_BINARY_BYTES (:421)。全文件永远不会整个过一遍网线。
  • delete 的诚实注释。 shlex.quote 只是把路径变成单个字面参数, 不是安全边界——不限制根目录、不挡穿越(backends/sandbox.py:1713-1717)。 另外 exit_codeNone(后端说不清)时不当作"不存在",而是继续往下删, 避免凭空捏造一个诊断。

5.2 write:先探路,再上传

BaseSandbox.write 是两步(backends/sandbox.py:1460-1487):

  1. _write_preflight 跑一条命令建好父目录(backends/sandbox.py:1436-1453);
  2. 内容交给 upload_files,走子类的原生传输通道。

docstring 里主动承认了这个设计的缺陷:两步之间有 TOCTOU 窗口, "an inherent limitation of splitting the operation across two backend calls" (backends/sandbox.py:1441-1443)。子类如果要覆写 write(比如 LangSmithSandbox 就覆写了), 被要求先调 _write_preflight 以保住建父目录的语义。

5.3 edit:小走内联,大走上传

编辑要把 old_string / new_string 送过去。小的塞命令行没问题,大的会撞 ARG_MAX。所以按载荷大小分两条路(backends/sandbox.py:1503-1545):

payload = len(old) + len(new) (bytes)

┌────────┴────────┐
▼ ▼
≤ 50_000 > 50_000
_edit_inline _edit_via_upload
│ │
一次 execute ① upload old/new 到 /tmp/.deepagents_edit_<80bit>_old|new
base64 载荷 ② execute 服务端脚本读两个临时文件做替换
服务端替换 ③ 脚本 finally 清理;解析失败时调用方再 rm -f 兜底

阈值是 _EDIT_INLINE_MAX_BYTES = 50_000(backends/sandbox.py:523)。

两条路的共同点值得强调:源文件从头到尾没离开过沙箱。 _edit_via_upload 传的只是 old/new 两个字符串,替换发生在服务端 (backends/sandbox.py:1581-1640)。临时文件名带 80 位随机 uid,JSON 解析失败时还有一次 best-effort 的 rm -f 清理,清理失败只 warning 不报错(backends/sandbox.py:1610-1625)。

还有个体贴模型的细节:read() 会把 CRLF 归一成 LF 给模型看,所以模型给回来的 old_string 通常是纯 LF。服务端脚本因此依次尝试原样、CRLF 变体、LF 变体, 并把同样的变换应用到 new_string 上,以保住文件原本的换行风格 (backends/sandbox.py:467-478,编辑命令模板里的 match-driven CRLF 处理)。

5.4 随机 heredoc 分隔符:防的是什么

这条在 §6 的 capture wrapper 里用,但值得单独讲。

要把一段任意的用户命令嵌进另一段 shell 脚本里,最干净的办法是 quoted heredoc (<<'DELIM',里面不做任何展开)。风险在于:如果这段命令自己包含 DELIM 这一行, heredoc 就被提前终止,后面的内容会被当成脚本执行——一个经典的注入面。

做法是让分隔符不可预测(backends/sandbox.py:1203-1205):

def _new_heredoc_delim() -> str:
"""Return a random heredoc delimiter, e.g. `__DEEPAGENTS_CMD_<80 random bits>__`."""
return "__DEEPAGENTS_CMD_" + base64.b32encode(os.urandom(10)).decode("ascii").rstrip("=") + "__"

外加一道确定性保险:生成后还要检查它在不在命令里,在就重生成 (while delim in command: delim = _new_heredoc_delim(),backends/sandbox.py:1221-1223)。 80 位随机只是让重试概率趋近于零,真正保证正确性的是这个循环。

同一段里还有另一个次序上的讲究:模板里所有占位符都先替换,__COMMAND__ 最后替换 (backends/sandbox.py:1224-1236),这样命令内容里哪怕正好写着 __BUDGET__ 也不会被误替换。


6. 大输出不进上下文:源头卸载(capture-at-source)

6.1 问题

模型跑了一句 pytest -v,输出 8 MB。这 8 MB 一旦作为工具结果回到 agent 进程, 就要么撑爆上下文、要么触发一轮昂贵的驱逐与摘要。

6.2 思路:在源头就存下来,只回一个预览

execute_with_offload(backends/sandbox.py:1328-1359)不直接跑命令,而是把它包进一段 POSIX sh 包装器(_EXECUTE_CAPTURE_CMD_TEMPLATE,backends/sandbox.py:1160-1199):

命令输出 ──► head -c MAX ──► 沙箱上的 capture 文件

└─► cat > /dev/null (把剩下的排掉,让命令正常收到 EOF 退出)

然后按文件大小分叉:
≤ inline 预算 → meta 行 + 全量输出,并 rm 掉 capture 文件
> inline 预算 → meta 行 + head 5 行 + "... [N lines truncated] ..." + tail 5 行
capture 文件留在沙箱上,调用方给模型一个 read_file 指针

回来的第一行是元信息,四个空格分隔的字段:

__DEEPAGENTS_EXEC_META__ <exit_code> <offloaded 0|1> <capped 0|1>

_parse_capture_execute_output(backends/sandbox.py:1240-1272)按这个格式解析成 ExecuteOffloadResult(backends/protocol.py:821-839),它只有两个字段: offloaded: boolresponse: ExecuteResponseoffloaded 刻意不放进 ExecuteResponse, 因为普通的 execute 永远不会设置它。

6.3 这段包装器里的三个坑,源码都填了

位置均相对 backends/sandbox.py:

现象解法位置
早关管道会 SIGPIPE 杀死命令退出码被污染head -c 后面接 cat > /dev/null 把剩余流排干,命令正常走到 EOF:1174
$? 拿到的是管道的退出码拿不到命令真实退出码命令自己的 $? 写进 sidecar 文件 $__da_f.ec,回头再读:1166:1174-1175
命令里一句 exit 会终止整个包装器元信息行都发不出来命令跑在子 shell ( eval "$__da_cmd" ):1174

另外硬上限 _EXECUTE_CAPTURE_MAX_BYTES = 10 MiB(backends/sandbox.py:1141)防止失控输出撑爆沙箱磁盘; 命中上限时 capped=1,最终体现为 response.truncated=True

6.4 为什么默认关闭

enable_capture_offload 默认是 False(backends/sandbox.py:1297-1307)。理由写得很直白: 这段包装器对 shell 和 coreutils 有假设,不是每个沙箱镜像都满足。 所以它是 opt-in——已知兼容的子类自己打开(目前只有 LangSmithSandbox,backends/langsmith.py:61)。

关闭时 execute_with_offload 就直接跑原命令、返回 offloaded=False, 调用方(中间件,middleware/filesystem.py:2870-2874)退回到那条通用的驱逐路径: 结果照常回到 agent 进程,超阈值再由中间件写进 /large_tool_results/ (§4.5 的第一个前缀,机制见 第 05 章)。 两条路的终点一样,差别是大输出有没有白跑一趟网络。

解析同样是保守的:元信息行缺失或格式不对(比如后端自己截断了传输), 就退回 offloaded=False 并原样返回输出——注释特别提醒调用方这种情况下绝不能重跑命令 (backends/sandbox.py:1253-1257)。


7. 共享工具:backends/utils.py

多个后端要做同样的事(切片、替换、搜索、格式化),这些逻辑集中在 backends/utils.py。挑五个承重的(位置均相对该文件):

函数职责承重细节位置
perform_string_replacement带出现次数校验的精确替换0 次 / >1 次都返回给模型看的人话错误;还专门识别"old_string 多带了行尾换行"这个高频误差:502-558
slice_read_response按 offset/limit 切出一个窗口splitlines(keepends=True) 保住行尾状态(让 edit 能准确报 EOF 换行不匹配);只在窗口内把 CRLF/CR 归一成 LF:428-500
grep_matches_from_files在内存文件字典上做字面量搜索明确"不 raise",以保持后端在工具上下文里非抛异常;先按 path 过滤,再按 glob 过滤,最后逐行 pattern in line:882-926
create_file_data / update_file_data造 / 更新 FileDataupdate 保留原 created_at,只刷新 modified_at:318-364
format_content_with_line_numberscat -n 风格编号超过 MAX_LINE_LENGTH=5000 的行切块,续行编号写成 5.15.2:190-242

注意最后一个的归属:编号格式化属于工具层,不属于后端StateBackend.read 的 docstring 明确写着 "Line-number formatting is applied by the middleware" (backends/state.py:190)——后端只负责给出正确的原始窗口。


8. 边界

partners:同一协议,第三方包实现。 仓库把具体沙箱厂商拆成独立包放在 libs/partners/, 每个都只是继承 BaseSandbox 再实现 §5.1 那四个抽象成员(下表路径相对克隆根):

位置
langchain_daytonaDaytonaSandboxlibs/partners/daytona/langchain_daytona/sandbox.py:23
langchain_modalModalSandboxlibs/partners/modal/langchain_modal/sandbox.py:16
langchain_runloopRunloopSandboxlibs/partners/runloop/langchain_runloop/sandbox.py:18
langchain_vercel_sandboxVercelSandboxlibs/partners/vercel/langchain_vercel_sandbox/sandbox.py:31
langchain_quickjs不走 backend 这条轴,提供的是 CodeInterpreterMiddleware(middleware.py:128)libs/partners/quickjs/langchain_quickjs/

这就是这层抽象的验收标准:加一家新沙箱厂商 = 写一个几十行的子类,内核一行不动。

这层刻意不做的事:

  • 不做安全边界。 BaseSandbox 的类文档明说它"does not reduce or partition the trust boundary of execute()"(backends/sandbox.py:1285-1291);FilesystemBackend / LocalShellBackend 的类文档都是一整段安全警告(backends/filesystem.py:98-128backends/local_shell.py:33-60)。 隔离由沙箱本身或 HITL 中间件提供,不由这层提供。
  • 执行不可路由。 CompositeBackend.execute 只认 default,没有"按路径选沙箱"这回事。
  • 能力探测靠"有没有覆写方法"。 _supports_delete 用的是 type(backend).delete is not BackendProtocol.delete(backends/protocol.py:954)—— 子类如果覆写了 delete 但内部又 raise NotImplementedError,探测会误判为支持。
  • 异步默认是假的。 基类的 a* 方法都是 asyncio.to_thread 包同步版 (backends/protocol.py:424-426 等),不覆写就没有真正的并发收益。
  • agrep 的超时管不住线程。 只约束调用方等待时长,工作线程仍在跑(backends/protocol.py:542-544)。

9. 代码地图

本表给相对克隆根的完整路径(与正文的包目录基准不同,便于直接 grep)。

主题文件路径符号名
契约本体libs/deepagents/deepagents/backends/protocol.pyBackendProtocol
结果类型族libs/deepagents/deepagents/backends/protocol.pyReadResultWriteResultEditResultDeleteResultLsResultGrepResultGlobResult
数据结构libs/deepagents/deepagents/backends/protocol.pyFileInfoFileDataGrepMatchFileFormat
错误常量libs/deepagents/deepagents/backends/protocol.pyFILE_NOT_FOUNDPERMISSION_DENIEDIS_DIRECTORYINVALID_PATH
能力探测libs/deepagents/deepagents/backends/protocol.py_supports_deleteexecute_accepts_timeout
执行协议libs/deepagents/deepagents/backends/protocol.pySandboxBackendProtocolExecuteResponseExecuteOffloadResult
state 落点libs/deepagents/deepagents/backends/state.pyStateBackend_read_files_send_files_update
磁盘落点libs/deepagents/deepagents/backends/filesystem.pyFilesystemBackend_resolve_path_ripgrep_search_python_search
store 落点libs/deepagents/deepagents/backends/store.pyStoreBackendNamespaceFactory_validate_namespace_search_store_paginated
Hub 落点libs/deepagents/deepagents/backends/context_hub.pyContextHubBackend_load_tree_commit
本机 shelllibs/deepagents/deepagents/backends/local_shell.pyLocalShellBackendDEFAULT_EXECUTE_TIMEOUT
前缀路由与卸载根libs/deepagents/deepagents/backends/composite.pyCompositeBackendartifacts_root_route_for_path_strip_route_from_pattern_remap_grep_path_remap_file_info_path_DELETE_UNSUPPORTED_ERROR
沙箱基类libs/deepagents/deepagents/backends/sandbox.pyBaseSandbox_build_ls_cmd_build_read_cmd_build_edit_inline_cmd_edit_via_upload
源头卸载libs/deepagents/deepagents/backends/sandbox.pyexecute_with_offload_build_capture_execute_cmd_parse_capture_execute_output_new_heredoc_delim
官方沙箱实现libs/deepagents/deepagents/backends/langsmith.pyLangSmithSandboxenable_capture_offload
共享工具libs/deepagents/deepagents/backends/utils.pyperform_string_replacementslice_read_responsegrep_matches_from_filescreate_file_dataformat_content_with_line_numbers
导出面libs/deepagents/deepagents/backends/__init__.py__all__

相关章节