文件管理 Agent 实战:当操作不可逆时,Guardrails 怎么落地
运行一个带显式审批和持久撤销日志的文件管理 Agent:限制工作区、预览提案、备份删除内容,并通过真实临时文件测试验证跨进程恢复与冲突拒绝。
Hex4C59 18 分钟阅读 更新于 2026-10-17
AI 与 Agent·原创·实战教程
前面的 CLI Coding Agent 会读写代码,Research Agent 会搜索和整理资料。文件管理要多回答一个问题:模型提出“把它删掉”时,程序怎样在执行前等待人确认,又怎样在执行后保留恢复所需的内容?
本文面向会写简单 Python、了解文件路径的读者。你将运行一个完整的命令行(CLI)示例:先预览移动和删除提案,再批准执行;关闭本次程序后,重新调用它,恢复被移动或删除的合成文件。默认运行使用本地固定模型响应,文件操作和 SQLite 日志是真实的;SQLite 把操作记录保存在本地数据库文件中,不访问模型服务。
这里的 Guardrails 指程序执行的约束:拒绝越界路径、拒绝覆盖、等待用户批准、核对文件有没有变化。它们写在执行代码里,不依赖模型是否记住提示。
先给结论 #
- 模型只能列目录、提交提案、查看预览,拿不到执行和批准工具。真实文件变更由另一次 CLI 调用读取用户的完整确认口令后完成。
- “删除”先保存原始字节,再从业务目录移除文件。因此本例支持带备份删除,不提供永久擦除。
- 待确认队列和撤销记录保存在工作区内的 SQLite 文件中。新应用进程会读取同一份日志,撤销不再依赖上一个进程的内存。
- 执行、撤销和恢复都不覆盖已有路径。原路径被重新占用、目标被修改或日志无法确认时,程序明确拒绝,不猜测哪个文件可以丢弃。
- 验证覆盖临时文件与应用恢复流程。固定响应不证明模型会正确整理你的真实目录;路径检查也不是操作系统沙箱。
下载并跑通一次 #
需要 Python 3.11 或以上版本。先下载 文件管理 Agent 完整示例,将 ZIP 放到一个独立目录,再执行:
unzip file-management.zip -d file-management-demo
cd file-management-demo
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python app.py下载包包含 app.py、test_app.py、README、共享 agent_runtime.py 和依赖文件。解压后可以直接运行,不需要修改 Python 导入路径。
以下是本次运行默认演示时捕获的标准输出。fixture 表示模型响应由本地脚本固定提供,不是真实模型调用:
[fixture 本地固定响应] model=complete; pending=2
[fixture] dry-run: notes=True; trash=True
[fixture] approved: moved=True; deleted=True
[fixture] reopened journal: delete_undo=True; move_undo=True; bytes_restored=True
[fixture] temporary workspace cleaned on exit; no live model called前两行说明模型只生成了两条提案,两个原文件仍在。演示随后为这两个已知的临时文件提供固定审批口令,执行移动和删除,再重新创建应用对象撤销,检查恢复的字节。退出时临时目录被清理,程序不会把 HOME 或当前仓库当作业务工作区。独立 CLI 进程之间的恢复在后文测试中另外验证。
把提案和执行分开 #
本例把功能分为四个边界明确的部分:
| 部分 | 输入 | 实际行为 |
|---|---|---|
| 共享模型循环 | 任务、工具定义、已有结果 | 请求模型,按顺序调用工具,把结果交回模型 |
| 模型可见工具 | 相对路径和操作类型 | 读目录,或把操作及原始内容快照保存为 pending |
| CLI 审批入口 | 操作 ID、用户输入的确认口令 | 再次核对源文件和目标,批准后才执行 |
| 日志与恢复 | SQLite 记录、文件现状 | 撤销已执行操作,或处理上次未完成的执行 |
pending 表示“已保存提案,尚未执行”。Dry-run 在这里就是查看这些提案:操作 ID、类型、源路径、目标路径、字节数和内容摘要。程序可能写入自己的日志,但不会移动、复制或删除业务文件。
图:先预览影响,再选择批准或取消。本实现逐条按 ID 批准;图中的批量项表示多条单文件提案,程序不展开通配符,也没有一键批准全部的入口。删除内容会先保留在日志中。
所有文件变更都等待批准。目录创建由用户在准备阶段完成,程序不支持删除目录,也不替用户建立一批未记录的父目录。
工作区检查具体检查什么 #
启动时必须明确选择一个已经存在的目录。程序拒绝 HOME、HOME 的祖先和 Git checkout;无参数运行则自行创建临时目录。模型提交的操作路径必须相对这个目录,不能包含 ..,不能通过符号链接,也不能访问 .file-agent 状态目录。
检查不是比较字符串前缀。共享的 confined_path 逐段检查路径,执行前还会重新调用它。因此,如果预览后用户把目标父目录换成指向外部的符号链接,本次执行会被拒绝,外部目录不会因此写入文件。为避免路径别名让撤销记录对不上,提案保存规范化后的相对路径,并要求使用目录实际列出的大小写。
文件还必须是普通文件,最多 1 MiB。外部硬链接会让同一份文件内容拥有另一个路径名称,本例拒绝这类文件,也拒绝会影响程序运行身份的 setuid/setgid 特殊权限。目标父目录必须已存在,目标名称必须空闲,源与目标必须处于同一文件系统。重命名可以用“源和目标在同一目录内的移动”表达。
这些检查服务于可信本地用户。它们不能阻止另一个恶意进程在检查后立刻替换目录,也不能限制这个 Python 进程拥有的全部系统权限。不要将它当作沙箱或文件系统权限隔离。
在新进程里批准和撤销 #
下面的命令创建独立临时工作区,并把移动提案的实际 ID 存入变量。它们没有写入当前项目:
WORKSPACE="$(mktemp -d)"
mkdir "$WORKSPACE/sorted"
printf 'keep this note\n' > "$WORKSPACE/notes.txt"
OP_ID="$(python app.py --workspace "$WORKSPACE" --queue move --source notes.txt --destination sorted/notes.txt | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
python app.py --workspace "$WORKSPACE" --status
python app.py --workspace "$WORKSPACE" --apply "$OP_ID"最后一条打印提案,并要求输入 APPROVE、一个空格和显示的完整 ID。输入 y、n、其他 ID 或不完整口令都不会批准。模型的文字回复同样不能替代这次输入。
现在再次运行程序:
python app.py --workspace "$WORKSPACE" --undo "$OP_ID"这次输入 UNDO、一个空格和同一个 ID。它是新的 Python 进程,但会打开原工作区中的日志,把 sorted/notes.txt 恢复为 notes.txt。如果原路径已存在另一个文件,程序拒绝覆盖。
移动撤销后,可以再试带备份删除:
DELETE_ID="$(python app.py --workspace "$WORKSPACE" --queue delete --source notes.txt | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
python app.py --workspace "$WORKSPACE" --apply "$DELETE_ID"
python app.py --workspace "$WORKSPACE" --undo "$DELETE_ID"两次调用仍分别等待批准。在删除操作执行前,原始字节已经保存在 SQLite 的 image 字段中;撤销时从这份备份创建原路径。这里的移动也按备份创建目标、再移除源文件。移动、复制和撤销只保留文件内容和普通权限,不保留原有的底层文件编号、修改时间、附加访问控制规则和扩展元数据。带备份删除因此能恢复内容;它没有执行永久擦除。
撤销采用后进先出:先撤销最后一条成功执行的操作,再撤销更早执行的操作。日志单独保存执行顺序;即使用户倒序批准独立提案,撤销也不按提案创建顺序猜测。若文件先从 A 移到 B,再从 B 移到 C,应先恢复 B,再恢复 A。恢复 B 会创建一个新文件,文件系统给它的编号(inode)会改变。程序会同步更新前一条操作的目标身份记录,否则第二次撤销会把应用自己恢复的文件误判为外部替换。
日志不能只在成功后写一行 #
.file-agent/journal.sqlite3 保存队列、操作状态、原始字节及源和目标的身份。身份包括 inode、大小、修改时间、普通权限和内容哈希;它让程序发现预览之后的文件改动。哈希是对内容计算出的摘要,这里用来核对备份和文件是否一致,不是让模型评价可靠性。
执行前,程序先提交 applying 记录,再改变业务文件。创建目标时,它先在应用状态目录准备内容,再用硬链接为文件增加目标名称;这个操作遇到已有名称会失败,不会替换已有目标。执行确认后清理暂存链接。两份 SQLite 文件各司其职:一份保存操作,一份在执行区间维持应用互斥锁,防止本例的两个进程同时修改同一工作区。
| 持久状态 | 新进程看到它时做什么 |
|---|---|
pending | 保留提案,继续等待用户批准或取消 |
applying | 说明执行没有得到日志确认,按备份回到执行前,记为失败 |
applied | 保留执行结果,允许用户申请撤销 |
undoing | 继续把已批准的撤销做完 |
undone、cancelled、failed | 保留历史,不自动重做操作 |
打开工作区时也会恢复未结束记录,所以“查看状态”可能先完成上一次已批准操作的恢复。这不是一次新的模型授权。
日志与文件系统是两种不同的存储,不能把一次 SQLite 提交当成二者共同的事务。程序在执行失败时尝试回滚;如果日志不可写、目标被别人修改、原路径被占用或备份校验失败,它会保留现场并返回需要恢复的错误。用户解决具体冲突后,重新打开同一工作区,程序再按日志恢复。
另一个容易误判的情况是:日志已成功提交,之后报告过程发生异常。这时不能又把文件回滚,却让日志仍显示 applied。程序先查持久状态;若执行已经确认,就保留结果,提示用户查看状态或明确申请撤销。因此,“命令返回错误”不一律等于“没有改动任何文件”。
接入真实模型只改变提案来源 #
默认的 ScriptedModel 按固定顺序返回工具请求,用于确认模型循环与真实工具接得上。可选 live 模式改用共享运行时的异步客户端:
python app.py --workspace "$WORKSPACE" --live --task '查看目录,为整理 notes.txt 提出移动方案,不执行'运行前需在自己的终端设置 OPENAI_API_KEY、OPENAI_MODEL,按需设置 OPENAI_BASE_URL。代码不内嵌密钥,客户端在 finally 中关闭。缺少配置或接口失败会返回错误,不悄悄替换为固定响应。
模型依然只能使用 list_directory、queue_operation 和 preview。共享运行时校验参数,并将工具失败交回模型;规划返回 complete 只表示本次模型循环完成,不代表文件执行完成。本轮没有可用的模型凭据,未执行真实模型调用,也没有评测规划质量。
工具接口设计强调返回信息要足以支持下一步判断。在这里,最关键的信息是“待批准”“已执行”“拒绝冲突”这些真实状态,而不是让模型从一句“成功”猜测文件究竟发生了什么。
怎样验证这些行为 #
在下载包目录中执行:
python -m unittest -v本次在 Python 3.11.16、macOS 的本地临时目录中执行了 30 项测试,全部通过。测试会真实写入带二进制字节的合成文件;断言检查内容,而不只是检查函数返回的成功字段。
覆盖的关键情况包括:
- 预览、错误批准口令和取消均不改变业务文件;模型尝试调用批准工具会收到未知工具错误。
- 移动、复制和带备份删除成功后,新应用对象能撤销;删除和撤销还通过两个独立 CLI 进程执行。
- 绝对路径、
..、指向工作区内外的符号链接、预览后换入的符号链接以及状态目录访问被拒绝。 - 执行前出现同名目标、撤销时原路径被占用、目标被修改或增加外部硬链接时,不覆盖、不删除冲突文件。
- 注入日志准备或最终写入故障后,检查实际文件和重新读取的状态;备份损坏和数据库损坏都不会触发静默重建。
- 真实子进程在准备、目标创建、源文件移除和撤销写回等阶段直接退出;再次打开后检查回滚或完成撤销,并验证 SQLite 锁已释放。
这些是固定输入下的文件操作证据,不是模型能力、性能或断电恢复证据。备份仍在同一个工作区;整个工作区或硬盘损坏时,本例没有另一份独立备份。历史快照暂未提供清理策略,取消也保留内容,因此用户应在确认不再需要恢复后,明确决定如何归档或清理这个临时工作区。
完整源码与后续使用边界 #
下载包中的 app.py 是实现来源,以下代码从同一文件展示;测试和依赖也随包提供:
"""File operations with explicit approval, durable backups and restart recovery."""
from __future__ import annotations
import argparse
import asyncio
from contextlib import closing, contextmanager
import hashlib
import json
import os
from pathlib import Path
import sqlite3
import stat
import tempfile
from typing import Callable
import uuid
from agent_runtime import Tool, ScriptedModel, model_from_env, run_agent, confined_path
STATE_DIR = ".file-agent"
MAX_BYTES = 1024 * 1024
MAX_PENDING = 20
class FileAgentError(ValueError):
pass
class RecoveryRequired(FileAgentError):
pass
class FileManager:
"""A local-user boundary. Other programs must not race directory changes."""
def __init__(self, workspace: Path, *, fault: Callable | None = None):
given = Path(workspace).absolute()
if given.is_symlink() or not given.is_dir():
raise FileAgentError("工作区必须是现有目录,不能是符号链接")
self.root = given.resolve()
home = Path.home().resolve()
if self.root == home or self.root in home.parents:
raise FileAgentError("不能把 HOME 或它的祖先作为工作区")
if any((p / ".git").exists() for p in (self.root, *self.root.parents)):
raise FileAgentError("请使用独立练习目录,不能使用 Git checkout")
self.control = self.root / STATE_DIR
if self.control.is_symlink():
raise FileAgentError("状态目录不能是符号链接")
self.control.mkdir(mode=0o700, exist_ok=True)
self.staging = self.control / "staging"
if self.staging.is_symlink():
raise FileAgentError("暂存目录不能是符号链接")
self.staging.mkdir(mode=0o700, exist_ok=True)
os.chmod(self.control, 0o700)
os.chmod(self.staging, 0o700)
self.fault = fault or (lambda point, row: None)
with self._locked() as conn:
version = conn.execute("PRAGMA user_version").fetchone()[0]
if version not in (0, 1):
raise RecoveryRequired("日志版本不支持,未修改业务文件")
if version == 0:
if conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchone():
raise RecoveryRequired("无法识别已有日志,未重建数据库")
with conn:
conn.execute("BEGIN IMMEDIATE")
conn.execute("""CREATE TABLE operations (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT UNIQUE NOT NULL,
action TEXT NOT NULL,
source TEXT NOT NULL,
destination TEXT,
digest TEXT NOT NULL,
image BLOB NOT NULL,
source_stamp TEXT NOT NULL,
target_stamp TEXT,
execution_order INTEGER,
state TEXT NOT NULL,
error TEXT NOT NULL DEFAULT ''
)""")
conn.execute("PRAGMA user_version=1")
self._recover(conn)
def _control_files(self):
for name in ("lock.sqlite3", "lock.sqlite3-journal", "journal.sqlite3",
"journal.sqlite3-journal", "journal.sqlite3-wal", "journal.sqlite3-shm"):
p = self.control / name
if p.is_symlink() or (p.exists() and (not p.is_file() or p.stat().st_nlink != 1)):
raise RecoveryRequired("状态文件必须是独立普通文件")
if self.control.is_symlink() or self.staging.is_symlink():
raise RecoveryRequired("状态路径发生变化")
@contextmanager
def _locked(self):
self._control_files()
# This second SQLite database holds an application-wide file lock across
# commits to the journal. Process exit releases it; no stale PID lock.
with closing(sqlite3.connect(self.control / "lock.sqlite3", timeout=0.2)) as lock:
try:
lock.execute("BEGIN EXCLUSIVE")
except sqlite3.OperationalError as exc:
raise FileAgentError("工作区正被另一个应用调用占用") from exc
self._control_files()
with closing(sqlite3.connect(self.control / "journal.sqlite3")) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA synchronous=FULL")
for name in ("lock.sqlite3", "journal.sqlite3"):
os.chmod(self.control / name, 0o600)
yield conn
@contextmanager
def _ready(self):
with self._locked() as conn:
self._recover(conn)
yield conn
def _path(self, relative: str, *, allow_root: bool = False) -> Path:
if not isinstance(relative, str) or STATE_DIR in Path(relative).parts:
raise FileAgentError("不能操作应用状态目录")
try:
p = confined_path(self.root, relative, allow_root=allow_root)
except (ValueError, OSError) as exc:
raise FileAgentError(str(exc)) from exc
if not p.parent.is_dir() and p != self.root:
raise FileAgentError("目标父目录必须已存在")
cursor = self.root
for part in p.relative_to(self.root).parts:
if cursor.is_dir():
child = cursor / part
if child.exists() and part not in os.listdir(cursor):
raise FileAgentError("路径必须使用目录列出的实际大小写")
cursor = cursor / part
cursor = p if p.exists() else p.parent
if cursor.stat().st_dev != self.root.stat().st_dev:
raise FileAgentError("不支持跨文件系统操作")
return p
@staticmethod
def _stamp(p: Path, *, managed: bool = False) -> dict:
info = p.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_nlink not in ((1, 2) if managed else (1,)):
raise FileAgentError("只支持普通文件;目录、符号链接和外部硬链接不支持")
if info.st_mode & (stat.S_ISUID | stat.S_ISGID):
raise FileAgentError("不支持 setuid/setgid 特殊权限文件")
if info.st_size > MAX_BYTES:
raise FileAgentError("文件超过 1 MiB 教学上限")
data = p.read_bytes()
after = p.lstat()
if (info.st_ino, info.st_size, info.st_mtime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns):
raise FileAgentError("读取期间文件发生变化")
return {"dev": info.st_dev, "ino": info.st_ino, "size": len(data),
"mtime": info.st_mtime_ns, "mode": stat.S_IMODE(info.st_mode),
"sha256": hashlib.sha256(data).hexdigest()}
@staticmethod
def _sync_dir(p: Path):
# Directory fsync is available on the POSIX hosts used for this example.
# This demo does not claim power-loss guarantees on other filesystems.
if os.name == "posix":
fd = os.open(p, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def _row(self, conn, operation_id: str) -> dict:
row = conn.execute("SELECT * FROM operations WHERE id=?", (operation_id,)).fetchone()
if row is None:
raise FileAgentError("没有该操作")
result = dict(row)
self._validate_row(result)
return result
@staticmethod
def _validate_row(row: dict):
states = {"pending", "cancelled", "applying", "applied", "undoing", "undone", "failed"}
if row["action"] not in {"move", "copy", "delete"} or row["state"] not in states:
raise RecoveryRequired("日志操作或状态无效")
if len(row["image"]) > MAX_BYTES or hashlib.sha256(row["image"]).hexdigest() != row["digest"]:
raise RecoveryRequired("日志备份校验失败,未改业务文件")
try:
source = json.loads(row["source_stamp"])
if source["sha256"] != row["digest"] or source["size"] != len(row["image"]):
raise ValueError("snapshot mismatch")
if row["target_stamp"] is not None and json.loads(row["target_stamp"])["sha256"] != row["digest"]:
raise ValueError("target mismatch")
except (ValueError, KeyError, TypeError) as exc:
raise RecoveryRequired("日志文件摘要无效") from exc
@staticmethod
def _public(row: dict) -> dict:
return {k: row[k] for k in ("id", "action", "source", "destination", "state", "error")} | {
"bytes": len(row["image"]), "sha256": row["digest"]}
def _persist(self, conn, row: dict, state: str, *, error: str = "", refresh: bool = False):
try:
order = row["execution_order"]
if state == "applying" and order is None:
order = conn.execute("SELECT coalesce(max(execution_order), 0) + 1 FROM operations").fetchone()[0]
conn.execute("UPDATE operations SET state=?, target_stamp=?, execution_order=?, error=? WHERE id=?",
(state, row["target_stamp"], order, error, row["id"]))
# Undoing B->C recreates B with a new inode. The preceding A->B
# journal entry must recognize that app-owned restored file.
restore = self._stage_path(row, "restore")
source = self._path(row["source"])
if refresh and restore.exists() and source.exists() and source.samefile(restore):
predecessor = conn.execute("""SELECT id FROM operations WHERE execution_order<? AND state='applied'
AND destination=? AND digest=? ORDER BY execution_order DESC LIMIT 1""",
(order, row["source"], row["digest"])).fetchone()
if predecessor:
conn.execute("UPDATE operations SET target_stamp=? WHERE id=?",
(json.dumps(self._stamp(source, managed=True)), predecessor["id"]))
conn.commit()
except Exception:
conn.rollback()
raise
row["state"], row["error"], row["execution_order"] = state, error, order
def _stage_path(self, row: dict, suffix: str) -> Path:
if len(row["id"]) != 32 or any(c not in "0123456789abcdef" for c in row["id"]):
raise RecoveryRequired("日志操作编号无效")
return self.staging / (row["id"] + "." + suffix)
def _stage(self, row: dict, suffix: str) -> Path:
p = self._stage_path(row, suffix)
if p.is_symlink():
raise RecoveryRequired("暂存文件不能是符号链接")
if p.exists():
if self._stamp(p, managed=True)["sha256"] != row["digest"]:
raise RecoveryRequired("暂存内容与日志备份不符")
return p
if hashlib.sha256(row["image"]).hexdigest() != row["digest"]:
raise RecoveryRequired("日志备份校验失败,拒绝恢复")
created = False
try:
with p.open("xb") as out:
created = True
out.write(row["image"])
out.flush()
os.fsync(out.fileno())
os.chmod(p, json.loads(row["source_stamp"])["mode"] & 0o777)
self._sync_dir(self.staging)
except Exception:
if created:
p.unlink(missing_ok=True)
raise
return p
def _cleanup(self, row: dict):
for suffix in ("apply", "restore"):
p = self._stage_path(row, suffix)
if p.is_symlink():
raise RecoveryRequired("拒绝清理异常暂存路径")
if p.exists():
if not p.is_file():
raise RecoveryRequired("暂存路径类型不符")
p.unlink()
self._sync_dir(self.staging)
def _owned_target(self, row: dict) -> Path | None:
if not row["destination"]:
return None
p = self._path(row["destination"])
stage = self._stage_path(row, "apply")
managed = stage.exists() and not stage.is_symlink() and p.exists() and p.samefile(stage)
if p.exists() and self._stamp(p, managed=managed) != json.loads(row["target_stamp"]):
raise RecoveryRequired("目标文件已变化;保留现状,拒绝删除或覆盖")
return p
def _restore_source(self, row: dict):
p = self._path(row["source"])
restore = self._stage_path(row, "restore")
if p.exists():
owned = restore.exists() and not restore.is_symlink() and p.samefile(restore)
if not owned and self._stamp(p) != json.loads(row["source_stamp"]):
raise RecoveryRequired("原路径已被占用或修改;拒绝覆盖")
if self._stamp(p, managed=owned)["sha256"] != row["digest"]:
raise RecoveryRequired("恢复中的文件内容不符")
return
stage = self._stage(row, "restore")
os.link(stage, p) # Atomic no-replace: an occupied name raises.
self._sync_dir(p.parent)
def _rollback_apply(self, row: dict):
target = self._owned_target(row) # Validate before changing either side.
if row["action"] in ("move", "delete"):
self._restore_source(row)
if target is not None and target.exists():
target.unlink()
self._sync_dir(target.parent)
def _finish_undo(self, row: dict):
target = self._owned_target(row)
if row["action"] in ("move", "delete"):
self._restore_source(row)
if target is not None and target.exists():
target.unlink()
self._sync_dir(target.parent)
def _recover(self, conn):
rows = [dict(r) for r in conn.execute("SELECT * FROM operations ORDER BY execution_order DESC, seq DESC")]
for row in rows:
try:
self._validate_row(row)
if row["state"] == "applying":
self._rollback_apply(row)
self._persist(conn, row, "failed", error="未完成的执行已回滚", refresh=True)
elif row["state"] == "undoing":
self._finish_undo(row)
self._persist(conn, row, "undone", refresh=True)
self._cleanup(row)
except Exception as exc:
raise RecoveryRequired(f"操作 {row['id']} 需要恢复;未宣称完成:{exc}") from exc
def list_directory(self, directory: str = "") -> dict:
with self._ready():
p = self._path(directory, allow_root=True)
if not p.is_dir():
raise FileAgentError("不是目录")
return {"entries": [{"name": q.name,
"type": "symlink-blocked" if q.is_symlink() else
"directory" if q.is_dir() else "file"}
for q in sorted(p.iterdir()) if q.name != STATE_DIR]}
def preview(self) -> list[dict]:
with self._ready() as conn:
return [self._public(dict(r)) for r in conn.execute(
"SELECT * FROM operations WHERE state='pending' ORDER BY seq")]
def history(self) -> list[dict]:
with self._ready() as conn:
return [self._public(dict(r)) for r in conn.execute("SELECT * FROM operations ORDER BY seq")]
def queue(self, action: str, source: str, destination: str | None = None) -> dict:
with self._ready() as conn:
if action not in {"move", "copy", "delete"}:
raise FileAgentError("只支持 move/copy/delete")
if conn.execute("SELECT count(*) FROM operations WHERE state='pending'").fetchone()[0] >= MAX_PENDING:
raise FileAgentError("待确认队列已达20项上限")
src = self._path(source)
source = src.relative_to(self.root).as_posix()
stamp = self._stamp(src)
image = src.read_bytes()
if hashlib.sha256(image).hexdigest() != stamp["sha256"]:
raise FileAgentError("读取期间文件发生变化")
if action == "delete":
if destination is not None:
raise FileAgentError("删除不接受目标路径")
else:
if not destination:
raise FileAgentError("移动/复制需要目标路径")
dst = self._path(destination)
if dst.exists() or src == dst:
raise FileAgentError("目标已存在,拒绝覆盖")
destination = dst.relative_to(self.root).as_posix()
operation_id = uuid.uuid4().hex
with conn:
conn.execute("""INSERT INTO operations
(id,action,source,destination,digest,image,source_stamp,state)
VALUES (?,?,?,?,?,?,?,'pending')""",
(operation_id, action, source, destination, stamp["sha256"], image, json.dumps(stamp)))
return self._public(self._row(conn, operation_id))
@staticmethod
def _approval(operation_id: str, approval: str, verb: str):
if approval != f"{verb} {operation_id}":
raise FileAgentError("未收到该操作的完整确认口令,未改业务文件")
def cancel(self, operation_id: str) -> dict:
with self._ready() as conn:
row = self._row(conn, operation_id)
if row["state"] != "pending":
raise FileAgentError("只能取消待确认操作")
self._persist(conn, row, "cancelled")
return self._public(row)
def apply(self, operation_id: str, approval: str) -> dict:
with self._ready() as conn:
row = self._row(conn, operation_id)
if row["state"] != "pending":
raise FileAgentError("不是待确认操作,不能重复执行")
self._approval(operation_id, approval, "APPROVE")
src = self._path(row["source"])
if self._stamp(src) != json.loads(row["source_stamp"]):
raise FileAgentError("源文件自预览后已变化,请取消并重新提案")
dst = self._path(row["destination"]) if row["destination"] else None
if dst is not None and dst.exists():
raise FileAgentError("目标已被占用,拒绝覆盖")
stage = self._stage(row, "apply") if dst else None
row["target_stamp"] = json.dumps(self._stamp(stage)) if stage else None
try:
self._persist(conn, row, "applying") # Commit before any business mutation.
except Exception:
self._cleanup(row)
raise
try:
self.fault("after_prepare", row)
if dst is not None:
os.link(stage, dst)
self._sync_dir(dst.parent)
self.fault("after_target", row)
if row["action"] in ("move", "delete"):
src.unlink()
self._sync_dir(src.parent)
self.fault("after_source", row)
self._persist(conn, row, "applied")
except Exception as exc:
# If a wrapper failed after commit, the durable outcome wins.
# Do not undo an applied operation and leave an applied record.
try:
recorded = self._row(conn, operation_id)
except Exception as unknown:
raise RecoveryRequired("无法确认日志提交状态,请重新打开应用恢复") from unknown
if recorded["state"] == "applied":
raise RecoveryRequired("日志已确认执行,请重新打开查看状态或申请撤销") from exc
try:
self._rollback_apply(row)
self._persist(conn, row, "failed", error=str(exc), refresh=True)
self._cleanup(row)
except Exception as recovery:
raise RecoveryRequired("执行失败,恢复未完成;请排除冲突后重新打开应用") from recovery
raise FileAgentError("执行失败,业务文件已回滚") from exc
self._cleanup(row)
return self._public(row)
def undo(self, operation_id: str, approval: str) -> dict:
with self._ready() as conn:
latest = conn.execute("SELECT id FROM operations WHERE state='applied' ORDER BY execution_order DESC LIMIT 1").fetchone()
if latest is None or operation_id != latest["id"]:
raise FileAgentError("只能撤销最近一条已执行操作")
row = self._row(conn, operation_id)
self._approval(operation_id, approval, "UNDO")
target = self._owned_target(row)
if target is not None and not target.exists():
raise FileAgentError("目标已不在原处,拒绝猜测撤销")
if row["action"] in ("move", "delete") and self._path(row["source"]).exists():
raise FileAgentError("原路径被占用,拒绝覆盖")
self._persist(conn, row, "undoing")
try:
self._finish_undo(row)
self.fault("after_undo_files", row)
self._persist(conn, row, "undone", refresh=True)
except Exception as exc:
raise RecoveryRequired("撤销尚未确认完成;重新打开后按日志继续恢复") from exc
self._cleanup(row)
return self._public(row)
def tools_for(manager: FileManager) -> list[Tool]:
return [
Tool("list_directory", "列出工作区目录;符号链接标为禁止使用", {
"type": "object", "properties": {"directory": {"type": "string"}},
"additionalProperties": False}, manager.list_directory),
Tool("queue_operation", "仅提交待确认提案,不执行文件变更", {
"type": "object", "properties": {
"action": {"type": "string", "enum": ["move", "copy", "delete"]},
"source": {"type": "string"}, "destination": {"type": "string"}},
"required": ["action", "source"], "additionalProperties": False}, manager.queue),
Tool("preview", "查看尚未执行的操作与内容摘要", {
"type": "object", "properties": {}, "additionalProperties": False}, manager.preview),
]
def fixture_call(name: str, arguments: dict, call_id: str) -> dict:
return {"id": call_id, "type": "function", "function": {
"name": name, "arguments": json.dumps(arguments)}}
async def propose_with_model(manager: FileManager, task: str, *, live: bool = False):
if live:
model = model_from_env()
else:
model = ScriptedModel([
{"role": "assistant", "content": None, "tool_calls": [
fixture_call("queue_operation", {"action": "move", "source": "notes.txt", "destination": "sorted/notes.txt"}, "m"),
fixture_call("queue_operation", {"action": "delete", "source": "trash.txt"}, "d")]},
{"role": "assistant", "content": "提案已保存,文件尚未变更,请由用户在 CLI 审批。"},
])
try:
return await run_agent(model,
"你只生成工作区文件整理提案。先查看目录;变更只能提交队列。不能声称文件已经执行,批准不由你决定。",
task, tools_for(manager), max_turns=8, max_tool_calls=20, timeout=60)
finally:
if live:
await model.aclose()
async def fixture_demo():
with tempfile.TemporaryDirectory(prefix="file-agent-fixture-") as directory:
root = Path(directory)
(root / "sorted").mkdir()
(root / "notes.txt").write_text("keep this note\n", encoding="utf-8")
(root / "trash.txt").write_text("recover this delete\n", encoding="utf-8")
manager = FileManager(root)
result = await propose_with_model(manager, "整理合成文件")
pending = manager.preview()
print(f"[fixture 本地固定响应] model={result.status}; pending={len(pending)}")
print(f"[fixture] dry-run: notes={ (root / 'notes.txt').exists() }; trash={ (root / 'trash.txt').exists() }")
if result.status != "complete" or len(pending) != 2:
raise FileAgentError("固定响应演示未完成")
# Only this isolated fixture supplies preselected approval strings.
# Real --apply / --undo always reads the user's exact phrase from stdin.
for item in pending:
manager.apply(item["id"], "APPROVE " + item["id"])
print(f"[fixture] approved: moved={(root / 'sorted/notes.txt').exists()}; deleted={not (root / 'trash.txt').exists()}")
manager = FileManager(root) # Discard the first application object.
manager.undo(pending[-1]["id"], "UNDO " + pending[-1]["id"])
manager = FileManager(root)
manager.undo(pending[0]["id"], "UNDO " + pending[0]["id"])
restored = (root / "notes.txt").read_text() == "keep this note\n" and (root / "trash.txt").read_text() == "recover this delete\n"
print(f"[fixture] reopened journal: delete_undo=True; move_undo=True; bytes_restored={restored}")
if not restored:
raise FileAgentError("内容恢复失败")
print("[fixture] temporary workspace cleaned on exit; no live model called")
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--workspace", type=Path)
actions = parser.add_mutually_exclusive_group()
actions.add_argument("--queue", choices=["move", "copy", "delete"])
actions.add_argument("--apply", metavar="ID")
actions.add_argument("--undo", metavar="ID")
actions.add_argument("--cancel", metavar="ID")
actions.add_argument("--status", action="store_true")
actions.add_argument("--live", action="store_true")
parser.add_argument("--source")
parser.add_argument("--destination")
parser.add_argument("--task")
args = parser.parse_args(argv)
try:
if args.workspace is None:
if any((args.queue, args.apply, args.undo, args.cancel, args.status, args.live, args.task, args.source, args.destination)):
parser.error("操作需要明确的 --workspace;无参数只演示临时 fixture")
asyncio.run(fixture_demo())
return 0
if (args.source is not None or args.destination is not None) and not args.queue:
parser.error("--source/--destination 只能与 --queue 一起使用")
manager = FileManager(args.workspace)
if args.live:
if not args.task:
parser.error("--live 需要 --task")
result = asyncio.run(propose_with_model(manager, args.task, live=True))
print(json.dumps({"planning_status": result.status, "model_text": result.text,
"executed": False, "pending": manager.preview()}, ensure_ascii=False, indent=2))
return 0 if result.status == "complete" else 1
if args.task:
parser.error("--task 仅用于 --live;无参数演示固定响应")
if args.queue:
if not args.source:
parser.error("--queue 需要 --source")
value = manager.queue(args.queue, args.source, args.destination)
elif args.apply or args.undo:
operation_id = args.apply or args.undo
verb = "APPROVE" if args.apply else "UNDO"
print(json.dumps(manager.history(), ensure_ascii=False, indent=2))
approval = input(f"输入 {verb} {operation_id},其他输入均拒绝:")
value = manager.apply(operation_id, approval) if args.apply else manager.undo(operation_id, approval)
elif args.cancel:
value = manager.cancel(args.cancel)
else:
value = {"pending": manager.preview(), "history": manager.history()}
print(json.dumps(value, ensure_ascii=False, indent=2))
return 0
except (ValueError, sqlite3.Error, OSError, EOFError) as exc:
print(f"ERROR: {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())本例适合在支持 SQLite 锁和硬链接的本地文件系统中理解审批、恢复和冲突处理。目录批处理、跨文件系统迁移、网络盘与云同步竞争、恶意同权限进程和断电硬件故障都没有在本轮验收中得到覆盖。后续扩展前,先为新增文件类型和故障位置写实际文件测试,不能把“增加一个工具”当作恢复协议已经完整。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