deer-flow 读前写后门禁:ReadBeforeWriteMiddleware 如何用 sha256 版本闸杜绝“只追加不回读“的重复写入

发布时间:2026/9/7 1:49:21
deer-flow 读前写后门禁:ReadBeforeWriteMiddleware 如何用 sha256 版本闸杜绝“只追加不回读“的重复写入 deer-flow 读前写后门禁ReadBeforeWriteMiddleware 如何用 sha256 版本闸杜绝只追加不回读的重复写入【免费下载链接】deer-flowAn open-source long-horizon SuperAgent harness that researches, codes, and creates. With the help of sandboxes, memories, tools, skill, subagents and message gateway, it handles different levels of tasks that could take minutes to hours.项目地址: https://gitcode.com/GitHub_Trending/de/deer-flow本文以 deer-flow 仓库中的实施计划 2026-07-02-read-before-write-gate.md 为主体结合已落地的源码、配置与测试完整讲解ReadBeforeWriteMiddleware的设计目标、五条闸规则、read-mark 机制与中间件链装配方式。读完你将掌握如何在 LangChain/LangGraph Agent 中用消息内嵌内容哈希实现无状态的文件写入新鲜度护栏以及该门禁在 deer-flow 中的实际代码路径、配置项与验证方法。1. 背景issue #3857 的追加式重复产出失败模式deer-flow 是一个长时间任务long-horizonSuperAgent 框架主代理lead agent经常在分钟到小时级的任务里反复编辑产出文件。计划文档开篇即点明要解决的问题Goal:Deterministically blockwrite_file(append / overwrite-existing) andstr_replaceunless the agent has read the filescurrentversion, fixing issue #3857s output-layer duplicate-append failure.其对应的失败模式在中间件源码 docstring 中被描述得更为具体见 read_before_write_middleware.pyThe lead agents duplicate-output failure mode (the same report section appended five times) came from append-only, never read back writes.即代理对同一报告章节只追加、从不回读导致同一节内容在产物文件中被追加了五次。计划给出的解法是一个确定性的版本闸version gate修改已存在文件之前必须先用read_file读到该文件的当前版本做不到就拒绝写入并返回引导性错误逼代理去读。完整的行为规格定义在配套设计文档 2026-07-02-read-before-write-gate-design.md计划文档中将其列为全局约束的第一条Spec:docs/superpowers/specs/2026-07-02-read-before-write-gate-design.md。2. 核心架构read-mark 版本闸计划文档的 Architecture 一节给出了三条核心设计决策后文逐条展开新中间件ReadBeforeWriteMiddleware通过wrap_tool_call/awrap_tool_call拦截文件工具成功read_file后把sha256(完整文件内容)写入返回的ToolMessage.additional_kwargs[deerflow_read_mark]被门禁的写操作执行前中间件重新对文件求哈希要求state[messages]中该路径最新的 mark 与之匹配。2.1 五条闸规则行为规格与 issue 描述一致定义了明确的放行/拦截矩阵场景行为read_file成功记录该路径 read-marksha256(当前完整文件内容)带行号范围/被截断的读同样记 markhash 始终基于完整文件内容不是读到的片段write_file(appendTrue)/ 覆盖已存在文件 /str_replace执行前校验该路径最新 read-mark 的 hash 当前文件 hash不通过则拦截返回引导性错误请先读该文件对 append 场景提示只读尾部若干行即可控制上下文成本写入本身永不刷新 mark任何成功写入都会改变文件 hash → 上一次读立即过期 → 连续修改之间被逼重读。这是治重复追加的关键强制点write_file写不存在的文件新建放行不记 mark新建后的第一次 append/str_replace 也要求先读str_replace目标文件不存在放行由工具自身返回 not-found 错误拦截错误的措辞是刻意设计的引导语句见源码常量_BLOCK_MESSAGEread_before_write_middleware.py_BLOCK_MESSAGE ( Error: {tool_name} blocked — {path} already exists and you have not read its current version. Any write invalidates earlier reads, so re-read before every modification. Call read_file on it (a ranged read of the relevant section is enough, e.g. the last ~30 lines before an append), check what is already there, then retry. )措辞同时满足两个约束告诉模型恢复路径读文件、可用范围读、重试且不泄漏后端配置细节全局约束要求blocked-tool errors must not leak backend config keys/paths。2.2 mark 与上下文存活的绑定这是 issue #3857 的第三小项要求也是该设计最巧妙的地方mark不落 ThreadState而是附着在消息上。设计文档原文mark 不落 ThreadState而是附着在read_file返回的ToolMessage.additional_kwargs[deerflow_read_mark]上{path, hash}。闸校验时从state[messages]由新到旧扫描该路径的 mark。总结summarization删掉该 ToolMessage ⇒ mark 自然消失 ⇒ 闸拦截。闸通过但内容已被总结删掉被结构性排除无需保留区也无需 summarization hook。由于 deer-flow 对长对话做上下文总结summarization旧工具结果会被删掉。mark 随消息生灭意味着闸通过 ⇒ 模型此刻一定还看得见那次读取的内容反之内容被总结掉 ⇒ mark 消失 ⇒ 下一次写必然被拦。这是mark tied to context presence要求被结构性满足的方式不需要任何保留区或额外 hook。计划文档的测试也固化了这一语义test_mark_removed_by_summarization_blocks用一条namesummary的消息模拟总结后的上下文断言此时写入被拦截见计划文档 Task 2 的测试代码以及已落地的 test_read_before_write_middleware.py 中TestWriteGate类。还有一个设计文档明确记录的推论同一轮并行 readwrite 同一文件会被拦——此时模型尚未看到读结果拦截语义正确。2.3 fail-open 失败语义护栏的第一原则是不能把 agent 砖死。失败语义规定闸自身读文件/求 hash 出现意外错误非FileNotFoundError如二进制内容UnicodeDecodeError、沙箱瞬时故障→fail-open 放行并记日志让工具自己去产生真实错误非本地沙箱AIO/E2B的read_file会把读失败含文件不存在吞成Error: ...字符串而不抛异常闸读到以Error:开头的内容按无法检视处理 → fail-open 放行、不打标记。这样新建文件在这类沙箱上正常放行已存在文件的正常读写不受影响设计文档标注为 #3912 review 修复拦截返回ToolMessage(statuserror)措辞引导恢复路径不暴露后端配置细节。在落地源码中可以看到这两条语义的实现_check_write_gate对FileNotFoundError直接放行新建、对其他异常logger.warning(...allowing the write (fail-open)...)并对current.startswith(_UNINSPECTABLE_CONTENT_PREFIX)_UNINSPECTABLE_CONTENT_PREFIX Error:同样放行read_before_write_middleware.py。对应测试类TestErrorStringSandboxes已存在于 test_read_before_write_middleware.py。3. 实现拆解计划的五个 TDD 任务计划文档采用严格的先写失败测试 → 跑红 → 实现 → 跑绿 → 提交流程Tech StackPython 3.12、LangChainAgentMiddleware、LangGraphToolCallRequest、pytest全局约束要求后端 TDD 必须、完成后跑cd backend make format、所有后端命令从backend/目录以PYTHONPATH. uv run pytest ...执行。3.1 Task 1提取read_current_file_content共享 helper动机闸需要对代理即将看到的字节求哈希必须与read_file工具使用完全相同的路径解析规则skills 路径、ACP workspace 路径、user-data 路径、自定义挂载路径等分支。若中间件自己复制一遍解析逻辑两者迟早漂移。因此 Task 1 从 sandbox/tools.py 中提取共享 helper修改backend/packages/harness/deerflow/sandbox/tools.pyread_file_tool上方新增 helper接口deerflow.sandbox.tools.read_current_file_content(runtime, path: str) - str—— 用read_file的路径解析规则读完整当前内容文件不存在抛FileNotFoundError其他错误向上传播。计划给出的实现local 沙箱分支需处理_is_skills_path/_is_acp_workspace_path/_is_custom_mount_path/_resolve_and_validate_user_data_path等路径解析分支再调sandbox.read_file(path)随后被read_file_tool复用删除了原本重复在工具体顶部的 sandbox 初始化与路径解析代码且要求requested_path path在 helper 调用之前赋值保证错误消息仍显示原始路径。当前仓库中该 helper 的落地位置在 tools.pydocstring 明确其契约def read_current_file_content(runtime: Runtime | None, path: str) - str: Read the full current content of path using read_files resolution rules. Shared by read_file_tool and ReadBeforeWriteMiddleware (issue #3857) so the gate hashes exactly the bytes the read tool would see. Raises FileNotFoundError when the file does not exist; other sandbox errors propagate to the caller. 配套测试计划文档 Task 1 给出验证两点通过沙箱读取并走路径解析断言sandbox.read_file恰好被以解析后的路径调用一次FileNotFoundError原样传播。落地测试即 test_read_before_write_middleware.py 中的TestReadCurrentFileContent类。3.2 Task 2中间件核心 —— mark 打标 同步写门禁新建backend/packages/harness/deerflow/agents/middlewares/read_before_write_middleware.py接口ReadBeforeWriteMiddleware(content_readerNone)实现wrap_tool_call模块常量READ_MARK_KEY deerflow_read_markcontent_reader: Callable[[Any, str], str]默认为read_current_file_content可注入测试里用内存files字典模拟沙箱。计划文档给出了中间件的核心骨架此处摘录关键路径逻辑_READ_TOOLS frozenset({read_file}) _GATED_WRITE_TOOLS frozenset({write_file, str_replace}) def wrap_tool_call(self, request, handler): name request.tool_call.get(name) if name in _GATED_WRITE_TOOLS: blocked self._check_write_gate(request) # 不匹配 → 返回拦截 ToolMessage if blocked is not None: return blocked return handler(request) result handler(request) if name in _READ_TOOLS: self._attach_read_mark(request, result) # 读成功后打标 return result三个关键方法的语义_check_write_gate取出args.path非字符串/空则不门禁调_content_reader读当前内容FileNotFoundError→ 放行其他异常 → 记日志放行fail-open然后取state[messages]中该规范化路径最新的mark hash 与当前文件 hash 比较相等放行否则返回_BLOCK_MESSAGE填充的ToolMessage(statuserror)tool_call_id与工具名从tool_call原样带出。_latest_mark_hash从新到旧反向扫描messages跳过非ToolMessage读additional_kwargs[READ_MARK_KEY]匹配path 规范化路径的第一条即返回其 hash——最新 mark 胜出。_attach_read_mark仅对成功的读结果打标message.status error不打_content_reader失败则 debug 日志跳过支持从Command.update[messages]中提取最后一条ToolMessage。计划文档为 Task 2 写了完整的失败测试集落地于TestReadMarkStamping与TestWriteGate两个类覆盖以下断言要点值得作为实现自检清单成功读打标mark 为{path: 规范化路径, hash: sha256(全文)}范围读start_line3, end_line3打标的 hash 仍是全文line1\nline2\nline3的 sha256而非片段读结果为 error / reader 抛异常 →不打标非文件工具如bash完全透传、对象不变新文件写入放行已存在文件未读 →write_file覆盖、append 两种形态与str_replace均被拦handler 不得被调用返回的ToolMessage带原tool_call_id且内容含 read 字样str_replace目标文件不存在 → 透传给工具自身报错新鲜 mark 放行文件在读取后变化mark 对应 v1、当前 v2→ 拦截最新 mark 胜出消息里同时存在 v1 旧 mark 与 v2 新 mark 时按 v2 判定放行上下文只剩总结消息 → 拦截闸自身读文件失败RuntimeError→ fail-open 放行路径规范化匹配读的是/mnt/user-data/outputs/report.md写的是/mnt/user-data/outputs/../outputs/report.mdposixpath.normpath后一致 → 放行。3.3 Task 3异步路径awrap_tool_call固化计划全局约束明确要求异步 hook 不得在事件循环上跑阻塞 IO引用了sandbox/tools.py中_run_sync_tool_after_async_sandbox_init的asyncio.to_thread/ensure_sandbox_initialized_async模式。Task 3 为awrap_tool_call写三个固化测试落地为TestAsyncPaths类test_async_block未读即写 →asyncio.run(mw.awrap_tool_call(...))返回 errorhandler 不得运行test_async_read_stamps_mark异步读成功后 mark 的 hash 等于全文 sha256test_async_allowed_write_calls_handler带新鲜 mark 的写正常调用 handler。落地实现中异步路径把_check_write_gate与_attach_read_mark放进asyncio.to_thread执行read_before_write_middleware.py避免同步沙箱 IO 阻塞事件循环。3.4 Task 4配置模型与中间件链装配新建backend/packages/harness/deerflow/config/read_before_write_config.py修改backend/packages/harness/deerflow/config/app_config.py新增read_before_write字段位于loop_detection之后修改backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py的_build_runtime_middlewarestail 层修改 config.example.yaml新增配置段并bumpconfig_version计划时 16 → 17落地后上游 16 被max_recursion_limit占用最终落为 17当前仓库该值已演进至 40见 config.example.yaml更新链序 pin 测试backend/tests/test_tool_error_handling_middleware.py中的expected_order。配置模型read_before_write_config.py只有一个字段默认开启——对应 issue 第 4 点护栏要真正生效class ReadBeforeWriteConfig(BaseModel): Deterministic version gate on file-modifying tools. ... enabled: bool Field( defaultTrue, descriptionWhether to block writes to existing files that were not read at their current version, )config.example.yaml中的配置段config.example.yaml# Read-Before-Write File Gate (issue #3857) # Blocks write_file (append / overwrite of an existing file) and str_replace # unless the agent has read the files current version first; any write # invalidates earlier reads, forcing a re-read between consecutive edits. # Deterministic guardrail against blind duplicate appends in long tasks. read_before_write: enabled: true链装配位置tool_error_handling_middleware.py在_build_runtime_middlewares的 tail 层、SandboxAuditMiddleware之后 /ToolErrorHandlingMiddleware之前按配置条件挂载。源码注释还说明了更深一层的顺序约束——它必须是最外层的写门禁位于ToolProgress与ToolErrorHandling之外这样被拦截的写立即返回、不占用 ToolProgress 槽位并且中间件会给被拦截的ToolMessage自己打上deerflow_tool_meta通过normalize_tool_result使下游拿到格式完整的结果。计划文档的TestChainWiring三个测试固化了装配契约默认链中存在该中间件且顺序为SandboxAuditMiddleware ReadBeforeWriteMiddleware ToolErrorHandlingMiddlewareenabledFalse时从链中移除build_subagent_runtime_middlewaressubagent 也拿到该门禁对应 issue 的通用机制要求。3.5 Task 5工具 docstring 与文档同步让模型能自解释被拦原因需要把规则写进工具描述。落地后的 docstring 增补tools.py# write_file_tool: READ-BEFORE-WRITE (issue #3857): if the target file already exists (including appendTrue), you must have read its CURRENT version with read_file first. Any write invalidates earlier reads, so re-read between consecutive modifications — a ranged read of the relevant section is enough. Writes that fail this check are rejected with an error. # str_replace_tool: READ-BEFORE-WRITE (issue #3857): you must have read the files CURRENT version with read_file first; any write invalidates earlier reads.同时更新 backend/AGENTS.md 的 Middleware ChainShared runtime base 列表中SandboxAuditMiddleware之后插入条目与 Sandbox Tools 小节并注明write_file/str_replacesubject to the read-before-write gate whenread_before_write.enabled。收尾验证命令为cd backend make format make lint make test。4. 落地实现相对计划的演进并发锁与错误串通道计划文档是规划态而当前仓库的 read_before_write_middleware.py 在实现层面又吸收了后续 review#3912的两处关键强化读源码时值得对照1per-(scope, path) 临界区锁。LangGraph 会并发执行同一条 AIMessage 里的多个 tool_calls。若无锁同一轮两个同路径写可能都读到同一个旧 hash、双双通过门禁随后互相覆盖——这正是重复追加的并发变体。实现为此维护了一个WeakValueDictionary[(scope, norm_path), threading.Lock]read_before_write_middleware.py把闸校验 写入执行和读取执行 打标分别放进同一临界区同轮第二个同路径写必须等第一个完成后再校验此时 hash 已变 → 确定性拦截读侧锁则保证 mark 哈希的永远是模型实际看到的那个版本。锁的 scope 取自runtime.context.thread_id回退到state[sandbox][sandbox_id]再回退global使无关 agent 互不竞争_lock_scopeL202-L217。设计文档同时说明该锁与工具内部的file_operation_lock分属不同命名空间、无嵌套获取不会死锁异步路径中threading.Lock跨线程释放是安全的acquire 在 worker 线程、release 在事件循环线程。对应测试类TestSamePathSerialization已存在于测试文件。2Error:字符串读通道。如 2.3 所述AIO/E2B 类沙箱把读失败吞成Error: ...字符串。实现用模块常量_UNINSPECTABLE_CONTENT_PREFIX Error:在门禁侧_check_write_gate与打标侧_attach_read_mark同时识别该前缀一律 fail-open 且不打标避免错误串被当成内容哈希造成误拦或伪 mark对应TestErrorStringSandboxes。3沙箱授权作用域。落地版本在wrap_tool_call/awrap_tool_call中用sandbox_authorization_scope/sandbox_authorization_scope_async包住门禁与 handler 调用SandboxAuthorizationError转成标准的工具级拒绝ToolMessage带stamp_exception_meta而不是 fail-open 或让整个 run 失败L122-L135。5. 验证方式测试矩阵与运行命令整套行为由 backend/tests/test_read_before_write_middleware.py 的七个测试类完整固化与计划/设计文档的 TDD 覆盖一一对应测试类对应计划任务覆盖点TestReadCurrentFileContentTask 1helper 的路径解析与异常传播TestReadMarkStampingTask 2打标、范围读打全文 hash、错误不打标、非文件工具透传TestWriteGateTask 2五条闸规则、最新 mark 胜出、总结失效、fail-open、路径规范化TestAsyncPathsTask 3awrap_tool_call的拦截/打标/放行TestChainWiringTask 4默认装配、链序、enabledFalse移除、subagent 链TestErrorStringSandboxes#3912 修复AIO/E2B 错误串通道 fail-open 且不打标TestSamePathSerialization#3912 修复同轮同路径并发写的确定性拦截链序 pin 测试则位于 test_tool_error_handling_middleware.pytest_build_lead_runtime_middlewares_chain_order_matches_agents_md把中间件顺序与backend/AGENTS.md的文档声明绑定防止装配漂移。运行方式遵循计划全局约束cd backend PYTHONPATH. uv run pytest tests/test_read_before_write_middleware.py -v cd backend make format make lint make test6. 边界与已知限制设计文档诚实地记录了不在本项处理范围的边界引用原文要点语义重复仍可能发生读了现状后模型仍可能决定再追加同一节。结构化产物状态与终稿去重校验是独立后续项本机制定位是新鲜度护栏保证agent 读过当前版本不保证最终产物无语义重复bash修改文件不走闸但它会改变文件 hash使后续write_file/str_replace被逼重读方向一致TOCTOU 窗口设计文档初版记录了闸校验与实际写入之间存在极窄 TOCTOU 窗口并标记为可接受随后按 #3912 review 意见用 per-path 临界区消除原文中该条以删除线标注并指向并发语义一节适用前提门禁对 local 与 AIO/E2B 沙箱一致生效依赖read_current_file_content的统一解析在错误串读通道上退化为纯 fail-open即这类沙箱上对已存在文件未读的拦截能力受通道限制。7. 小结ReadBeforeWriteMiddleware展示了在 Agent 系统中落地确定性护栏的一个完整范式状态不留在工具里而留在消息里mark 附着于ToolMessage.additional_kwargs随上下文生灭判定不依赖模型自觉而依赖 sha256 全文哈希比对失败语义永远偏向放行fail-open把砖死 agent的风险留给工具自身的错误处理并发语义显式建模per-(scope, path) 临界区消除同轮双写窗口。从 计划文档 的五个 TDD 任务到 设计规格 的行为矩阵再到 落地源码、配置模型 与 测试矩阵这条读前写后链路在 deer-flow 中是默认开启read_before_write.enabled: true、对 lead agent 与 subagent 共同生效的通用文件写入护栏。【免费下载链接】deer-flowAn open-source long-horizon SuperAgent harness that researches, codes, and creates. With the help of sandboxes, memories, tools, skill, subagents and message gateway, it handles different levels of tasks that could take minutes to hours.项目地址: https://gitcode.com/GitHub_Trending/de/deer-flow创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考