跳到主要内容

数据截至 (上游 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 的工具描述里那句"真要正则就用 executerg"就成了假信息,必须同步换掉。
  • 权限规则拦得住 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_promptfilesystem.py:3053 / :3005
② 人审工具调用前_build_interrupt_on_from_permissionsmiddleware/_fs_interrupt.py:156
③ 入参工具函数开头validate_path / _check_fs_permissionbackends/utils.py:643 / filesystem.py:420
④ 结果工具函数返回前_filter_file_infos_by_permissionfilesystem.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沙箱跑 shellExecuteSchema (: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 是不是活的、解析出来的后端实例。

判定顺序值得注意:

  1. 用户白名单不在这里——tools= 的排除已在 __init__ 落实(docstring 明说,filesystem.py:2712-2715),这里只做后端能力探测。
  2. 只有当请求里确实出现了 deleteexecute,才去碰后端(:2719-2722)。没有这两个工具就直接返回,省掉一次可能很贵的探测。
  3. 再按后端能力补进不支持集(: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),真值在每次请求时才对齐。

4.5 wrap_model_call 的完整职责

wrap_model_call(:3053)/awrap_model_call(:3099)在上面那段之后还做三件事:

  1. _move_media_results_after_tool_results(:160,详见 §6.4)重排消息。
  2. _scrub_unsupported_multimodal_content(:269)把模型不支持的多模态块替换成文字占位,避免 provider 直接拒绝(docstring :3070-3071)。
  3. _evict_and_truncate_messages(:3285)处理超大 HumanMessage(详见 §10)。

三件事之外它才把请求交给 handler。

一处历史细节: 旧版这里有个带缓存的 _build_dynamic_system_prompt 和整套工具清单 prompt。0.7.x 起内置工具指引整体移除——注释写得很直白:那份 prose 与工具自己的 schema 描述重复,不值得每轮重发(filesystem.py:3034-3039)。现在 prompt 只拼两段:调用方传入的 system_prompt(缺省 None)和 execute 激活时的路径映射段。


5. system prompt 只剩"必要的动态段"

5.1 拼装的两段

0.7.x 起,中间件默认不再生成任何内置工具指引——那份"你有哪些文件工具、/large_tool_results 是什么"的 prose 被整体删除,理由写在 _filter_unsupported_tools_and_apply_prompt 的注释里:内置指引与工具自己的 schema 描述重复,每次请求重发纯属浪费(filesystem.py:3034-3039)。剩下的只有:

调用方 system_prompt 段 ← 构造参数 system_prompt 非 None 才有
+
路径映射段 ← execution_active 且后端是 CompositeBackend 才有

两段用 "\n\n".join(...).strip() 拼起来(filesystem.py:3040-3045)。

5.2 描述动态改写代替了清单罗列

旧版靠 system prompt 罗列"活着的工具";现在同一信息直接落在工具描述上:grep 的描述在 execute 消失时换成不提 rg 的那份(§4.4 第 2 步),execute 的描述按可见搜索工具在四份变体里挑一份(§4.4 第 3 步)。工具名顺序恒定,由 _FS_TOOL_ORDER(:1339)固定——不随集合的哈希顺序抖动,这对 Anthropic 的 prompt 缓存前缀很重要(缓存策略见 05-context-engineering.md)。

5.3 最巧的一段:虚拟路径 ↔ 宿主路径映射

它要解决的小问题: CompositeBackend/memories/ 这种虚拟前缀路由到另一个后端。文件工具返回的是虚拟路径,但 execute 跑在默认后端的真实 shell 里——模型照着虚拟路径拼 cat /memories/x 就会扑空。

思路: 不去重写 shell 命令(那要解析 shell 语法),而是把前缀替换规则直接写进 system prompt,让模型自己生成正确命令。

_route_host_path_prompt(filesystem.py:1343-1432)按路由逐条判定:

路由后端形态默认后端结果
FilesystemBackend + virtual_modeLocalShellBackend前缀 → route.cwd(:1384-1386)
FilesystemBackend + 非 virtualLocalShellBackend前缀被剥掉,映射到 /(:1387-1390)
任何路由远程/沙箱默认后端无映射,列进"shell 访问不到"清单(:1372-1383)
Store 类后端任意同上,无映射

判定的前提写在 :1375:default_uses_local_shell = isinstance(backend.default, LocalShellBackend)默认后端的 shell 不在本地文件系统上,本地路由就一律不可达——这个"失败就说不可达"的保守取向,比给模型一条错映射要好。

生成的提示词长这样(:1407-1429 拼出):

## Shell paths vs. virtual paths
...
Host path mappings:
- `/common/` -> `/data/` (e.g. `/common/dir/x.py` -> `/data/dir/x.py`)

Virtual mounts without a host path mapping (not accessible from the shell):
- `/memories/`

两侧前缀都被 _norm 补上尾斜杠(:1395-1405),这样"前缀替换"对子路径才是组合的——少了这一步,/common/data 会把 /commonwealth 也换掉。


6. 读文件:一个工具、五条分支

read_file 是八个工具里逻辑最重的。核心分派在 _handle_read_result(filesystem.py:1845-1939,嵌在 _create_read_file_tool 里),源码里那句 # one branch per distinct read-result disposition 的注释直说了分派分支多的原因。

6.1 分支顺序(顺序本身是设计)

backend 返回

├─ 有 error? ─→ 报错 :1852-1858
├─ 无 file_data? → 报错 :1860-1866
├─ 内容为空? ─→ EMPTY_CONTENT_WARNING :1872-1895
├─ 是视频? ──→ 抽帧窗口(见 6.3) :1897-1907
├─ base64 或非 text? → 多模态 content block :1909-1923
└─ 否则 ────→ 加行号 + 大小截断 :1925-1939

空文件检查必须排在媒体分支之前(源码注释 :1872-1874):否则一个空的二进制文件会产出一个空的 content block 塞给模型。空内容还分两种情况(:1877-1889):limit<=0 的"零行窗口"(后端用 no_lines_requested 标记)给一句专门的 NO_LINES_REQUESTED_WARNING,真空文件才给 EMPTY_CONTENT_WARNING——两者的下一动作不同,不能混。

编码判定优先于扩展名(:1909-1913):后端说 encoding == "base64" 就一定当二进制处理,哪怕扩展名不认识;扩展名只用来挑 block 类型,认不出就退到通用的 "file"

6.2 行号、分页与截断的四个常量

常量作用位置
DEFAULT_READ_OFFSET0默认从第 0 行开始filesystem.py:890
DEFAULT_READ_LIMIT100默认读 100 行filesystem.py:891
NUM_CHARS_PER_TOKEN4字符→token 的粗估比filesystem.py:905

行号格式化在 format_content_with_line_numbers(backends/utils.py:190-242),cat -n 风格,标记宽度按实际内容动态取宽(:240)。超过 MAX_LINE_LENGTH = 5000 的长行会被切块,续行用 5.15.2 这种小数标记(:221)。

_truncate_paginated_read(filesystem.py:908-960)在字符数达到 4 × token_limit 时截断,并追加 READ_FILE_TRUNCATION_MSG(:894)。这条消息不只是"被截断了",它还给出自救办法:"如果这是 JSON,用 execute(command='jq . {file_path}') 重排格式再读"。它还要重算分页提示:字符预算可能挤掉尾行,直接照抄后端的 next_offset 会 advertise 一个越界偏移,重读就跳行(:916-922)——所以提示从"最后一个完整渲染行"重新推。

有个踩过的坑写在 :1930-1932:文本分支不再按行数二次截断。因为 limit 已经在后端限过源码行数,续行标记行会挤掉真实源码行(issue #2453)。

空文件的警告文案 EMPTY_CONTENT_WARNING(filesystem.py:845)在 backends/utils.py:23 有一份同值定义,实际返回值来自 check_empty_content(backends/utils.py:243-256);filesystem.py 那份是给外部引用的重复常量。

6.3 视频:把 offset/limit 重新解释成秒

视频支持是可选依赖video_dependencies_available()(middleware/_video.py:41-58)用 importlib.util.find_spec 探测 avPIL.Image,探测结果在 _create_read_file_tool 一开始就决定了两件事(filesystem.py:1826-1831):

  • 用哪份工具描述(带不带视频说明);
  • 用哪个 schema(ReadVideoFileSchema 只改了 offset/limit 的 description 文案,字段本身完全相同,:1113-1130)。

_get_read_file_type(:375-383)在通用分类之上加一层门:只有开了视频依赖,.mkv 才算 video(_VIDEO_EXTRA_EXTENSIONS,backends/utils.py:271)。

_handle_video_read(:294-365)的语义翻转是关键:

  • offset → 跳过多少;
  • limit → 采样多少;
  • 采样率固定 _VIDEO_SAMPLING_RATE = 0.5(:137),即每 0.5 秒一帧;
  • limit <= 0 直接报工具错(:313),而不设每次调用的上限——总量由抽帧器那一层的多个上限兜底(MAX_VIDEO_SAMPLED_FRAMES=64MAX_VIDEO_DECODE_SECONDS=10.0MAX_VIDEO_EMITTED_BYTES=4MB,middleware/_video.py:61-79)。

_video_window_header(:367-373)生成给模型看的窗口说明,首窗和续窗文案不同(Reading first 100s of ... vs Reading [3.000s, 8.000s) of ...)。

返回的不是一条消息,而是一个 Command,里面装两条(:342-362):

  1. 一条 ToolMessage,内容是"采了 N 帧,帧在下一条消息里";
  2. 一条 HumanMessage,content_blocks 装图像,并打上 _READ_FILE_MEDIA_RESULT 标记(:134、:352)。

6.4 为什么要重排消息:_move_media_results_after_tool_results

问题: 供应商要求同一批 tool_call 的所有 ToolMessage 必须连续排在一起,中间不能夹非 tool 消息。视频读却硬塞进来一条 HumanMessage。同一轮里如果还有别的工具在跑,消息就会交错成 Tool, Human, Tool —— 请求直接被拒。

解法(filesystem.py:160-190):扫到一条带 tool_calls 的 AIMessage,就把紧随其后的"tool 消息 + 媒体消息"整批收集起来,先全部吐出 ToolMessage,再全部吐出媒体消息(:178-190)。

原始: AI ─ Tool(video) ─ Human(frames) ─ Tool(ls)
重排后: AI ─ Tool(video) ─ Tool(ls) ─ Human(frames)

这个重排只作用于发给模型的请求副本(request.override(messages=...),:3083-3086),state 里的顺序不动。


7. 编辑与搜索

7.1 edit_file:精确字符串替换契约

契约由 EditFileSchema(:1139-1153)和 EDIT_FILE_TOOL_DESCRIPTION(:1253-1261)两头写死,四条:

  1. old_string 必须精确匹配,包括缩进;
  2. 除非 replace_all=True,否则 old_string 必须在文件里唯一;
  3. new_string 必须和 old_string 不同;
  4. 必须先读后改——描述里明写"没读过就编辑会报错"。

中间件本身只做三件事:validate_path → 权限检查 → 转交 backend.edit()(_create_edit_file_tool 内部,filesystem.py:2095-2191)。匹配与唯一性判定在后端,不在这里。成功文案回报替换次数:Successfully replaced {res.occurrences} instance(s) ...(同段)。

7.2 glob 的超时保护:一次真刀真枪的并发处理

问题: 一个 **/* 打在大目录上可能跑到天荒地老,而 concurrent.futures 的任务取消不了——一旦开跑,只能等它自己结束。

方案(filesystem.py:2287-2458):

┌ 共享线程池 _glob_executor(4 worker) :1712-1715
│ + 有界信号量 _glob_slots(4 slot) :1716

acquire(blocking=False) 失败? ──→ 立刻报"太忙" :2320-2325
│ 拿到 slot

submit(run_glob) ──→ wait([future], 10.0s) :2335-2346
│ │
│ 按时完成 │ 超时
▼ ▼
future.result() 报 _glob_timeout_message()
(后端异常在此浮现) worker 继续跑,自己 release slot

三个设计点,每个都有源码注释背书:

  • 为什么用共享池而不是 with ThreadPoolExecutor():with 退出时 shutdown(wait=True) 会一直阻塞到那个失控的 glob 结束,超时就白设了(注释 :1709-1711)。
  • 为什么信号量而不是排队:超时的 worker 还占着线程,新调用排在它后面只会一起卡死;直接拒绝并让模型换个更窄的模式,比排队诚实(:1709-1711)。
  • 为什么 wait() 而不是 future.result(timeout=...):Python 3.11+ 起 concurrent.futures.TimeoutError is TimeoutError,直接 catch 会把后端内部抛的 TimeoutError(比如沙箱 RPC 超时)误报成"glob 模式太宽"(:2341-2345)。

_glob_timeout_message()(:875-883)刻意做成函数而不是常量,在调用时才读 GLOB_TIMEOUT,这样测试改了阈值,文案里的秒数也跟着变。

async 版(同闭包内,filesystem.py:2416)用 asyncio.wait 复刻同一套语义,超时后 task.cancel() 并挂上 _discard_task_result(:884-887)吞掉取消异常,避免事件循环告警。

7.3 搜索结果的截断策略

两条不同的截断路径,别混:

场景谁截断加什么尾巴
后端自己提前停(命中时限)后端置 result.truncatedGREP_TRUNCATION_NOTE / GLOB_TRUNCATION_NOTE(:862、:869)
结果太大(超 token 预算)truncate_if_too_long(backends/utils.py:567)TRUNCATION_GUIDANCE

_format_grep_tool_result(:714-755)里有一处顺序讲究:先截断匹配体,再拼尾注。反过来的话,外层再截一次就会把刚加上的 SEARCH_TRUNCATION_NOTE 削掉——所以函数文档明写"调用方请直接用返回值,别再截一次"。

还有一处细腻的判断(:714-755 内):只有当"后端在权限过滤前也确实没找到东西"(backend_had_matches=False)时,才追加"你是不是把正则当字面量用了"的提示(regex_literal_hint)。匹配存在但被权限滤光了,跟正则语法毫无关系,这时给提示是误导。


8. 权限闸门

8.1 规则长什么样

# 示意,非源码
FilesystemPermission(
operations=["write"], # "read" 或 "write"
paths=["/secrets/**"], # glob,必须以 / 开头
mode="deny", # allow / deny / interrupt
)

真身是个 dataclass:filesystem.py:384-418,class FilesystemPermission__post_init__(:406-418)对每条 path 做三项校验:

校验不合规的下场
必须以 / 开头ValueError
组件里不能有 ..ValueError
组件里不能有 ~NotImplementedError

~NotImplementedError 而不是 ValueError,是在说"这不是你写错了,是我们还没做 home 展开"。

8.2 匹配:先匹配者胜,无匹配即放行

def _check_fs_permission(rules, operation, path):
for rule in rules:
if operation not in rule.operations:
continue
if any(wcglob.globmatch(path, pattern, flags=_FS_WCMATCH_FLAGS) for pattern in rule.paths):
return rule.mode
return "allow"

真实位置:filesystem.py:420-431。整个权限模型就这十来行,两条铁律:

  • 顺序敏感:第一条命中的规则说了算,后面的不再看。所以"先 deny 特例、再 allow 通配"和反过来写,结果完全不同。
  • 默认放行:一条都没命中就是 allow。这是白名单式拒绝,不是黑名单式准入。

匹配用 wcmatch,flags 是 BRACE | GLOBSTAR(:114),即支持 {a,b} 展开和 ** 递归。

8.3 删除的特殊处理:通配符重叠

为什么删除不能复用 _check_fs_permission: 递归删除 /work 会连带删掉 /work/secrets/key。逐路径匹配只会检查 /work 本身,而 /work 并不匹配 /work/secrets/**——洞就在这里。

_find_delete_deny_patterns(:558-618)改成检查子树是否相交,并按模式有无通配符分两路:

  • 字面量模式 → _paths_overlap(backends/utils.py:607),"谁是谁的前缀"即算重叠;
  • 通配符模式 → _wildcard_delete_overlap(:433-481)。

_wildcard_delete_overlap 的四级判定(读作"命中任一即拦截"):

anchor == "/"? → 拦 (模式可能匹配任何地方) :433-481
target 直接匹配 pattern? → 拦 :433-481
anchor 在 target 子树内? → 拦 (递归删会带走匹配项) :433-481
target 在 anchor 之下:
suffix 不是单个非-** 组件? → 拦 (深度不定,保守失败) :433-481
target 的某个祖先匹配 pattern? → 拦 :433-481
以上都不是 → 放行 :433-481

这里的 anchor 指模式里最长的无通配前缀,由 _glob_anchor(backends/utils.py:586-606)算出:/secrets/**/secrets,/a/*/b/a,而 /**/secrets 退化成 /

举三个例子把规则坐实(均取自 _wildcard_delete_overlap 的源码注释,filesystem.py:433-481):

deny 模式删除目标结果为什么
/work/*.log/work/notes.txt放行这个模式永远匹配不到 notes.txt 之下的东西
/work/*/work/app/child拦截/work/app 本身被模式匹配,删它的孩子等于改动受保护目录
/work/*/secrets任意子树拦截目录级通配,可能匹配到后代,保守失败

被拦时错误消息会把命中的模式列出来(_create_delete_tool 内部,filesystem.py:2192-2286),便于调试。

8.4 结果侧过滤:三个孪生函数

入参检查挡得住 read_file("/secrets/x"),挡不住 ls("/") 顺手把 /secrets/x 列出来。所以列表类工具在返回前还要再滤一遍:

函数过滤对象位置
_filter_paths_by_permissionlist[str]filesystem.py:620
_filter_file_infos_by_permissionlist[FileInfo](ls/glob 用):656
_filter_grep_matches_by_permissionlist[GrepMatch]:670

三者共用同一条判据,而且只滤 deny,不滤 interrupt。理由写在 _filter_paths_by_permission 的文档串里(:620-631):interrupt 已经在工具执行之前由人批准过了,这里再滤掉,等于把用户刚点了"同意"的那份清单悄悄清空。

ls/glob 各有一个薄封装把过滤和"取 path 字段"合一(_apply_permissions_to_ls_results :756、_apply_permissions_to_glob_results :765)。

8.5 一条明确的能力边界

__init__ 里有个硬性拒绝(filesystem.py:1680-1687):execute 能力的后端 + 权限规则 = 直接抛 NotImplementedError,除非所有规则路径都落在 CompositeBackend 的路由前缀内(_all_paths_scoped_to_routes,:638-655)。

理由很实在:execute 能跑任意 shell,路径级权限对它形同虚设。与其假装拦得住,不如在构造时就报错。


9. interrupt 怎么变成人审

9.1 分工:中间件不认识 HITL

FilesystemMiddleware 里只有 deny 的代码,搜不到任何 interrupt 的执行逻辑。这是刻意的分层,middleware/_fs_interrupt.py 的模块文档串开门见山(:1-9)。

FilesystemPermission(mode="interrupt")

│ _build_interrupt_on_from_permissions _fs_interrupt.py:156

dict[工具名 → InterruptOnConfig(when=谓词)]

│ _merge_fs_interrupt_on(生成项, 用户项) graph.py:182
│ 用户同名条目覆盖生成项

HumanInTheLoopMiddleware(interrupt_on=merged) graph.py:876

graph.py 在三处走同一条路:主 agent(:871-876)、通用子 agent(:807-812)、声明式子 agent(:720-724)。合并函数只有一条规则:merged.update(user_interrupt_on)——用户写的赢(graph.py:195-198)。两边都空就返回 None,HumanInTheLoopMiddleware 干脆不挂。

9.2 谓词分两类:exact 与 bulk

_FS_TOOL_PATH_ARGS(_fs_interrupt.py:38-46)给每个工具标了四元组 (操作, 路径参数名, 作用域, 模式参数名):

工具操作路径参数作用域
read_file / write_file / edit_fileread / write / writefile_pathexact
ls / glob / grepreadpathbulk
deletewritefile_pathbulk
  • exact(_make_exact_when_predicate,:76-91):路径命中 interrupt 规则才弹。沿用先匹配者胜——前面有条 deny 命中了,就不弹人审,直接由工具报权限拒绝。
  • bulk(_make_bulk_when_predicate,:94-137):这类调用的路径是搜索根,可能带出任意后代,所以判据换成"搜索子树是否与规则 anchor 相交"(_paths_overlap)。

delete 被划进 bulk 而不是 exact,和 §8.3 是同一个理由:它删的是一棵树。

9.3 bulk 谓词堵的三个洞

攻击面堵法位置
不给 pathgrep(path=None) 搜全盘路径缺失就无条件触发:110-114
. 当 pathls(path=".") 绕过前缀比对validate_path 产出的 /. 归一成 /,让"根覆盖一切"的分支生效:119-125
用 pattern 改根glob(pattern="/secrets/**", path="/workspace")额外用 pattern 再判一次(_bulk_pattern_fires):129-134、:140-153

第三条的判定细节(:150-153):绝对性看原始 pattern,不看 anchor——因为 *.txt 这种相对模式的 anchor 会退化成 /,看起来像绝对路径。相对模式里带 .. 则一律触发,因为爬出去落在哪算不出来。

_glob_anchor 退化成 / 会导致保守过触发:FilesystemPermission 的文档串明写建议用 /secrets/** 这种带字面前缀的模式,别写 /**/secrets(filesystem.py:397-404)。

9.4 批准者拿到的四个选项

_build_interrupt_on_from_permissions 给每个条目配的是全套决策 ["approve", "edit", "reject", "respond"](:174)。为什么放开 edit 也安全?注释给了答案(:170-173):被改过的调用会重新进入工具,照样撞上工具内的 deny 前置检查;respond 则根本不执行工具。人始终是授权闸口。

9.5 记住这一条:权限不是可见性

libs/ARCHITECTURE.md:85 有一句结论式论断:权限不是可见性机制——模型仍会看见一个稍后可能被拒绝或被人审拦住的工具。

两者的分工必须分清,否则排障会找错地方:

症状该看哪
工具根本没出现在模型的工具表里中间件装配、profile 的 excluded_tools、后端能力探测(§4)
工具看得见但调用失败后端能力 + 权限规则(§8)

同样的分法也写在 libs/ARCHITECTURE.md:87


10. state 侧与超大内容的入口

10.1 FilesystemState:一个字段,一个 reducer

class FilesystemState(AgentState):
files: Annotated[
NotRequired[dict[str, FileData]],
DeltaChannel(_file_data_delta_reducer, snapshot_frequency=50),
]

filesystem.py:1066-1071。要点三条:

  • 只有 files 一个新字段,内容是"路径 → FileData"。
  • DeltaChannel 而不是普通 reducer:存增量而不是每步存全量,snapshot_frequency=50 每约 50 个 pregel step 打一次全量快照,给读取深度封顶。
  • 这是 StateBackend 的落盘处;别的后端不写这里(见 02-backends.md)。

两个 reducer 语义相同,签名不同:

reducer被谁调入参形态位置
_file_data_reducer传统 annotated reducer单个 dict:1009-1044
_file_data_delta_reducerDeltaChannel一个 step 内所有写入组成的 list:1045-1064

共同约定:None 是删除标记{"/a.txt": None} 表示把 /a.txt 从 state 里抹掉,而不是把它的值设成 None。delta 版只做一次 dict 拷贝,然后一遍扫完所有写入(:1045-1064)。

10.2 超大内容的三个入口(细节交给第 05 章)

本章只交代什么时候触发、东西去了哪;具体机制在 05-context-engineering.md

先统一两个承重词,后面各章一律这么用:

英文本书译名指什么源码里的痕迹
eviction驱逐把内容从上下文里赶出去(替换成存根)TOOLS_EXCLUDED_FROM_EVICTION_check_eviction_neededtool_token_limit_before_evict
offload卸载把内容写进后端文件,换回一个可读的路径_offload_tool_message_content{artifacts_root}/large_tool_results/

一次超大结果处理是这两件事的组合:先卸载到文件,再把上下文里的原件驱逐成存根。 两个词不是同义反复,别互换。

入口触发条件去向
wrap_tool_call_intercept_large_tool_result_process_large_message(:3458 / :3367 / :3135)工具结果文本 > 4 × tool_token_limit_before_evict(默认 20000 token){artifacts_root}/large_tool_results/<tool_call_id>
wrap_model_call_check_eviction_needed_apply_eviction_and_truncate(:3053 / :3211 / :3236)最后一条消息是超过 human_message_token_limit_before_evict(默认 50000 token)的未标记 HumanMessage{artifacts_root}/conversation_history/<uuid>.md
execute 的源头捕获 _resolve_capture(:2733)后端是 BaseSandbox 且卸载路径确实路由到它输出直接由 shell 写进捕获路径,不经过内存

三处的共同点:替换进上下文的是"提示 + 头尾预览",模型可以用 read_file 按需分页取回——这也是 §5.1 那段 system prompt 要告诉模型 /large_tool_results/ 前缀的原因。

两个不显然的细节:

  1. 有一批工具豁免驱逐:TOOLS_EXCLUDED_FROM_EVICTION(:1477-1486)排除了 ls/glob/grep(它们自带截断,结果太多说明查询该收窄)、read_file(卸载后模型又去 read 它,死循环)、edit_file/write_file/delete(回执本来就很短)。注释把三类理由分别写清了(:1445-1476)。
  2. HumanMessage 的驱逐只改请求、不改 state 内容:state 里存的仍是全文,只在 additional_kwargs["lc_evicted_to"] 上打个标(:3236-3284);每次请求时才现算截断版。用的是"同 id 覆盖"而非 REMOVE_ALL_MESSAGES 哨兵——后者会连带清掉模型节点在同一 super-step 写的 AIMessage(:3236-3284 的 docstring)。

11. 巧妙之处(可以带走的几条)

  1. 能力探测的结果同时改三处,而不只是删工具。execute 的同时换 grep 描述、换 execute 描述(:3021-3032)。工具表自洽了,模型才不会拿到自相矛盾的信息。
  2. 不能改的东西就写进提示词。 虚拟路径映射解决不了 shell 命令重写的问题,于是把替换规则告诉模型(_route_host_path_prompt,:1343)。这是"提示词即接口适配层"的一个干净样例。
  3. 超时用 wait() 而非 result(timeout=) 因为 Python 3.11+ 两种 TimeoutError 是同一个类,混用会把后端故障误报成用户模式太宽(:2341-2345)。这类坑很难在测试里被发现。
  4. 拒绝服务比排队诚实。 glob 的有界信号量满了就直接告诉模型"太忙,换个更窄的模式"(:2320-2325),而不是排队等一个取消不掉的任务。
  5. 权限的"结果侧"和"入参侧"是两套判据。 尤其递归删除必须换成子树相交检查(_wildcard_delete_overlap,:433),逐路径匹配在这里是有洞的。
  6. interrupt 结果不做二次过滤。 人刚批准的清单不能被静默清空(:620-631)——这类"授权后不要再自作主张"的规则,很容易在重构时被顺手加回去。

12. 边界与局限

  • 有沙箱就没有路径权限。execute 的后端加权限规则会直接抛 NotImplementedError(:1680-1687),除非规则全部落在 Composite 的路由前缀内。execute 层面的权限尚未实现
  • _permissions 是私有参数。 构造参数名带下划线,文档串明说"可能在未来挪到后端层"(:1655-1660)。
  • 无锚点的 interrupt 模式会过触发。 /**/secrets 的 anchor 退化成 /,任何 bulk 调用都会弹人审(filesystem.py:397-404)。
  • 超时的 glob worker 杀不掉。 只是被放弃,线程要等后端调用自己返回才释放 slot(:2346-2361)。
  • 权限路径不支持 ~ 显式抛 NotImplementedError(:406-418)。
  • 视频依赖是可选的。 探测用 find_spec,一个"能被发现但装坏了"的 av 会一路走到抽帧时才炸成 VideoExtractionError(_video.py:44-49)。

13. 代码地图(导航索引)

主题文件符号
中间件本体libs/deepagents/deepagents/middleware/filesystem.pyFilesystemMiddleware
八个工具的工厂同上_create_ls_tool_create_execute_tool
入参 schema同上LsSchemaReadFileSchemaReadVideoFileSchemaWriteFileSchemaEditFileSchemaDeleteSchemaGlobSchemaGrepSchemaExecuteSchema
工具描述常量同上READ_FILE_TOOL_DESCRIPTIONGREP_TOOL_DESCRIPTION_GREP_TOOL_DESCRIPTION_WITHOUT_EXECUTEEXECUTE_TOOL_DESCRIPTION
能力探测同上supports_execution_unsupported_tools_and_execution_state
请求期改写同上wrap_model_callawrap_model_call_filter_unsupported_tools_and_apply_prompt_with_filtered_grep_description
动态 prompt同上_build_fs_tools_section_FILESYSTEM_SYSTEM_PROMPT_TEMPLATEEXECUTION_SYSTEM_PROMPT_route_host_path_prompt
读文件分派同上_handle_read_result_get_read_file_type_handle_video_read_video_window_header
消息重排同上_move_media_results_after_tool_results_is_read_file_media_result
glob 超时同上_glob_timeout_messageGLOB_TIMEOUT_SYNC_GLOB_WORKERS
grep 结果成形同上_format_grep_tool_resultSEARCH_TRUNCATION_NOTE
权限规则与匹配同上FilesystemPermission_check_fs_permission_all_paths_scoped_to_routes
删除重叠判定同上_find_delete_deny_patterns_wildcard_delete_overlap
结果侧过滤同上_filter_paths_by_permission_filter_file_infos_by_permission_filter_grep_matches_by_permission
state 与 reducer同上FilesystemState_file_data_reducer_file_data_delta_reducer
驱逐与卸载入口同上wrap_tool_call_process_large_message_check_eviction_needed_apply_eviction_and_truncate_resolve_captureTOOLS_EXCLUDED_FROM_EVICTION
HITL 桥接libs/deepagents/deepagents/middleware/_fs_interrupt.py_build_interrupt_on_from_permissions_make_exact_when_predicate_make_bulk_when_predicate_bulk_pattern_fires_FS_TOOL_PATH_ARGS
装配挂载libs/deepagents/deepagents/graph.py_merge_fs_interrupt_on
路径与格式化工具libs/deepagents/deepagents/backends/utils.pyvalidate_path_glob_anchor_paths_overlapformat_content_with_line_numberscheck_empty_contenttruncate_if_too_long
视频抽帧libs/deepagents/deepagents/middleware/_video.pyvideo_dependencies_availableextract_video_frames
分层论断出处libs/ARCHITECTURE.md"Tool surface and filesystem access" 一节(:77-87)

相邻章节: index.md · 01-assembly-and-profiles.md · 02-backends.md · 04-subagents.md · 05-context-engineering.md · 06-skills-memory-rubric.md