BoxAgnts 的工具系统从 WASM 沙箱的指令级隔离,到 Tool trait 的统一抽象,再到 Provider 层的多模型适配,支撑了单个工具的安全执行和调用。但一个完整的 Agent 系统还需要三个额外的能力:知识复用(如何让 AI 在面对重复任务时保持一致性)、任务分拆(如何突破单个对话的上下文限制)和自动化执行(如何让任务按时间表触发)。这三个能力分别由 Skill 模板、Agent 子代理和 Cron 定时调度提供。
Skill 模板:为什么需要一种"不是工具的工具"
考虑这个场景:用户说"帮我审查一下 src/ 目录下的 Rust 代码"。AI 需要执行一串操作——用 file-glob 找所有 .rs 文件、用 file-read 逐个读取、用 file-grep 检查潜在问题、用某种格式输出结果。这 4 步操作的每一步都可以用现有工具完成,但如果每次都要 AI 从头决定流程,每次的输出格式和质量都会不一致。
Skill 解决的就是这个问题。一个 Skill 是一段 Markdown 格式的提示词模板,存放在 extensions/skills/<name>/SKILL.md 中。AI 调用 skill-tool 工具,传入技能名,系统返回展开后的提示词,AI 据此执行后续操作。
以 code-review 技能为例,其 YAML frontmatter 定义了元信息:
---
name: code-review
description: 对代码变更进行深度审查并输出结构化报告
when_to_use: 当用户要求审查代码、进行质量评估时使用
tools: read, bash, glob, grep
args:
- name: target
description: 要审查的文件或目录;留空则审查 git 暂存变更
required: false
---
正文是具体的工作指令,包含审查维度(逻辑正确性、安全性、性能、可维护性)、输出格式(Markdown 表格)和约束(只读、不修改代码)。
Skill 的执行流程是:
- AI 收到用户请求,判断匹配某个 Skill 的
when_to_use条件 - AI 调用
skill-tool,传入skill="code-review"和args="src/"(用户指定的目标路径) SkillTool读取code-review/SKILL.md,剥离 YAML 头,将正文中的$ARGUMENTS替换为"src/"- 返回完整的提示词文本给 AI
- AI 按照提示词中的指令,调用
file-glob、file-read、file-grep等工具,按表格式输出审查结果
关键代码:
// tools/src/skill/skill_tool.rs
async fn execute(&self, input: Value, ctx: &ToolContext) -> ToolResult {
let params: SkillInput = serde_json::from_value(input)?;
if params.skill == "list" {
return list_skills(&search_dirs(ctx)).await;
}
let (_path, raw) = find_and_read_skill(¶ms.skill, &search_dirs(ctx)).await?;
let content = strip_frontmatter(&raw);
let prompt = content.replace("$ARGUMENTS", ¶ms.args.unwrap_or_default());
ToolResult::success(prompt)
}
Skill 和 Tool 的核心差异在于执行主体不同。Tool 的执行主体是 BoxAgnts 运行时——系统调用 tool.execute(),拿到结果,返回给 AI。Skill 的执行主体是 AI 本身——系统只负责替换模板变量并返回文本,后续的工具调用由 AI 自主决策和执行。这意味着 Skill 不仅定义了"要做什么",还定义了"怎么做"和"输出什么格式",是一种更高层次的抽象。
Agent 子代理:分治复杂任务
单个 AI 对话在处理大规模任务时会触碰到两个天花板:上下文窗口和注意力衰减。
上下文窗口的天花板很好理解——如果你的项目有 100 个 Rust 文件、总共 5 万行代码,审查所有文件的对话历史会在几轮内填满 200K token 的上下文。注意力衰减则是更隐蔽的问题:LLM 在长上下文中对中间部分的信息检索能力明显下降(所谓的 "lost in the middle" 问题),处理到第 10 个文件时,第 1 个文件的信息可能已经被忽略。
BoxAgnts 的 Agent 子代理机制就是对着这两个问题去的。AgentTool 让主 Agent 可以创建子 Agent,将复杂任务拆分为多个独立的子任务:
// tools/src/agent/mod.rs
struct AgentInput {
description: String, // 子任务简述
prompt: String, // 子任务的完整指令
tools: Option<Vec<String>>, // 子 Agent 可用的工具(默认:全部减去 AgentTool)
max_turns: Option<u32>, // 最大轮次,默认 10
model: Option<String>, // 模型覆盖(子 Agent 可以用不同模型)
run_in_background: bool, // 是否异步后台执行
}
子 Agent 的执行模式分为同步和异步两种:
同步模式(run_in_background = false):主 Agent 调用后阻塞,等待子 Agent 完成任务并返回结果。适用于主 Agent 需要子任务结果才能继续的场景。
异步模式(run_in_background = true):主 Agent 立即获得一个 agent_id,子 Agent 在后台独立运行。主 Agent 可以继续处理其他任务,后续通过 agent_id 查询结果。适用于多个独立子任务并行处理的场景。
一个实际例子:用户要求"全面审查这个项目"。
主 Agent:
│
├── 创建子 Agent A:"审查 backend/src/ 的 Rust 代码,关注逻辑和安全性"
│ └── 子 Agent A:独立的 Query Loop,使用 file-read/file-grep/bash
│ └── 返回:Markdown 表格,列出 15 个问题(3 个严重、7 个中等、5 个轻微)
│
├── 创建子 Agent B:"审查 frontend/src/ 的 Vue 组件,关注性能和可访问性"
│ └── 子 Agent B:独立的 Query Loop
│ └── 返回:Markdown 表格,列出 8 个问题
│
└── 汇总 A 和 B 的结果,输出综合报告
每个子 Agent 有独立的上下文窗口(不共享对话历史),所以不会相互污染。三个子 Agent 可以并行执行(异步模式),总时间取决于最慢的那个。
递归安全是一个重要的约束。子 Agent 的工具列表默认排除 AgentTool 本身——防止无限递归创建子子 Agent。如果用户确实需要多层代理(主 Agent → 子 Agent → 子子 Agent),可以通过显式配置子 Agent 的 tools 列表来包含 AgentTool。
上下文压缩
长时间、多工具、多轮次的 Agent 对话可能产生巨大的消息历史。即使每次工具调用的结果不大(比如 file-read 返回一个函数的内容),累积 50 轮后的总 token 数也会很大,挤占模型的推理空间。
BoxAgnts 的 AutoCompactState 处理这个问题。它监控消息历史和累计工具结果的总大小,当接近模型上下文限制时自动触发压缩:
检测到上下文压力(消息总 token 接近 context_window 的 80%)
│
▼
1. 筛选可压缩消息
- 优先压缩旧的 tool_result ContentBlock(工具执行结果)
- 保留最近的 N 轮对话完整内容
- 保留所有 user 和 assistant 消息(不压缩对话)
│
▼
2. 生成摘要
- 旧的 tool_result 被替换为:"[Earlier tool result from file-read: 读取了 src/main.rs,返回 42 行 Rust 代码]"
│
▼
3. 重新计算 token 数
- 如果仍超过限制,增加被压缩的轮次范围
有一个具体的配置项 tool_result_budget,默认值为 50,000 字符。当所有工具结果的累计字符数超过这个值时,最早的 tool_result 被截断替换。
压缩策略的取舍是:工具结果可能包含 AI 后续决策需要参考的细节(比如读取了一个配置文件的特定字段),摘要化会丢失这些信息。但对于典型的使用模式——最近几轮的工具结果仍然是最相关的——这种取舍是可接受的。
Cron 定时调度
工具执行的最后一个维度是时间。不是所有的 AI 任务都是由用户实时触发的——"每天早上 9 点生成本日代码质量报告"、"每隔 6 小时检查服务器日志中的异常"这类场景需要定时调度。
BoxAgnts 的 Cron 系统基于 tokio-cron-scheduler 实现:
pub async fn schedule_job(state: AppState, job_cfg: JobConfig) {
let cron_job = Job::new_async(&job_cfg.cron, move |_uuid, _lock| {
// 触发时:
// 1. 创建新的 AI 对话会话
// 2. 注入 job_cfg.prompt 作为用户消息
// 3. 执行完整的 Agent 循环(和用户触发的对话完全一致)
// 4. 记录 JobLog { id, executed_at, success, message, error }
});
scheduler.add(cron_job).await;
}
每个 Job 的配置包含:
{
"name": "每日代码质量报告",
"cron": "0 9 * * *",
"prompt": "检查 src/ 目录下的代码变更,生成本日质量报告",
"model": "claude-sonnet-4-5",
"timeout": 300,
"enabled": true
}
关键设计点:
- 超时保护:每个 Job 有独立的超时设置。如果 AI 对话在 5 分钟内没有完成,系统会取消该次执行并记录超时日志。这防止了一个跑飞的 Agent 占用所有资源。
- 调度器持久化:Job 配置和最近的执行日志存储在 SQLite 中。服务重启后自动重新加载所有 Job。
- 执行独立性:每个 Cron 触发创建一个独立的对话会话,不共享消息历史。这和 Agent 子代理的隔离模式一致——上下文污染的问题不在 Cron 场景下存在。
权限过滤
不同 Agent 可能需要不同的权限级别。BoxAgnts 支持按工具权限级别过滤:
pub async fn filter_tools_for_agent(
tools: Arc<Vec<Arc<dyn Tool>>>,
access: &str,
) -> Arc<Vec<Arc<dyn Tool>>> {
match access {
"full" => tools,
"read-only" => {
tools.iter()
.filter(|t| matches!(t.permission_level(), ReadOnly | None)
|| t.name() == "ask-user-question")
.collect()
}
_ => tools,
}
}
这允许创建"只读 Agent"——它可以使用 file-read、file-glob、file-grep、web-fetch 等只读工具,但不能使用 file-write、file-edit、bash 等写入或执行工具。对于一个只做代码审查的 Agent,这个限制是天然的。
总结
BoxAgnts 的高级编排层由三个机制构成,各自解决 Agent 系统中的一个关键缺口:
- Skill 模板解决知识复用问题。将"怎么做一件事"的最佳实践固化为 Markdown 提示词模板,AI 调用
skill-tool获取展开后的指令,然后自主执行后续操作。与 Tool 的核心差异在于执行主体——Tool 由系统执行,Skill 由 AI 按指令自主执行。 - Agent 子代理解决上下文窗口和注意力衰减问题。主 Agent 创建子 Agent 处理独立子任务,每个子 Agent 有独立的上下文窗口,避免长对话中的 "lost in the middle" 效应。同步模式用于需要结果的场景,异步模式用于并行处理的场景。默认排除
AgentTool自身防止无限递归。 - Cron 定时调度解决时间维度的自动化问题。每个 Job 有独立的超时保护、SQLite 持久化和隔离的对话会话。即使一个定时任务跑飞,也不会影响其他任务或主对话。
AutoCompactState 的上下文压缩和 PermissionLevel 的权限过滤作为基础设施支撑这三个机制:前者在消息历史接近 token 限制时自动压缩旧的工具结果,后者让不同 Agent 可以拥有不同的工具权限级别。
参考资源
- BoxAgnts 源代码:github.com/guyoung/box…
- Skill 模板规范(SKILL.md):github.com/guyoung/box…
- tokio-cron-scheduler:docs.rs/tokio-cron-…
- Claude Code 子代理架构:blog.promptlayer.com/claude-code…
- "Lost in the Middle" 论文:arxiv.org/abs/2307.03…
- OpenClaw Heartbeat 机制:learnopenclaw.org/architectur…