加载中...
  • 本地文件、图片附件与可回滚写入:让 Agent 改得起也退得回 loading

    吃透 AI Agent 开发 · 第 26 篇 · 第五章 · 规划与任务

    路径边界、图片临时注入、写前快照和冲突检测共同决定 Agent 改文件是否可控。

    用户说:“看看这张报错截图,再改一下项目里的配置。”一句话同时碰到三类数据:图片附件、项目文件和即将发生的写入。它们都在本机,却不能用同一种方式处理。

    文件能力的目标不是简单地“能读能写”,而是让内容在正确的范围、正确的生命周期里流动,并且在写错时有恢复机会。

    路径检查不能停在字符串

    下面这个检查看似合理:

    if (!target.startsWith(cwd)) throw new Error('Outside workspace')
    

    它可能被 C:\project-old、大小写差异、.. 和符号链接绕过。更稳妥的流程是:

    1. 基于明确根目录解析输入路径。
    2. 规范化分隔符和盘符大小写。
    3. 对已存在路径解析真实路径,消除符号链接。
    4. 使用路径相对关系判断是否位于允许目录。
    function isInside(root: string, target: string): boolean {
      const relative = path.relative(root, target)
      return relative === ''
        || (!relative.startsWith('..') && !path.isAbsolute(relative))
    }
    

    Windows 下比较前还要统一盘符大小写。不要重新打开一个“允许访问任意路径”的总开关,那会让所有精细边界失去意义。确有需要时,按能力分别配置受信只读目录、绝对附件和 Shell 工作目录。

    读和写不是同一权限

    Agent 可能需要读取用户级 Skills 或配置说明,但这不代表它可以修改这些目录。权限模型应该区分:

    • 工作区内可读写。
    • 受信用户目录只读。
    • 显式批准的外部附件只读。
    • 其他位置拒绝访问。

    “路径在白名单里”也不自动意味着内容适合进入模型。.env、私钥和凭据文件应有单独的敏感规则。

    附件只属于这一轮

    图片需要作为二进制或 base64 进入多模态请求。如果把完整图片写进 transcript,会话文件会急速膨胀,压缩和审计也可能意外携带原始数据。

    更合理的生命周期是:

    本地附件
      -> 校验路径、格式、大小和数量
      -> 构造本轮模型消息
      -> 请求结束后释放正文
    
    会话与审计只保存:类型、大小、脱敏名称、hash、是否成功
    

    普通文本附件也应有单文件和总量预算。文件过大时给出截断提示,或让模型通过读取工具按范围获取。

    写前快照要卡在正确位置

    为了支持回滚,文件快照既不能太早,也不能太晚。

    正确位置通常是:pre-tool Hook 已经放行,真实写入尚未发生。太早会为最终被阻止的操作制造无用快照;太晚则已经失去原始内容。

    参数与权限检查
      -> pre-hook
      -> 保存写前快照
      -> 执行写入
      -> 记录写后 hash / size / mode
      -> post-hook 与审计
    

    如果快照失败,写操作也应该失败。允许“先写,快照以后补”,就无法保证这次修改可恢复。

    回滚前为什么还要检查冲突

    Agent 写完文件后,用户可能手动修改了同一个文件。如果回滚直接覆盖,恢复 Agent 修改的同时也抹掉了用户的新内容。

    因此每次成功写入后,要记录文件的 hash、大小和权限。回滚前重新计算当前状态:

    if (currentHash !== snapshot.knownAfterHash) {
      throw new Error('File changed after the Agent write; refusing to overwrite')
    }
    

    遇到冲突时可以展示 diff,让用户决定,而不是偷偷选择一边。

    回滚的边界要诚实

    如果文件历史只追踪内置写工具,那么 Shell 中执行的重定向、格式化器和外部进程写入就不在保证范围内。界面应明确说明,而不是给用户“所有修改都能撤销”的错觉。

    想扩大覆盖范围,可以在更底层使用文件系统监控或工作区快照,但成本和误报都会上升。首版只保证可控写入口,往往是更稳妥的选择。

    大文件与二进制文件

    快照所有文件全文可能占用大量空间。可以按类型设置策略:小文本直接备份;大文本压缩;二进制和超大文件要求显式确认或只记录元数据。无论哪种策略,都要在写之前确定是否具备恢复能力。

    备份正文不要进入 transcript 或审计 payload。它属于数据面,应该放在受控存储目录;会话只保留 snapshot ID、路径摘要和 hash 等控制信息。

    用 Conflict-aware Rewind 做个小练习

    实现一次可回滚写入:

    1. 创建 demo.txt,内容为 A
    2. 写前保存快照,把内容改成 B,记录写后 hash。
    3. 用户再手动把内容改成 C
    4. 执行回滚,预期检测冲突并拒绝覆盖。
    5. 把内容恢复成 B 后再次回滚,预期得到 A

    这个练习能直接说明:快照解决“有旧版本”,hash 检查解决“现在还能不能安全恢复”。

    Attachment 到 Conflict-aware Rewind 的小结

    本地文件能力包含四条边界:真实路径决定能否访问,读写权限分别控制能力,附件正文只在必要生命周期内存在,写前快照和写后 hash 共同保证可回滚。

    下一篇讨论这些过程如何呈现给用户。模型在流式输出,工具在运行,后台任务也在发事件,界面怎样保持稳定而不反过来绑住核心逻辑?

    回滚前最重要的问题:文件还是不是那一版

    Agent 写完文件后,用户可能手动修改,也可能有格式化器继续写入。此时直接恢复旧快照会覆盖新内容。可靠回滚需要保存写前正文、写后 hash、文件大小和 mode;恢复前先比对当前文件是否仍等于最近一次已知写后状态。

    sequenceDiagram
      participant A as Agent 写工具
      participant H as File History
      participant F as 文件系统
      A->>H: 写前快照
      H->>F: 读取正文与 metadata
      A->>F: 写入新内容
      F-->>H: 写后 hash/size/mode
      Note over F: 用户或外部进程可能继续修改
      A->>H: rewind
      H->>F: 比对当前 hash
      H-->>A: restore 或 conflict
    

    四条路径组成文件能力

    观察位置 源码 要验证的约束
    1. 图片附件 src/attachments/index.ts 正文只进本轮模型请求,不进 transcript
    2. 文件历史 src/file-history/index.ts 快照发生在放行之后、真实写入之前
    3. 路径策略 src/tools/path-policy.ts 绝对路径和 symlink 怎样限制
    4. 文件引用 src/mentions/file-mentions.ts @file 如何校验并截断内容

    失败反例:只按字符串判断路径

    只按字符串判断路径,或回滚前不检查文件已被别人修改,导致覆盖新内容。另一个常见错误是把完整附件或文件快照写进会话记录,恢复虽然方便,却扩大了隐私和存储风险。

    文件正文与控制元数据要分开:transcript 只保留路径摘要、hash 和快照 id,真正备份放在受控目录;快照失败必须阻止写入,否则系统会产生无法兑现的“可回滚”承诺。

    练习:制造一次回滚冲突

    让 Agent 把 a.txt 从 A 改为 B,记录写后 hash;随后人工改成 C,再执行 rewind。正确结果应是冲突提示和差异摘要,而不是恢复 A。再测试新文件创建、文件删除和 mode 变化,明确哪些场景支持自动恢复,哪些必须人工确认。

    路径策略要处理“目标尚不存在”

    写新文件时无法对目标本身调用 realpath。可以先解析最近存在的父目录 realpath,再把剩余相对段规范化拼接并检查边界;真正创建前再次确认父目录没有被替换成 symlink。

    这仍存在检查与使用之间的竞态。高安全场景应使用目录句柄和 OS 级相对打开能力,普通本地 Agent 至少缩短间隔、禁跟随危险 symlink,并在写后记录解析结果。

    Windows 的 junction、UNC 路径、盘符大小写和保留设备名都要进入 fixture。CONNUL 等不是普通文件名,错误处理应清楚,不能在不同 Node 版本上产生意外行为。

    @file 注入先给摘要,再给正文

    用户显式引用文件是高相关信号,但一个 8MB 日志不能直接塞进模型。解析 mention 后先返回路径摘要、大小、类型、截断范围和内容 hash;文本在单文件与总预算内注入,超出部分按需读取。

    候选索引缓存可以加速补全,watcher 失败时保留旧列表并提示 stale。最终读取仍访问磁盘当前版本,候选摘要不冒充正文。

    允许绝对路径需要单独开关并写审计。即使开关开启,敏感文件规则与大小限制仍生效;文件引用不能借机扩大写权限。

    图片附件要验证内容,不只看扩展名

    名为 error.png 的文件可能不是 PNG。检查 MIME magic、解码能力、像素尺寸、文件大小和数量,防止超大图片或解压炸弹。剪贴板临时文件也放在受控目录,任务结束按策略清理。

    EXIF 可能包含地理位置、设备和时间。若任务不需要,可在发送 provider 前去除;审计只保存类型、尺寸、hash 和脱敏名称,不保存 base64 或绝对路径。

    模型请求失败后是否重用附件要明确。临时文件尚在且 hash 一致可以重试,已删除则提示重新附加,不能从 transcript 中恢复不存在的正文。

    多张图片按用户顺序保留 attachmentId。界面缩略图、模型 message part 和审计摘要都用同一 ID,排障才知道模型漏的是哪一张。

    写工具需要一个小事务

    一次 edit_file 的稳定顺序是:读取当前内容与 metadata,应用 patch 到内存,验证结果,Hook 放行,保存快照,原子写临时文件并 replace,读取写后状态,追加历史与审计。

    read-before -> patch-in-memory -> pre-hook -> snapshot
    -> temp-write -> atomic-replace -> verify-after -> receipt
    

    patch 找不到唯一匹配时停在写前,不做猜测;临时写成功但 replace 失败,删除临时文件并保留原件;replace 成功但 history metadata 写失败,则报告“文件已修改但历史记录不完整”,不能假装整个调用没发生后自动重试。

    真正的多文件原子事务很难。可以先为每个文件独立快照,按顺序写,某一项失败时提出补偿回滚;补偿也可能冲突,所以结果要列出每个文件的终态,不返回一个笼统 success。

    Snapshot 要区分存在、内容和 mode

    修改既有文件保存旧正文和 mode;创建新文件记录 existedBefore: false,回滚意图是删除,但若后来有人修改则冲突;删除文件保存正文与 mode,回滚意图是重建;rename 需要同时跟踪源和目标。

    interface FileSnapshotMeta {
      pathKey: string
      existedBefore: boolean
      beforeHash?: string
      beforeSize?: number
      beforeMode?: number
      knownAfterHash?: string
      operation: 'create' | 'update' | 'delete' | 'rename'
    }
    

    空文件的 hash 与“不存在”不能混为一谈。权限 mode 变化在 Windows 和 POSIX 语义不同,恢复器按平台能力处理并说明未恢复项。

    按用户轮次组织文件历史

    用户说一句话,Agent 可能连续写三个文件。/rewind 1 更符合预期的是撤销最近一个用户轮次的内置写工具,而不是只撤销最后一个文件。每次写 snapshot 关联 sessionId、turnId、callId 和顺序。

    回滚前先生成 diff 摘要与冲突列表;任一冲突是否阻止整轮恢复,需要明确策略。保守做法是先不写,要求用户选择;避免一半文件恢复、一半冲突后留下更难解释状态。

    文件正文备份放 <Q_CODE_HOME>/file-history/...,transcript 只保存 snapshot metadata。这样会话仍轻量,备份也不会上传外部 trace。

    Shell 写入为什么暂时不进入同一保证

    Shell 可以修改任意数量文件,格式化器还会启动子进程。想在执行前知道所有目标,需要文件系统快照、容器层或 watcher,成本远高于包裹 write_file

    首版只追踪内置写工具是合理边界,但 UI 在执行 Shell 写命令时明确提示“不受 rewind 完整保护”。不要观察到 Shell 结束后才给变化文件补快照,那时旧正文已经丢了。

    若项目需要更强保证,可以让写任务在 Git worktree 中运行,执行前要求 clean,结束后用 diff 作为整体 artifact;这提供隔离和人工恢复,不等于逐文件 snapshot。

    回滚也要经过当前权限

    历史上允许写某路径,不代表今天仍允许。恢复器重新解析目标、检查当前 cwd 和敏感规则;项目已移动或文件变成外部 symlink 时停止。旧 snapshot 不是绕过路径策略的通行证。

    同时避免普通 post-hook 修改恢复正文。回滚目标是精确恢复历史字节,Hook 可以阻断和审计,不应悄悄格式化;需要格式化时作为回滚后的新动作,由用户知道。

    存储清理与可恢复承诺一致

    快照按项目、会话和轮次索引。活跃会话与 trash 保留期内的历史不应提前清理;purge 时列出会话、snapshot 和 artifact 数量。清理失败不影响原文件,但要报告磁盘占用。

    大文件可以拒绝自动 snapshot 或使用内容寻址去重。只记录 hash 不保存正文,不能称为可回滚;界面在写前就说明该文件超出保护范围,并要求额外确认。

    一次端到端文件演练

    附加一张带 EXIF 的截图,引用一个工作区文本,创建新文件、修改既有文件、删除第三个文件。确认模型请求含图片正文,session 仅有摘要;三个 snapshot 类型正确。

    随后人工改动第二个文件并执行按轮 rewind。系统应先报告冲突,未触碰任何文件;用户解决冲突后再次执行,创建项被删除、修改项恢复、删除项重建,hash 与 mode 可核对。最后从审计只能看到 callId、大小和 hash,看不到图片或备份正文。

    本文目录
    本文目录