吃透 AI Agent 开发 · 第 26 篇 · 第五章 · 规划与任务
路径边界、图片临时注入、写前快照和冲突检测共同决定 Agent 改文件是否可控。
用户说:“看看这张报错截图,再改一下项目里的配置。”一句话同时碰到三类数据:图片附件、项目文件和即将发生的写入。它们都在本机,却不能用同一种方式处理。
文件能力的目标不是简单地“能读能写”,而是让内容在正确的范围、正确的生命周期里流动,并且在写错时有恢复机会。
路径检查不能停在字符串
下面这个检查看似合理:
if (!target.startsWith(cwd)) throw new Error('Outside workspace')
它可能被 C:\project-old、大小写差异、.. 和符号链接绕过。更稳妥的流程是:
- 基于明确根目录解析输入路径。
- 规范化分隔符和盘符大小写。
- 对已存在路径解析真实路径,消除符号链接。
- 使用路径相对关系判断是否位于允许目录。
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 做个小练习
实现一次可回滚写入:
- 创建
demo.txt,内容为A。 - 写前保存快照,把内容改成
B,记录写后 hash。 - 用户再手动把内容改成
C。 - 执行回滚,预期检测冲突并拒绝覆盖。
- 把内容恢复成
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。CON、NUL 等不是普通文件名,错误处理应清楚,不能在不同 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,看不到图片或备份正文。