函数级工具
pnpm tutorial:dev r-08 --locale zh源码examples/tutorials/r-08-function-level-tools/上一章让 Agent 能通过记录 id 指认某一行任务,并在执行前后看到记录变化。到目前为止,每个动作仍依附于一个真实控件:表单、复选框、删除按钮或菜单项。
但应用里总有一些操作没有对应控件。比如页面支持把所有未完成待办一次标记为完成,却没有提供批量按钮;它可能是为移动端、命令行或另一个界面准备的业务能力,只在代码层存在。Agent 若要使用这类能力,不能假装页面上有一个可点击的元素。
函数级工具(function-level tool)为此而设。动作的实现是一段普通应用函数,不经过 DOM 交互;它仍使用同一个动作 id、权限检查、执行器和回执结构。唯一的差别是,它没有目标元素。
没有控件承载的业务操作
本章仍使用 Todo 示例。页面已经有逐行完成、删除、筛选和记录投影,但没有“全部完成”按钮。新增的业务操作只有一句:把所有未完成待办标记为已完成。
一种看似省事的做法,是放一个隐藏按钮,再把工具挂到按钮上,让 Agent 通过点击触发。这样做会把问题推向更难维护的地方:
- 动作声称操作一个人看不见、也无法到达的元素;
- 框架的可见性、遮挡和滚动规则开始作用在一个虚构目标上;
- 页面多出一个只为 Agent 存在的控件,连同它的键盘和辅助技术表面;
- 按钮样式或事件处理改变时,业务函数没有变,工具也可能失效。
更关键的是语义。模型看到的能力描述应当是“完成所有未完成待办”,而不是“点击一个隐藏按钮”。如果页面没有这个控件,就不应该在能力层伪造它。
先保留原有业务函数
手动交互完全不变。toggleTask、removeTask、筛选和表单提交仍由原来的 React 状态与事件处理完成。新增的批量操作也只改同一份 tasks:
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
function completeOpenTasks(): number {
const current = tasksRef.current;
const openTasks = current.filter((task) => !task.done);
const next = current.map((task) =>
task.done ? task : { ...task, done: true }
);
tasksRef.current = next;
setTasks(next);
return openTasks.length;
}函数的职责很窄:找出当前未完成项,更新它们,返回实际改变的数量。它没有点击任何东西,也没有把页面结构复制到另一套状态里。
这里额外保留了 tasksRef。原因不是框架要求,而是 React 的渲染时序:声明里的回调可能在组件重渲染后继续执行,如果它只捕获某一次渲染的 tasks,成功判据和可用性判断就可能读到旧数组。每次渲染把 ref 指向当前任务集合;批量函数在调用 setTasks 之前也先把新数组写入 ref,让成功判据能立即检查业务状态。DOM 的实际提交仍由 React 和框架的落定步骤负责。
用 useAgentTool 声明动作
业务函数写好后,用 useAgentTool 把它注册成动作:
useAgentTool({
id: "complete_open_tasks",
desc: copy.completeOpenDesc,
kind: "write",
available: () =>
tasksRef.current.some((task) => !task.done)
? true
: copy.completeOpenNone,
run: () => ({ completed: completeOpenTasks() }),
expect: {
desc: copy.completeOpenExpect,
check: () => tasksRef.current.every((task) => task.done),
},
});声明里没有 action、target 或 fidelity。没有元素需要点击、填写、按键或定位。框架把它记为 call 原语;fidelity: "high" 表示调用会直接到达开发者提供的函数,不表示业务结果一定是正确的。
各字段回答不同问题:
| 字段 | 作用 |
|---|---|
id | 模型使用的稳定身份,也是执行器查找动作的键。它与元素动作共用命名空间。 |
desc | 说明操作是什么。没有可见控件可供推断时,这段说明尤其重要。 |
kind | write 表明操作会改变应用状态;它不是授权本身。 |
available | 当前页面状态下是否值得尝试该操作。 |
run | 调用业务实现一次,并返回有界结果。 |
expect | 调用之后,应用预期能观察到什么。 |
本例没有参数,因此不需要 params。如果操作需要订单号、原因或筛选条件,应像元素动作一样声明参数;执行器会在 run 之前校验。TypeScript 类型不会留给运行时,模型也读不到它们,所以只写接口类型不够。
与元素动作共享同一条管线
“没有元素”不等于“绕过执行器”。框架仍然先按 id 查找动作,再检查参数、策略与确认要求,然后调用 run,等待成功判据,最后比较执行前后的页面快照。函数级工具不能借此获得更高的权限,也没有一条专门的 eval 或 click(selector) 后门。
差别集中在元素相关环节:函数级工具没有可解析目标,没有原生禁用状态,也没有被裁剪或被弹窗遮挡的元素。因此 available 是开发者能提供的主要页面状态判断。它只说明“现在做这件事是否有意义”,不负责租户隔离、用户授权或服务端校验。
组件卸载时,hook 注册的动作随之注销;这使能力清单不会留下一个已经失效的函数。
一次调用,一个可观察的结果
run 是完整的业务调用:
run: () => ({ completed: completeOpenTasks() })它没有填隐藏输入框,也没有派发合成点击。任务集合被直接更新,返回值只保留数量。这个返回值会进入执行结果的 data;数量、短 id 或确认信息适合放在这里,整张任务表或完整的外部响应不适合。
成功判据放在 expect:
expect: {
desc: copy.completeOpenExpect,
check: () => tasksRef.current.every((task) => task.done),
}框架会在 run 之后等待该条件。条件成立时,回执带上“没有待办仍处于未完成状态”;超时则报告 EXPECTATION_FAILED。这区分了两件事:函数已经返回,和应用确实到达了预期状态。
在 React 里尤其要注意,不要把 expect 写成某次渲染捕获的局部值,也不要用当前筛选结果代替业务状态。前者可能永远看不到新数组,后者只能证明可见行完成了,不能证明所有待办都完成了。
成功调用的回执大致如下:
{
"ok": true,
"action": {
"id": "complete_open_tasks",
"description": "Mark every open task as completed. This changes all open records in one operation."
},
"expect": "No task remains open",
"data": { "completed": 2 },
"changed": {
"observed": true,
"records": {
"added": [],
"removed": [],
"updated": [
{ "id": "t1", "fields": ["status"] },
{ "id": "t2", "fields": ["status"] }
],
"reordered": false
}
}
}结果里的三层信息用途不同:
data是函数返回的业务摘要;expect是框架实际观察到的成功判据;changed是执行前后已暴露记录之间的差异。
它们都不是服务端事务回执。如果任务随后还要同步到网络,本地状态先成功、服务端再拒绝,仍可能产生这份结果。远程正确性重要时,应用需要另外暴露服务端确认。变更摘要也只覆盖已声明字段,不能证明数据库、邮件或审计日志里发生了什么。
三种声明方式如何选择
React 适配器里的三种声明各有位置:
| 声明 | 适用情形 | 需要注意 |
|---|---|---|
usePlingTool + 原生交互 | 真实控件会接收交互,且原生行为可靠。 | 动作绑定元素,受目标解析、可见性与遮挡规则影响。 |
usePlingTool + run | 真实控件存在,但复现它的 DOM 交互不可靠,或会把一个业务操作拆成多步。 | 动作仍绑定元素,注入路径直接执行业务函数。 |
useAgentTool | 操作已经是应用逻辑,但没有、也不应有控件代表它。 | 没有目标、可见性检查或悬停高亮,可用性完全由声明决定。 |
不要仅仅为了省掉一份清楚的控件声明而使用函数级工具。真实按钮已经承载操作时,声明按钮通常更容易理解,也更容易和用户看到的界面保持一致。只有当“没有控件”本身就是产品事实时,函数级形式才更准确。
同样,打开弹窗、填写多个字段再保存的复合行为,应由流程组合,而不是塞进一个不透明的函数。一个函数级工具应对应一个可以解释清楚的业务操作。
常见错误
| 现象 | 原因 | 更好的处理 |
|---|---|---|
| 工具在实际不可用时仍然存在 | 没有写 available,或只检查组件是否挂载 | 检查业务前置条件,并返回一句可读原因。 |
| 调用成功,但无法确认结果 | 函数在页面落定前返回,或相关字段没有进入记录 | 添加 expect,暴露需要观察的字段,并等待 React 更新。 |
| 筛选后误判为“全部完成” | 成功判据检查了当前可见 DOM,而不是完整业务状态 | 读取最新任务集合,而不是当前列表视图。 |
| 批量执行后上下文变大 | run 返回了每个对象或完整的外部响应 | 只返回数量、id 或有界摘要。 |
把 kind 当成权限 | 以为 write 或 danger 会决定谁能调用 | 仍需独立的策略、确认和服务端授权。 |
| 在条件分支里调用 hook | useAgentTool 位于提前返回之后 | 在组件顶层调用,让注册和卸载跟随组件生命周期。 |
框架会拒绝缺少 desc 的声明、拒绝重复动作 id,并让不可用动作连同原因保留在能力清单里。它不会替应用决定业务策略,也不会把函数结果自动当成外部系统已经成功。
验证本章示例
从仓库根目录启动中文示例:
pnpm tutorial:dev r-08 --locale zh打开 http://127.0.0.1:5248/?lang=zh。英文界面使用 pnpm tutorial:dev r-08 和 http://127.0.0.1:5248/。示例源码位于 examples/tutorials/r-08-function-level-tools。
依次检查:
- 对助手说:
把所有未完成待办都标记为已完成。两条未完成待办应变为已完成,记录字段和页面勾选状态同时更新。 - 打开 最近操作,进入 诊断信息。
complete_open_tasks的回执应包含completed: 2、判据“没有待办仍处于未完成状态”,以及t1、t2两条记录更新。 - 再问一次。动作仍应出现在能力清单里,但带声明的不可用原因,而不是再次执行。
- 刷新页面,手动新增、勾选和删除待办,确认原有表单、复选框、删除按钮和筛选没有改变。
- 让助手新增一条待办,再执行“完成所有未完成待办”。新任务也应进入同一份记录差异。
- 查看诊断信息里的工具列表。
complete_open_tasks不应有元素目标,也不应被折叠成记录模板。
本章边界与下一章
函数级工具解决的是“操作没有控件”的问题,不解决“控件是否可见”的问题。它在挂载且可用时始终存在,没有可以被滚出视口、被父元素裁剪或被弹窗遮挡的目标。因此,它不能用来绕过页面对真实控件的可见性规则。
下一章回到元素层,讨论区域、可见性与遮挡:页面如何告诉 Agent 当前活跃的是哪个区域,哪些控件被裁剪或覆盖,以及弹窗打开后哪些动作仍然有效。
下一章:区域、可见性与遮挡。

