数据截至 (上游 commit 25aa2735dabb)
文件系统中间件:工具族、动态可见性与权限闸门
30 秒导读:
FilesystemMiddleware是 Deep Agents 里最大的一块代码(libs/deepagents/deepagents/middleware/filesystem.py,3507 行)。它给 agent 装上ls/read_file/write_file/edit_file/delete/glob/grep/execute一套最多八个工具,在每次模型请求前根据后端真实能力决定哪些工具能被模型看见、system prompt 怎么写,并在每次工具执行时按路径规则决定放行、报错还是弹给人审批。
上一章 02-backends.md 讲的是"文件和执行落在哪";本章讲的是"模型能看见哪些操作、这些操作在落地前要过几道关"。装配顺序见 01-assembly-and-profiles.md。
引用约定(与 04、05 章一致): 本章所有
path:line相对克隆根下的包目录libs/deepagents/deepagents/;以libs/开头的路径(如libs/ARCHITECTURE.md)相对克隆根。本章主角文件middleware/filesystem.py在正文里简写成filesystem.py:NNN,只写冒号的:NNN也指它。
1. 这一章解决的问题(零基础也能懂)
一个能读写文件的 agent,天然要回答三个问题:
| 问题 | 白话 | 本章对应节 |
|---|---|---|
| 模型手里有哪些文件工具? | 发工具 | §2、§3 |
| 这些工具在当前环境下有几个真能用? | 现场增删 | §4、§5 |
| 一次调用允许落到哪些路径上? | 闸门 | §8、§9 |
难点不在"实现一个 read_file"。难点在于这三件事互相牵扯:
- 后端不支持 shell,
execute就不该出现在工具列表里——但也不能只是让它调用时报错,因为模型看见了就会用。 execute不在了,grep的工具描述里那句"真要正则就用execute跑rg"就成了假信息,必须同步换掉。- 权限规则拦得住
write_file("/secrets/x"),但拦不住ls("/")把/secrets列出来——所以结果侧还要再过一遍滤网。
FilesystemMiddleware 就是把这三件事塞进同一个中间件里统一处理的那个类(libs/deepagents/deepagents/middleware/filesystem.py:1547,class FilesystemMiddleware)。
一句话直觉: 把它想成一个文件柜台——柜台把八种业务的窗口牌子挂出来(工具),开门前先看今天哪几个窗口有人值班(可见性),办业务时再查你的门禁卡能不能进那个档案室(权限)。
2. 顶层全景:一次文件工具调用要过四道关
先看这张图。从上到下是时间顺序,每一道关都可能提前返回,后面的关就不跑了。
模型请求前(每次调用模型都跑一遍)
┌────────────────────────────────────┐
│ ① 可见性关 │
│ 探测后端能力,从请求里删掉 │
│ 办不到的工具,并重写 prompt │
│ wrap_model_call │
└─────────────────┬──────────────────┘
│ 模型看到"精简后"的工具表
▼
┌────────────────────────────────────┐
│ ② 人审关(可选) │
│ 路径命中 interrupt 规则 → │
│ 暂停,等人点批准/改/拒 │
│ HumanInTheLoopMiddleware │
└─────────────────┬──────────────────┘
│
▼
┌────────────────────────────────────┐
│ ③ 入参关 │
│ validate_path 归一化 + 防穿越 │
│ _check_fs_permission 判 deny │
└─────────────────┬──────────────────┘
│ 真正调 backend
▼
┌────────────────────────────────────┐
│ ④ 结果关 │
│ 逐条过滤被 deny 的路径 / 匹配项 │
│ 截断、行号、媒体块、超大结果卸载 │
└────────────────────────────────────┘
各道关的落点:
| 关 | 触发时机 | 关键符号 | 位置 |
|---|---|---|---|
| ① 可见性 | 每次模型请求 | wrap_model_call / _filter_unsupported_tools_and_apply_prompt | filesystem.py:3053 / :3005 |
| ② 人审 | 工具调用前 | _build_interrupt_on_from_permissions | middleware/_fs_interrupt.py:156 |
| ③ 入参 | 工具函数开头 | validate_path / _check_fs_permission | backends/utils.py:643 / filesystem.py:420 |
| ④ 结果 | 工具函数返回前 | _filter_file_infos_by_permission 等 | filesystem.py:656-682 |
注意 ② 和 ③ 分属两个中间件:FilesystemMiddleware 自己只做 deny,interrupt 模式要靠 graph.py 把规则翻译成 HumanInTheLoopMiddleware 的配置(§8)。
3. 工具族:一个中间件、八个工具
3.1 构造时按白名单建,后端能力留给运行时
__init__ 的最后一步是把八个工具的工厂列成 tuple,再按白名单过滤后实例化(filesystem.py:1718-1731):
tool_factories: tuple[tuple[str, Callable[[], BaseTool]], ...] = (
("ls", self._create_ls_tool),
...
("execute", self._create_execute_tool),
)
self.tools = [factory() for name, factory in tool_factories
if self._enabled_tools is None or name in self._enabled_tools]
注意分工:用户白名单(tools=)在构造期就生效——没进名单的工具连对象都不建,注释明说这让它"永远不会到达可分发的工具节点"(filesystem.py:1728-1730);而后端能力(支不支持 shell、能不能 delete)构造时一概不管——那是 §4 运行时才决定的事。
八个工具与它们的 schema、描述常量:
| 工具 | 干什么 | 入参 schema | 描述常量 | 工厂方法 |
|---|---|---|---|---|
ls | 列目录 | LsSchema (:1091) | LIST_FILES_TOOL_DESCRIPTION (:1213) | _create_ls_tool (:1733) |
read_file | 读文件/媒体 | ReadFileSchema (:1097) 或 ReadVideoFileSchema (:1113) | READ_FILE_TOOL_DESCRIPTION (:1240) / READ_FILE_VIDEO_TOOL_DESCRIPTION (:1245) | _create_read_file_tool (:1824) |
write_file | 整文件覆写 | WriteFileSchema (:1131) | WRITE_FILE_TOOL_DESCRIPTION (:1262) | _create_write_file_tool (:2004) |
edit_file | 精确串替换 | EditFileSchema (:1139) | EDIT_FILE_TOOL_DESCRIPTION (:1253) | _create_edit_file_tool (:2095) |
delete | 递归删除 | DeleteSchema (:1154) | DELETE_TOOL_DESCRIPTION (:1269) | _create_delete_tool (:2192) |
glob | 按模式找文件 | GlobSchema (:1160) | GLOB_TOOL_DESCRIPTION (:1278) | _create_glob_tool (:2287) |
grep | 字面量搜内容 | GrepSchema (:1177) | GREP_TOOL_DESCRIPTION (:1296) | _create_grep_tool (:2460) |
execute | 沙箱跑 shell | ExecuteSchema (:1202) | EXECUTE_TOOL_DESCRIPTION (:1315) | _create_execute_tool (:2812) |
为什么别处说 6 个、7 个,这里说 8 个: 三处数法都对,数的东西不同。
create_deep_agent的 docstring 只列默认可用的文件操作 6 个(graph.py:293,不含delete),execute单列一行(graph.py:294);_FS_TOOL_ORDER(filesystem.py:1339)是 7 个内置文件工具名的固定顺序(补上delete);本章数的是中间件能构造出来的工具对象,7 个加上条件可见的execute共 8 个。往下读时按这一列 算。
3.2 每个工具都是 sync + async 双份
每个 _create_*_tool 内部定义一对同名逻辑的闭包(sync_ls/async_ls……),然后交给 StructuredTool.from_function(func=..., coroutine=...) 打包(例:filesystem.py:1737、1776、1815-1821)。两份代码几乎逐行对称,差别只在 backend.ls() 与 await backend.als()。
infer_schema=False + 显式 args_schema 意味着模型看到的入参契约由 Pydantic 类唯一决定,不从函数签名反推——所以 runtime: ToolRuntime 这个注入参数不会泄漏进工具 schema。
3.3 schema 的字段描述就是给模型的提示词
这些 Field(description=...) 不是装饰,是模型唯一能看到的参数语义。两个写得最用力的例子:
GREP_GLOB_DESCRIPTION(filesystem.py:1073)专门澄清"这是工具内的文件过滤器,不是去调那个独立的glob工具",还点名"花括号展开不是所有后端都支持"。GREP_OUTPUT_MODE_DESCRIPTION(:1082)逐个把三种output_mode的输出长相描述出来(<path>: <count>之类),省得模型猜。
grep 的工具描述则反复强调一件事:pattern 是字面量,不是正则(filesystem.py:1290-1296 的 _GREP_TOOL_DESCRIPTION_TEMPLATE)。这不是随口一说——它连"想匹配多个词就分别 grep"这类用法说明都写进模板(filesystem.py:1290-1296)。
4. 可见性是运行时决定的
4.1 要解决的小问题
execute 依赖后端实现 SandboxBackendProtocol;delete 依赖后端真的覆写了 delete()。构造中间件时,后端可能还是个工厂函数,或者是个到运行时才知道路由构成的 CompositeBackend。所以能力只能在请求时探测。
4.2 探测:supports_execution
def supports_execution(backend: BackendProtocol) -> bool:
if isinstance(backend, CompositeBackend):
return isinstance(backend.default, SandboxBackendProtocol)
return isinstance(backend, SandboxBackendProtocol)
真实位置:filesystem.py:1434-1452。注意 Composite 的分支只看 default——因为 execute 是在默认后端的 shell 里跑的,挂在路由上的那些后端有没有沙箱都不算数。这一条直接决定了 §5.3 那段虚拟路径提示词为什么必须存在。
delete 的探测走另一条:_supports_delete(backends/protocol.py:939),靠"有没有覆写方法"判断,而不是真的调一次然后接 NotImplementedError。
4.3 汇总:_unsupported_tools_and_execution_state
filesystem.py:2707-2732。它一次算出三样东西:该删哪些工具、execute 是不是活的、解析出来的后端实例。
判定顺序值得注意:
- 用户白名单不在这里——
tools=的排除已在__init__落实(docstring 明说,filesystem.py:2712-2715),这里只做后端能力探测。 - 只有当请求里确实出现了
delete或execute,才去碰后端(:2719-2722)。没有这两个工具就直接返回,省掉一次可能很贵的探测。 - 再按后端能力补进不支持集(:2724-2732)。
4.4 落地:三件事一起改
_filter_unsupported_tools_and_apply_prompt(filesystem.py:3005-3051)是 sync/async 两条 wrap_model_call 的公共前半段。它做的事按顺序是:
ModelRequest 进来
│
├─ 1. 删工具 request.override(tools=visible_tools) :3021-3024
│
├─ 2. 换 grep 描述 _with_filtered_grep_description :3026
│ execute 没了 → 换成不提 rg 的那份
│
├─ 3. 换 execute 描述 _with_filtered_execute_description :3027-3030
│ 按还活着的搜索工具挑四份变体之一
│
├─ 4. 拼 prompt 调用方 system_prompt? + 路径映射段 :3040-3045
│
└─ 5. 追加到 system_message append_to_system_message :3047-3049
第 2 步的细节很有意思(_with_filtered_grep_description,filesystem.py:2574-2616):它只在描述仍是两个内置默认值之一时才改写,用户自己传了 custom_tool_descriptions["grep"] 就直接原样返回。而且没变化就返回原列表对象(return rewritten if changed else tools),让调用方能用 is not 判断要不要 override。
第 3 步是 0.7.x 新加的对称机制(_with_filtered_execute_description,filesystem.py:2637-2693):execute 的描述同样按"还能看见哪些搜索工具"动态改写——grep 还在就别提 find、glob 还在就别提 shell grep,四份变体 _EXECUTE_TOOL_DESCRIPTION_WITH_GREP_ONLY 等(filesystem.py:1315-1337)。
_create_grep_tool 里那句注释把这条设计说透了(filesystem.py:2466):静态挂在 self.tools 上的 grep 描述是乐观占位符(假设有 execute),真值在每次请求时才对齐。