BoxAgnts 工具系统(4)——Tool Trait 与并发上下文模型

BoxAgnts 的工具系统之所以能把 Rust 内置函数、WASM 沙箱组件、定时任务触发器这三种完全不同的执行实体统一管理,靠的是一个六方法 Trait 外加一个共享上下文的并发模型。这篇拆解这两部分的实现和设计考量。


Trait 的方法签名为什么要这样写

回顾 Tool trait:

rust 复制代码
#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &'static str;
    fn description(&self) -> &'static str;
    fn source(&self) -> ToolSource;
    fn permission_level(&self) -> PermissionLevel;
    fn input_schema(&self) -> Value;
    async fn execute(&self, input: Value, ctx: &ToolContext) -> ToolResult;
}

第一个值得注意的细节是 name() 和 description() 的返回类型:&'static str。对于 Rust 内置工具,这是自然的------字符串字面量在编译时就被放入二进制文件的 .rodata 段,天生具有 'static 生命周期。但对于 WASM 工具,name 和 description 是运行时从 help 文本中解析出来的 String,它们没有 'static 生命周期。

解决方案是 Box::leak:

rust 复制代码
// wasm-tools/src/wasm_tool.rs
fn name(&self) -> &'static str {
    Box::leak(self.name.clone().into_boxed_str())
}

Box::leak 将 Box<str> 的引用返回给调用者,并告诉编译器放弃这块内存的所有权------这块内存"泄漏"了,不会再被释放。对于工具的名字和描述这种在程序整个生命周期内都需要访问的字符串来说,这是正确的取舍。泄露几个字符串的内存总量不过几百字节,完全在可接受范围内。

当然,如果 BoxAgnts 支持频繁添加删除 WASM 工具(而不是只在启动和手动操作时),Box::leak 就可能积累不可忽视的内存占用。当前的设计假设是工具注册是低频操作,所以这个 trade-off 是合理的。

permission_level() 返回的 PermissionLevel 是枚举而不是位掩码。这是刻意的------权限级别是线性递增的(None < ReadOnly < Write < Execute),不存在"同时具有 ReadOnly + Write + Execute"的组合语义。如果需要扩展更复杂的权限模型(例如 Capability 级别的细粒度控制),可以改成一个 HashSet<Capability>,但当前的四级线性模型对于 CLI 工具的权限描述足够了。


ToolContext 的所有权设计

execute() 的签名是 async fn execute(&self, input: Value, ctx: &ToolContext)------注意 ctx 是不可变引用。这意味着工具在执行期间不能修改共享上下文。这个约束来自 Rust 的借用规则,不是靠运行时的检查。

来看看 ToolContext 里放了什么:

rust 复制代码
pub struct ToolContext {
    pub permission_mode: PermissionMode,
    pub cost_tracker: Arc<CostTracker>,
    pub session_id: Option<String>,
    pub current_turn: Arc<AtomicUsize>,
    pub non_interactive: bool,
    pub config: Config,
    pub managed_agent_config: Option<ManagedAgentConfig>,
    pub allowed_outbound_hosts: Vec<String>,
    pub block_url: Option<String>,
}

cost_tracker 和 current_turn 用 Arc 包裹,因为它们是多个并发执行的工具需要共享的可变状态。Arc<AtomicUsize> 保证了 current_turn 的原子递增不需要锁------在 tokio 的多线程调度器下,AtomicUsize 的操作使用的是 CPU 的原子指令(lock inc 在 x86 上),比 Mutex 快一到两个数量级。

CostTracker 同理,内部使用 AtomicF64(通过 atomic crate 提供,标准库尚未稳定 AtomicF64)来跟踪累计费用。

config 字段是 Clone 的完整配置对象副本------它的数据量不大(几个 KB),且在工具执行期间不会被修改,所以直接 Clone 比包在 Arc 里更简单,省去了一次解引用的开销。

allowed_outbound_hosts 是 Vec<String> 而非 Arc<Vec<String>> 或 &[String]。原因是 WASM 工具在执行时需要获取完整的所有权拷贝来构造 RunOption(它内部也要传给 Wasmtime 的 WasiCtx),所以没有理由保留引用------直接 Clone 后 move 进去即可。


ToolResult 与结构化输出

