deer-flow/docs/superpowers/specs/2026-07-02-read-before-write-gate-design.md
Nan Gao b81334ccfe
feat(middlewares): deterministic read-before-write version gate for file tools (#3911) (#3912)
* docs(specs): read-before-write gate design for issue #3857 output layer

* docs(plans): read-before-write gate implementation plan (#3857)

* refactor(sandbox): extract read_current_file_content helper (#3857)

* feat(middlewares): read-before-write version gate for file tools (#3857)

* test(middlewares): pin async read-before-write gate paths (#3857)

* feat(config): wire ReadBeforeWriteMiddleware into runtime chain, default on (#3857)

* docs(sandbox): document read-before-write gate in tool docstrings and AGENTS.md (#3857)

* docs(plans): align plan doc with landed config_version (17) and drop machine-specific paths

Addresses Copilot review comments on #3912.

* fix(middlewares): read-before-write gate — error-string sandboxes fail open; serialize gate+execution per path (#3912 review)

- AIO/E2B read_file reports failures (incl. missing files) as 'Error: ...'
  strings instead of raising; the gate treated that string as existing file
  content and blocked first-write creation. Error-string reads now count as
  uninspectable: gate fails open, no mark is stamped.
- LangGraph runs one AIMessage's tool calls concurrently, so two same-turn
  writes could both pass on one stale mark before either mutation landed
  (and a read mark could hash a version the model never saw). Gate check +
  tool execution (and read + mark stamping) now share a per-(thread, path)
  critical section, separate from the tool-internal file_operation_lock.
2026-07-03 17:21:04 +08:00

5.6 KiB
Raw Blame History

产物层修改已存在文件前强制先读ReadBeforeWriteMiddleware— 设计

对应 issue #3857parent产物层子项。目标用确定性的"版本闸"逼 agent 在修改已存在文件前看见文件现状,治"只追加、不回读"导致的重复产出Case 2并作为通用的文件写入新鲜度护栏。

定位(采纳 issue 评论区共识):本机制是新鲜度护栏——保证"agent 读过当前版本",不保证"最终产物无语义重复"。后者(结构化产物状态、终稿去重校验)是独立后续项。

行为规格

闸规则(与 issue 描述一致):

  1. read_file 成功 → 系统记录该路径的 read-marksha256(当前完整文件内容)。带行号范围/被截断的读同样记 markhash 始终基于完整文件内容)。
  2. write_file(append=True)write_file 覆盖已存在文件、str_replace 执行前:校验"该路径最新 read-mark 的 hash == 当前文件 hash"。不通过 → 拦截,不执行写入,返回引导性错误("请先读该文件";对 append 场景提示读尾部若干行即可,控制上下文成本)。
  3. 写入永不刷新 mark任何成功写入都会改变文件 hash → 上一次读立即过期 → 连续修改之间被逼重读。这是治 Case 2 的关键强制点。
  4. write_file 写不存在的文件(新建)→ 放行,不记 mark新建后的第一次 append/str_replace 也要求先读)。
  5. str_replace 目标文件不存在 → 放行,由工具自身返回 not-found 错误。

read-mark 与上下文存活的绑定issue 第三小项):

  • mark 不落 ThreadState而是附着在 read_file 返回的 ToolMessage.additional_kwargs["deerflow_read_mark"] 上({path, hash})。
  • 闸校验时从 state["messages"] 由新到旧扫描该路径的 mark。总结summarization删掉该 ToolMessage ⇒ mark 自然消失 ⇒ 闸拦截。"闸通过但内容已被总结删掉"被结构性排除,无需保留区,也无需 summarization hook。
  • 推论:同一轮并行 read+write 同一文件会被拦——此时模型尚未看到读结果,拦截语义正确。

失败语义

  • 闸自身读文件/求 hash 出现意外错误(非 FileNotFoundError如二进制 UnicodeDecodeError、沙箱瞬时故障→ fail-open 放行并记日志。护栏不应把 agent 砖死。
  • 非本地沙箱AIO/E2Bread_file 把读失败(含文件不存在)吞成 "Error: ..." 字符串而不抛异常:闸读到以 Error: 开头的内容按"无法检视"处理 → fail-open 放行、不打标记。新建文件因此在这类沙箱上正常放行;已存在文件的正常读写不受影响(#3912 review 修复)。
  • 拦截返回 ToolMessage(status="error"),措辞引导恢复路径,不暴露后端配置细节。

并发语义(#3912 review 修复)

  • LangGraph 会并发执行同一条 AIMessage 里的多个 tool_calls。中间件对每个 (thread, 规范化路径) 持有独立锁,把"闸校验 + 写入执行"以及"读取执行 + 打标"分别放进同一临界区同轮第二个同路径写必须等第一个完成后再校验hash 已变 → 确定性拦截);读的标记保证 hash 的是模型实际看到的那个版本。该锁与工具内部的 file_operation_lock 分属不同命名空间,无嵌套获取,不会死锁。

实现结构

  • 新文件 packages/harness/deerflow/agents/middlewares/read_before_write_middleware.pyReadBeforeWriteMiddleware(AgentMiddleware),实现 wrap_tool_call / awrap_tool_call
    • 读状态由中间件逻辑持有/解释工具零改动、不持状态issue 要求)。
    • 当前文件内容读取复用 deerflow.sandbox.tools 的路径解析与沙箱读取逻辑(提取一个共享 helper避免复制 _resolve_* 细节);对 local 与 AIO 沙箱一致生效。
    • mark 的路径键:对 agent 提供的虚拟路径做 posixpath.normpath 规范化。
  • 装配位置:tool_error_handling_middleware.py::_build_runtime_middlewarestail 层、SandboxAuditMiddleware 之后 / ToolErrorHandlingMiddleware 之前 → lead 与 subagent 共同生效issue通用机制。#3809 的链序 pin 测试同步更新。
  • 配置:新增 deerflow/config/read_before_write_config.pyenabled: bool = True),挂到 AppConfig.read_before_writeconfig.example.yaml 增段并 bump config_version。默认开启issue 第 4 点:护栏要真正生效)。
  • 工具描述:write_file / str_replace docstring 增补"目标文件已存在时须先读到当前版本,过期写入会被拒绝"的提示,使模型可自解释错误。

已知边界(记录,不在本项处理)

  • 语义重复仍可能发生读了现状仍决定再追加同一节——ShenAC-SAC 评论指出的产物状态/终稿校验属后续项。
  • bash 修改文件不走闸;但它改变 hash会使后续 write_file/str_replace 被逼重读,方向一致。
  • 闸校验与实际写入之间存在极窄的 TOCTOU 窗口(并行工具调用),作为护栏可接受。 该窗口已按 #3912 review 意见通过 per-path 临界区消除(见"并发语义")——同轮并行重复写正是 Case 2 的变体,不应写掉。

测试TDDbackend/tests/test_read_before_write_middleware.py

覆盖新建放行未读改已存在文件overwrite/append/str_replace拦截读后放行写后 mark 过期、再写拦截、重读后放行mark 随消息被删(模拟总结)后拦截;范围读也记 markhash 失败 fail-openstr_replace 不存在文件放行;enabled=False 全透传;同步与异步路径;链序 pin 测试更新。