Skip to content

函数级工具 ​

可运行示例在浏览器中运行本章示例

这里运行与本地夹具相同的 React 源码。模型请求由服务端网关转发,供应商密钥不会进入浏览器。

本地运行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:

tsx
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 把它注册成动作:

tsx
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说明操作是什么。没有可见控件可供推断时,这段说明尤其重要。
kindwrite 表明操作会改变应用状态;它不是授权本身。
available当前页面状态下是否值得尝试该操作。
run调用业务实现一次,并返回有界结果。
expect调用之后,应用预期能观察到什么。

本例没有参数,因此不需要 params。如果操作需要订单号、原因或筛选条件,应像元素动作一样声明参数;执行器会在 run 之前校验。TypeScript 类型不会留给运行时,模型也读不到它们,所以只写接口类型不够。

与元素动作共享同一条管线 ​

“没有元素”不等于“绕过执行器”。框架仍然先按 id 查找动作,再检查参数、策略与确认要求,然后调用 run,等待成功判据,最后比较执行前后的页面快照。函数级工具不能借此获得更高的权限,也没有一条专门的 eval 或 click(selector) 后门。

差别集中在元素相关环节:函数级工具没有可解析目标,没有原生禁用状态,也没有被裁剪或被弹窗遮挡的元素。因此 available 是开发者能提供的主要页面状态判断。它只说明“现在做这件事是否有意义”,不负责租户隔离、用户授权或服务端校验。

组件卸载时,hook 注册的动作随之注销;这使能力清单不会留下一个已经失效的函数。

一次调用,一个可观察的结果 ​

run 是完整的业务调用:

tsx
run: () => ({ completed: completeOpenTasks() })

它没有填隐藏输入框,也没有派发合成点击。任务集合被直接更新,返回值只保留数量。这个返回值会进入执行结果的 data;数量、短 id 或确认信息适合放在这里,整张任务表或完整的外部响应不适合。

成功判据放在 expect:

tsx
expect: {
  desc: copy.completeOpenExpect,
  check: () => tasksRef.current.every((task) => task.done),
}

框架会在 run 之后等待该条件。条件成立时,回执带上“没有待办仍处于未完成状态”;超时则报告 EXPECTATION_FAILED。这区分了两件事:函数已经返回,和应用确实到达了预期状态。

在 React 里尤其要注意,不要把 expect 写成某次渲染捕获的局部值,也不要用当前筛选结果代替业务状态。前者可能永远看不到新数组,后者只能证明可见行完成了,不能证明所有待办都完成了。

成功调用的回执大致如下:

json
{
  "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 会决定谁能调用仍需独立的策略、确认和服务端授权。
在条件分支里调用 hookuseAgentTool 位于提前返回之后在组件顶层调用,让注册和卸载跟随组件生命周期。

框架会拒绝缺少 desc 的声明、拒绝重复动作 id,并让不可用动作连同原因保留在能力清单里。它不会替应用决定业务策略,也不会把函数结果自动当成外部系统已经成功。

验证本章示例 ​

从仓库根目录启动中文示例:

bash
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。

依次检查:

  1. 对助手说:把所有未完成待办都标记为已完成。 两条未完成待办应变为已完成,记录字段和页面勾选状态同时更新。
  2. 打开 最近操作,进入 诊断信息。complete_open_tasks 的回执应包含 completed: 2、判据“没有待办仍处于未完成状态”,以及 t1、t2 两条记录更新。
  3. 再问一次。动作仍应出现在能力清单里,但带声明的不可用原因,而不是再次执行。
  4. 刷新页面,手动新增、勾选和删除待办,确认原有表单、复选框、删除按钮和筛选没有改变。
  5. 让助手新增一条待办,再执行“完成所有未完成待办”。新任务也应进入同一份记录差异。
  6. 查看诊断信息里的工具列表。complete_open_tasks 不应有元素目标,也不应被折叠成记录模板。

本章边界与下一章 ​

函数级工具解决的是“操作没有控件”的问题,不解决“控件是否可见”的问题。它在挂载且可用时始终存在,没有可以被滚出视口、被父元素裁剪或被弹窗遮挡的目标。因此,它不能用来绕过页面对真实控件的可见性规则。

下一章回到元素层,讨论区域、可见性与遮挡:页面如何告诉 Agent 当前活跃的是哪个区域,哪些控件被裁剪或覆盖,以及弹窗打开后哪些动作仍然有效。

下一章:区域、可见性与遮挡。