rust 复制代码
pub struct ToolResult {
    pub content: String,
    pub is_error: bool,
    pub metadata: Option<Value>,
}

is_error 不是 Rust 的 Result------它是用于标记 AI 层面的成功/失败,不是 Rust 程序层面的。一个 WASM 工具可能在沙箱中成功运行(Rust 层面 Ok),但它的输出指示操作失败(比如 file-read 要读的文件不存在)。AI 模型需要看到 is_error: true 来决定是重试还是向用户报告。如果这个字段缺失,AI 就无法区分"技术错误"和"业务失败"。

metadata 是一个 escape hatch,允许工具返回 Markdown 表格、diff 数据、图表配置等富结构化信息,供前端渲染使用。调用方式:

css 复制代码
ToolResult::success("文件内容如下:\n...")
    .with_metadata(json!({
        "lines": 42,
        "language": "rust",
        "diff_stats": {"added": 15, "removed": 3}
    }))

前端拿到这个 ToolResult 后,如果 metadata 中包含 language 字段,就用 CodeMirror 的高亮模式渲染代码块;如果包含 diff_stats,就渲染 diff 视图。工具开发者不需要关心渲染细节,只需要提供结构化数据。


WASM 工具的 execute 实现

WasmTool 的 execute() 比内置工具多了一个转换层:将 AI 生成的 JSON 参数转为 CLI 参数:

css 复制代码
async fn execute(&self, input: Value, ctx: &ToolContext) -> ToolResult {
    let args = value_to_cli_args(input);      // {"mode":"encode","input":"hello"}
                                               // → ["--mode","encode","--input","hello"]

    let mut options = RunOption::default();
    options.work_dir = Some(ctx.get_work_dir());
    options.allowed_outbound_hosts = Some(ctx.allowed_outbound_hosts.clone());
    options.block_url = ctx.block_url.clone();
    options.wasm_cache_dir = Some(ctx.get_app_cache_dir());

    let result = wasm_sandbox::run::execute(
        self.wasm_file.clone(), None, Some(args), options, None
    ).await;

    match result {
        Ok((stdout, stderr)) => {
            let output = decode::decode_bytes(stdout);
            // 尝试 JSON 解析------如果 WASM 工具返回 {"error":false,"content":"..."}
            match serde_json::from_str::<Value>(&output) {
                Ok(Value::Object(map)) => {
                    // 映射到 ToolResult 的 is_error、content、metadata
                }
                _ => ToolResult::success(output) // 非 JSON 输出,整段作为 content
            }
        }
        Err(e) => ToolResult::error(format!("{:?}", e)),
    }
}

JSON 映射有一个边界情况:如果 WASM 工具返回 {"content": "some text", "metadata": {...}},BoxAgnts 会自动映射为 ToolResult { is_error: false, content: "some text", metadata: Some(...) }。如果包含了 "error": true,则 is_error 设为 true。这套约定让 WASM 开发者可以输出纯文本(简单场景),也可以输出结构化 JSON(需要附带元数据的场景)。


内置工具的特点

再对比一下内置工具。BriefTool 的实现不到 50 行:

rust 复制代码
impl Tool for BriefTool {
    fn name(&self) -> &str { "brief" }
    fn description(&self) -> &str { "Send a formatted message to the user" }
    fn source(&self) -> ToolSource { ToolSource::BuiltIn }
    fn permission_level(&self) -> PermissionLevel { PermissionLevel::None }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "message": {
                    "type": "string",
                    "description": "The message to send"
                },
                "format": {
                    "type": "string",
                    "enum": ["text", "markdown"]
                }
            },
            "required": ["message"]
        })
    }

    async fn execute(&self, input: Value, ctx: &ToolContext) -> ToolResult {
        let params: BriefInput = serde_json::from_value(input)?;
        let formatted = match params.format.as_deref() {
            Some("markdown") => render_markdown(&params.message),
            _ => params.message.clone(),
        };
        ToolResult::success(formatted)
    }
}

和 WASM 工具的核心区别在于性能特征。内置工具没有沙箱启动开销------execute() 直接调用 Rust 函数,从进入到执行第一条逻辑指令的延迟是纳秒级的。WASM 工具即使有 .cwasm 缓存,从 tokio 任务调度到 Wasmtime 组件初始化的延迟也是微秒级的。对于几十 KB 小文本的读写操作,这个差异可以忽略;但对于需要亚微秒响应的高频操作(例如 AI 在循环中反复调用同一个工具做参数扫描),内置工具有明显优势。


