工具运行时是四层结构,收敛于 FinalizedToolset。本文覆盖契约、权限边界、路径安全和七个内置工具的语义。
| 层 | 是什么 | 生命周期 |
|---|---|---|
| 工具契约 | 稳定 ID、typed input/output、schema、风险等级、执行逻辑 | 编译期 |
| 工具集选择 | Agent 从已注册工具中选出真正可用的子集 | Agent 构建时 |
ToolSessionContext |
工作目录、权限档案、文件系统与进程后端、环境 | 与 Session 同寿 |
ToolCallContext |
本次调用的 ID、取消令牌、deadline、进度通道 | 每次调用 |
分层的作用是让每个依赖只出现在它真正稳定的那一层:工作目录一个 Session 内不变,取消令牌每次调用都不同——混在一起会导致要么泄漏跨调用状态,要么每次调用重建整个上下文。
#[async_trait]
pub trait Tool: Send + Sync + 'static {
type Input: DeserializeOwned + JsonSchema + Send + 'static;
type Output: ToolOutput + Send + 'static;
fn id(&self) -> ToolId;
fn description(&self) -> &'static str;
fn risk(&self) -> ToolRisk;
async fn execute(
&self,
session: &ToolSessionContext,
call: ToolCallContext,
input: Self::Input,
) -> Result<Self::Output, ToolError>;
}要求:
ToolId是稳定的内部身份,不从文件名或类型名隐式推导;- Input 由
serde反序列化,Schema 由schemars从同一个类型生成——两者不可能不一致; - Tool Definition =
id + description + schema + risk; - handler 不再手工
Value::get("...")。
带关联类型的 Tool 不能直接作为 dyn Tool 保存,Registry 内部用 ToolAdapter<T> 做一次类型擦除:
#[async_trait]
trait DynTool: Send + Sync {
fn definition(&self) -> ToolDefinition;
fn risk(&self) -> ToolRisk;
async fn call(&self, session: Arc<ToolSessionContext>,
call: ToolCallContext, input: Value) -> ToolResult;
}Adapter 负责:JSON → T::Input 反序列化;把两级上下文分别传入;把 T::Output 或 ToolError 统一转成 ToolResult。具体工具保持强类型,运行时仍可 HashMap<ToolId, Arc<dyn DynTool>> 分发。
Registry = 当前二进制知道哪些工具
Toolset = 当前 Agent 实际允许哪些工具
两者必须分开:Registry 是编译期事实,Toolset 是 Agent 策略。
pub struct ToolSessionContext {
pub working_directory: PathBuf,
pub permissions: PermissionProfile,
pub environment: Arc<HashMap<String, String>>,
pub filesystem: Arc<dyn AsyncFileSystem>,
pub process_backend: Arc<dyn ProcessBackend>,
}它不保存:Turn 取消令牌、Tool Call ID、当前 Permission Request、SessionActor、Chat State、ModelPort、数据库连接、Trace Recorder。
Tools 可以依赖 ToolSessionContext,但不能反向依赖 openwork-core。
内置工具不直接调 tokio::fs / tokio::process,而是走两个受控后端:
- 单元测试不需要访问真实工作目录;
- 路径检查可以在唯一入口强制执行;
- 进程表、kill、timeout 和取消语义集中管理;
- 进程启动方式集中在一处,替换实现不必改每个工具。
pub struct ToolCallContext {
pub call_id: ToolCallId,
pub cancel: CancellationToken,
pub deadline: Option<Instant>,
pub progress: Option<ProgressSender>,
}不放入 SessionId / TurnId 等 Core 类型,避免反向依赖。Session/Turn 关联由 Core 的 Trace 层维护。
- Session 创建时不创建永不更新的工具取消令牌;
- 每个 Tool Call 从当前 Turn 的 token 派生自己的 child token;
- 用户取消 Turn 时所有活动调用同时取消;
- timeout 与用户取消最终进入同一条进程终止路径;
- 不允许把持有 child token 的 future 丢进
tokio::spawn后丢弃 JoinHandle; - ProcessBackend 不得遗留失去 owner 的子进程;kill 确认失败时必须继续持有句柄并明确返回
outcome_unknown; kill_on_drop只是最后防线,不能代替显式取消协议。
四层的收敛点:
pub struct FinalizedToolset {
definitions: Vec<ModelToolDefinition>,
tools: HashMap<ToolId, Arc<dyn DynTool>>,
session: Arc<ToolSessionContext>,
}不变量:
definitions与tools用同一批 selected entries 构造;- finalize 后不可增删替换工具;
call只能在 finalized map 中查找;- schema、风险和执行 adapter 来自同一个注册项;
- Session Context 在 finalize 时绑定,Call Context 每次调用由 Core 提供;
- Core 只持有一个
Arc<FinalizedToolset>,不再同时持有 Catalog 和 Executor。
第 1 条是"广告了但调不动 / 能调但没广告"的根本防线。
V1 没有动态 MCP,因此不需要 ToolBridge。若未来出现动态工具,在不可变的内置 toolset 之外增加独立动态层,而不是让当前对象提前变可变。
Allow → 立即执行
Ask → 发出 PermissionRequest,等待用户
Deny → 返回 denied,不调用工具
权限等待会修改 Session 运行时状态、发 Live Update、接收 Desktop 命令,所以它属于 SessionActor 的控制流。
目标路径是否在允许范围内、是否允许写保护目录、进程参数是否合法、symlink 是否越界。
用户选择
Allow不能绕过这些边界。
分工:工具声明它这次会产生什么效果,权限系统按效果决定问不问(permissions.md §2);ToolSessionContext 负责执行期强制检查。没有 OS 级隔离,且不做。
词法规范化只能处理 . / .. 和前缀,不能证明真实文件仍在授权根目录内——工作区内的 symlink 可以指向外面;对尚不存在的新文件,只检查目标字符串也无法证明父目录没越界。
所有文件工具必须通过同一个异步解析入口获得已检查路径:
pub enum PathIntent { MustExist, MayCreate }
pub enum PathAccess { Read, Write }
pub struct CheckedPath { /* 字段私有 */ }
async fn resolve_path(&self, input: &str,
access: PathAccess, intent: PathIntent)
-> Result<CheckedPath, ToolExecutionError>;CheckedPath 字段保持私有,只有文件系统 Backend 能消费——防止工具检查完路径后又换回未验证的 PathBuf。
解析规则:
- 以
working_directory解析相对路径; - 词法规范化,拒绝明显越界;
- canonicalize 所有授权根目录;
- 已存在目标 → canonicalize 目标本身;
- 新目标 → canonicalize 最近的已存在父目录;
- 验证真实目标或真实父目录位于允许的 canonical root 内;
- 再执行只读 / 写入 / 保护目录判断;
- 返回
CheckedPath,由 Backend 完成实际操作。
第 7 步的保护目录里包含一个用户级 skill 根:~/.agents/skills/。它在工作目录之外,必须通过 Core 持有的 SkillRoots 显式授权;对 read / grep / glob / list 可读,对 write / edit 一律拒绝。该根为 None 时不纳入;.claude/skills/ 不是来源。没有 project、bundled 或 .openwork/skills/ 根。理由是闭环——能改 skill 就能让一次提示注入变成跨 Session 持久的提权,见 skills.md §5.2。
对创建路径仍需防止"检查后父目录被替换"。首选方案是 Backend 在已验证父目录下创建临时文件并同目录 rename。
bash 不能只检查启动目录——printf x > ../outside.txt、tee /abs/path、ln -s /outside target 都会直接写文件。
语法预检可以识别常见的 > / >> / tee / cp / mv / ln 目标,用于提前审批和友好报错,但覆盖不了变量展开、子 Shell、脚本文件和工具自身的间接写入。
因此:
- 语法预检不是安全边界,它挡不住变量展开、符号链接、子进程自身的写入与网络访问;
- 能真正强制这条边界的只有操作系统级隔离,而本项目不做(permissions.md §1.4);
- 因此
bash只在两种情况下自动执行:命令通过只读判定(程序 + 参数子集的封闭白名单,permissions.md §2.3),或在acceptEdits下命中文件系统命令闸门(permissions.md §4.8)。其余一律逐次确认,审批卡片就是它的边界(permissions.md §5); - 上述两条自动放行路径的边界是判定表本身的正确性,因此它们必须在 Trace 中留下可反查的来源(permissions.md §7);
- 任何情况下文案不得声称已限制写入或网络。
七个工具,两组:filesystem/{read, write, edit, grep, glob, list} 与 process/{bash}。
Skill 不增加第八个工具。 SKILL.md 与 references/ 走 read、scripts/ 走 bash——一个专用的 skill(name) 工具能做的事 read 已经全能做,而工具定义的常驻成本每次 Model Call 都要付。理由见 skills.md §4.4。用户从 $ 候选框显式选择是 Turn 输入,不是 Tool Call。
通过 CheckedPath 读取;分配大字符串前先检查文件大小;明确报告过大、二进制和无效 UTF-8。
可选 offset(零基行偏移)与 limit(按行,最大 2000)。未给 limit 时返回 offset 之后全部内容,仍受 1 MiB 全局字节上限约束。
PathIntent::MayCreate 验证真实父目录;共享原子写入;单次写入字节上限;返回创建或覆盖状态。父目录创建必须逐层验证,不能先递归创建再补权限检查。
write 表示"写入完整文件",不承担局部修改职责。
精确字符串替换语义:old_text 不存在 / 出现多次 / 与新文本相同都失败;空 old_text 只用于创建新文件。
并发保护:
- 每个 canonical path 一把异步写锁;
- 读取原始内容并计算摘要;
- 计算替换结果;
- 提交前再次验证当前摘要;
- 文件已变化则返回 stale edit,不覆盖并发修改;
- 共享原子写入提交。
不把 edit 改成补丁语言。 可靠的 apply_patch 需要独立定义多文件、hunk 定位、偏移容忍、部分失败和回滚语义;没有这些契约就把两种编辑方式塞进一个参数会降低可预测性。
流式目录遍历;每个目录项和读取阶段都检查取消;glob 过滤统一匹配相对工作区路径;跳过超限文件和二进制并汇总 skipped 数;达到 maxResults 立即停止;拒绝 maxResults = 0。保持 content / files_with_matches / count 三种模式。单行过长时输出仍受字节上限约束,截断必须显式标注。
流式遍历;不忽略取消令牌;统一匹配相对路径;达上限提前结束而不是先收集整棵树;maxResults 默认 200、范围 1–2000;输出确定性排序。
glob 不等同于 Shell 命令——它跨平台、受权限控制、结果有界且不执行任意代码,因此保留。
安全路径解析后列出单层目录,文件类型是枚举而非布尔:
pub enum EntryKind { File, Directory, Symlink, Other }零基 offset + limit,默认每页 200、最大 2000。分页基于排序后的稳定结果。
保持前台、单次调用,不增加后台任务生命周期——只有启动后台任务而没有对应的查询和终止工具,会留下无法管理的进程和不完整的权限闭环。
有界输出:stdout/stderr 必须持续 drain(避免子进程因管道写满而阻塞),但内存只保留固定大小的 head 与 tail,最终组合并标注省略字节数。不能先 read_to_end 再截断——否则 yes 这类命令仍会耗尽内存。
退出与取消:
- 正常退出:返回
exit_code、stdout、stderr、耗时、截断信息; - 非零退出仍是最终工具结果,不转换成基础设施错误;
- 启动失败 / 管道失败 / Backend 故障:返回工具执行错误;
- timeout 或取消:终止整个进程组,返回终止前已收集的部分输出;
- 结果明确区分
exited/timed_out/cancelled,spawn_failed是独立错误类型。
网络:不管控,也不声称(permissions.md §1.4)。没有隔离手段就没有可强制的网络边界,因此不设网络相关环境变量,结果里也不附网络注解——一个恒为“未强制”的免责声明只会训练用户忽略它。
模型最终收到文本,但工具内部不构造任意字符串:
pub struct BashResult {
pub status: ProcessStatus,
pub exit_code: Option<i32>,
pub stdout: CapturedOutput,
pub stderr: CapturedOutput,
pub duration_ms: u64,
}模型适配器只序列化 ToolResult 的文本 output,不把 Artifact 放进模型上下文或 Token 统计。
pub enum ToolProgress {
Stdout { chunk: String },
Stderr { chunk: String },
Message { message: String },
}边界:Progress 是临时观察数据,不写入 Conversation;一次调用仍只产生一个最终 ToolResult;进度发送失败不改变执行结果;Core 把它转成 tool_call_progress Live Update;不折叠进 Snapshot——断线重同步只恢复最终结果,不恢复历史进度。
因此增加进度能力不需要数据库迁移。
write / edit 成功改变文本文件时生成 kind = "file_change" Artifact。它与 Progress 生命周期不同:
- Artifact 是最终
ToolResult的一部分,随工具消息写入messages.contentJSONB; - 保存路径、变更 ID、创建/修改类型、准确增删行数、带三行上下文的 diff hunk、前后 SHA-256,以及撤销所需的原内容;
- 桌面端不重新读磁盘去"猜" diff,因此会话重载后仍显示当时的准确变更;
- Undo 根据会话和变更 ID 从已持久化的 Artifact 恢复,并把对应 Artifact 标记
undone = true; - Undo 与同一会话的新 Turn 串行;哈希不匹配时返回冲突,不覆盖用户或外部进程后来写入的内容;
- 同一批次对同一路径的多次变更按逆序撤销——"先创建、再编辑"最终会删除该文件。
它同时也是压缩运行状态 edited_paths 的唯一来源(见 compaction.md)。
边界: 这是 write/edit 的文本文件变更历史,不是 Git 快照或任意文件系统事务。为了生成可持久化 diff 和可撤销内容,覆盖目标必须是可读、UTF-8 且不超过 1 MiB 的文本文件。创建文件的"校验内容后删除"在通用文件系统 API 上仍有外部进程抢占的极小竞态。若文件系统已撤销成功而 JSONB 状态写回失败,磁盘与会话标记可能短暂不一致——不能把这条链路描述成数据库与文件系统的单一原子事务。
- Definitions 与 Dispatch 来自同一个
FinalizedToolset; - finalize 之后无法增删替换工具;
- Input Schema 与反序列化类型来自同一个 Rust 类型;
- 权限过滤后不可见的工具既不广告也不可执行;
- 输入未变化时工具定义顺序确定;
openwork-tools不出现对openwork-core的依赖。
- 指向工作区外的 symlink 被拒绝;
- 新建文件时,父目录越界被拒绝;
- 工具无法绕过
CheckedPath直接拿到PathBuf; Allow不能绕过路径边界。
- 取消 Turn 时所有活动 Tool Call 同时取消;
- timeout 与用户取消走同一条终止路径;
- bash 终止整个进程组,并返回终止前已收集的输出;
- kill 确认失败时返回
outcome_unknown且不遗留孤儿进程; yes这类无限输出的命令不会耗尽内存。
edit在old_text缺失、重复或与新文本相同时失败;- 并发修改导致摘要变化时
edit返回 stale 而不覆盖; grep/glob达到上限后停止遍历而不是先收集全树;read/list的分页在稳定排序上进行;- bash 非零退出是正常工具结果,不是基础设施错误。
- 模型上下文中不出现 Artifact,Token 统计不包含它;
- 进度发送失败不改变工具结果;
- 会话重载后仍能显示当时的准确 diff;
- Undo 在哈希不匹配时返回冲突;
- 同批次多次变更按逆序撤销。