统一的调度入口

gateway/src/api/tool.rs 中的 build_tools_with_mcp()(注意:文件名保留了历史命名,该函数实际上是 build_all_tools)将所有工具合并为一个 Arc<Vec<Arc<dyn Tool>>>:

rust 复制代码
pub async fn build_all_tools() -> Arc<Vec<Arc<dyn Tool>>> {
    let mut v = boxagnts_tools_manager::all_tools().await;
    // 扩展点:未来可在此接入外部工具协议
    // if let Some(manager) = &mcp_manager { ... }
    Arc::new(v)
}

返回 Arc<Vec<Arc<dyn Tool>>> 而非 Vec<Arc<dyn Tool>>,是因为同一个工具列表可能被多个并发的 Agent 对话引用。每个对话需要访问完整的工具列表(用于权限检查和匹配 ToolUse 请求),但不需要独立拷贝一份(列表内容在对话期间不变)。两层 Arc------外层是共享列表本身,内层是共享每个工具实例------避免了任何数据复制。


怎么加一个新工具

从开发者视角,添加工具的步骤非常简洁:

Rust 内置工具:

  1. 在 tools/src/ 下新建模块,实现 Tool trait
  2. 在 tools-manager/src/lib.rs 的 bundled_tools() 里加一行 Arc::new(MyTool)
  3. 编译整个项目

WASM 扩展工具:

  1. 用任意语言写一个 CLI 程序,确保 --help 输出符合约定格式
  2. 编译为 wasm32-wasip2 目标
  3. 将 .wasm 文件放入 extensions/tools/ 目录
  4. 完成。不需要触碰 BoxAgnts 的任何源码。

这种"源码级"和"文件级"两个注册通道的设计,既保证了内置核心工具的紧密集成(性能、类型安全),又保留了扩展生态的开放性(任意语言、零配置部署)。


总结

Tool trait 的六个方法构成了 BoxAgnts 工具系统的统一抽象层,它解决了"如何在同一个接口后面隐藏 Rust 函数、WASM 组件和 Cron 任务三种完全不同的执行实体"这个核心工程问题。

几个关键设计决策:

  • name() 和 description() 返回 &'static str,通过 Box::leak 将运行时解析的 WASM 工具元信息转为静态生命周期。对于低频注册的工具系统,泄露几百字节是可接受的 trade-off。
  • ToolContext 使用 Arc<AtomicUsize> 和 Arc<CostTracker> 实现无锁共享可变状态------AtomicUsize 的 fetch_add 在 x86 上是一条 lock inc 指令,比 Mutex 快一到两个数量级。&ToolContext 的不可变借用保证工具不可能修改共享上下文,这个保证来自编译器而非运行时检查。
  • 两层 Arc(Arc<Vec<Arc<dyn Tool>>>)在外层共享工具列表、内层共享工具实例,避免了多 Agent 并发场景下的数据复制。
  • ToolResult.metadata 为前端渲染提供了结构化通道------工具开发者只需要提供 JSON 元数据,前端按约定渲染对应的视图组件。

参考资源

相关推荐
择势9 小时前
iPhone Duo 适配实践:从几何原理到工程封装
ai编程
时早玖沐9 小时前
给 AstrBot 写一个天气推送插件:12 次提交、6 个坑
python·开源·ai编程
qinglongcn9 小时前
当 Agent “很努力但没用”——伪进展检测
agent·ai编程
niyongsheng9 小时前
3D 装箱算法工程化:数据结构、放置启发式与支撑规则
数据结构·算法·rust
灿飞9 小时前
第 3 章 第一个 Mojo 程序与 CLI 全解
ai编程
大模型真好玩9 小时前
DeepSeek Harness 插件的服务与事件监听详解
人工智能·agent·deepseek
程序员老刘9 小时前
从短剧App的bug看响应式编程在AI时代的优势
flutter·ai编程·客户端
ServBay10 小时前
API Key 泄露如何在 10 分钟内止血?AI 密钥管理必备
后端·ai编程
Hello.Reader10 小时前
tsrs 技术解析用 Rust 移植 TypeScript 7,类型检查还能怎样提速?
开发语言·rust·typescript