Agent 页面接进真实应用以后,有个容易被忽略的问题:端侧到底怎么知道自己能调用哪些能力。这些能力如果只埋在服务端代码里,端侧看不到参数要求,也看不到风险高低,就只能把按钮写死在页面上,服务端每加一个能力,端侧跟着改一次。
这次把本机 Agent Gateway 里的工具整理成一份能力注册表。每个能力都带上名称、描述、输入结构、风险等级、要不要确认、返回示例。鸿蒙端不写死按钮,改成从 /tools 动态读取这份注册表,再按注册表里的信息决定哪些能直接执行。
能力先得有一份能被读懂的说明
这些能力最终都会落到某个执行入口:查时间、看机器状态、检查 URL、写待办、检索材料、巡检设备。入口写在哪儿是次要的,关键是入口前面有没有一份端侧和模型都能读懂的说明。
本机 Gateway 先跑起来,所有能力从这里统一暴露。模型 Key 只留在 Gateway 环境变量里,鸿蒙端只跟 Gateway 通信,也不去猜服务端有什么接口,而是先读 /tools 返回的能力清单。
清单里每个工具都有一组固定字段。拿 http_check 举例,它带着风险等级和参数要求,不是给个 URL 就完事:
json
{
"name": "http_check",
"description": "检查一个 HTTP 地址是否能访问,用于发布前接口巡检。",
"riskLevel": "medium",
"needConfirm": true,
"inputSchema": {
"required": ["url"]
}
}
这几个字段直接决定端侧怎么处理这个能力。riskLevel 决定页面上怎么标风险,needConfirm 决定能不能直接点执行,inputSchema 交给 Gateway 做参数校验。模型能选哪个工具,但选不出清单以外的东西。

普通接口列表告诉开发者"有这么个 URL"就够了。这份注册表要多担一层:把能力做什么、要什么参数、调用前要不要确认、返回大概什么样,都写进去,让端侧和模型直接读。
低风险能力可以直接执行
time_now 和 local_status 是低风险工具,只读本机 Gateway 状态,不改数据,也不碰外部系统,端侧可以放开让它们直接执行。
先在终端里调 time_now 和 local_status,返回里有当前时间、时区、系统版本、机器架构、磁盘使用率,还有一个 requestId。

这个 requestId 有用处。就算是低风险工具,也得保留一次调用的身份。终端、端侧页面、Gateway 日志里出现同一个 requestId,一次调用才追得回来,排查的时候不用光靠时间点去猜是哪一次。
风险低不代表能跳过校验。Gateway 该先查工具在不在、参数合不合 schema,一步都不少。端侧按钮只是个入口,真正卡边界的地方在服务端。
调一个没注册的工具,直接被拒
为了看边界在哪,故意调一个根本没注册的工具:
json
{
"tool": "delete_everything",
"arguments": {}
}
Gateway 回了 TOOL_NOT_FOUND,没有去做任何模糊匹配,也没把这事丢给模型自由发挥。

这一关挺要紧。智能体系统最怕的就是"看起来什么都懂",模型把用户一句话解释成一个不存在的能力,再指望服务端临时兜底。有了注册表,工具名必须来自白名单,白名单以外的一律不执行。
参数校验必须压在服务端
note_search 要一个 query 参数。故意不传,Gateway 返回 ARGUMENT_MISSING: query;把参数补上,工具才正常跑。

这道校验放在服务端,不能只放在 ArkUI 页面里。端侧可以做更友好的提示,但它不能当唯一的防线。只要请求能到 Gateway,就得按同一份 schema 校验参数,不然换个入口绕过页面就能钻空子。
注册表里的 inputSchema 顺带也让端侧知道每个工具要什么参数。当前页面先展示了基础字段,已经能说明能力边界是从服务端来的,不是写死在某个按钮上。
鸿蒙端从注册表把能力列表生成出来
端侧页面没把工具写死在数组里。核心就一个 loadTools(),请求 /tools,把返回的 tools 塞进 @State tools,交给 List 渲染。

页面做成了一个轻量的能力控制台:顶上是工具总数、可直接执行数、需要确认数;中间能按"全部、低风险、需确认"筛;每一条列出名称、描述、风险等级和确认策略。
加载完,鸿蒙端显示 8 个工具,7 个可直接执行,1 个需要确认。这几个数字不是页面里写死的,是从 Gateway 返回的注册表算出来的。

这个页面比单纯列一排按钮更像个能力控制台。用户不光知道有这个能力,还能看到它的风险和执行条件。像 http_check 这种中风险工具,端侧只给个锁定状态,不让直接点;低风险和中风险在同一个页面里被明确分开。
执行结果回到同一个面板
点 time_now 的执行按钮,页面拼一个 tool-registry- 加时间戳的 requestId,POST 到 /tools/call,底部"最近执行"区域直接回一段完整 JSON。页面不跳转、不弹窗,结果就留在当前这块。

time_now 结果短,适合先验证一遍端侧的执行闭环;local_status 会带回系统、机器架构、磁盘使用率和检查时间,能证明这次调用真的读到了机器状态。结果留在同一个面板里,用户能把"点了哪个工具"和"工具回了什么"对上号。
端侧又压了一层比风险等级更保守的白名单
注册表里 8 个工具,7 个标的是低风险。但页面并没有把这 7 个全放开让点。判断一个工具能不能执行的 canRun,除了看 needConfirm,还额外卡了一个工具名白名单:
ts
canRun(tool: ToolItem): boolean {
return !tool.needConfirm && (tool.name === 'time_now' || tool.name === 'local_status')
}
意思是,哪怕注册表说某个工具低风险,端侧这一版也只放开自己实际验证过的 time_now 和 local_status,其余低风险工具在页面上显示成"只读",需要确认的工具显示成"锁定"。同样是"不能点",端侧用两种文案分开:一个是"这一版还没放开",一个是"风险更高、要走确认流程"。风险等级是服务端给的建议,端侧在这个建议上又收了一道,宁可少放,不抢跑。
可执行、只读、锁定这三种状态,落到代码里就是一个 if (this.canRun(tool)) 分支加一个三元判断,页面不用为每个工具单独写逻辑,全靠注册表字段加这一层端侧策略推出来。筛选也是同一个思路:visibleTools() 按 filterMode 过滤,lowRiskCount()、confirmCount() 直接在 @State tools 上算,顶部那三个统计数、中间那份列表,都跟着注册表返回的数据走,没有一个是写死的常量。
能力边界这样就定住了
走到这一步,本机 Gateway 这边的能力边界已经清楚:模型只能从注册表里挑工具,挑不出清单以外的东西;参数不合 schema 会在 Gateway 被挡下;中风险工具在注册表里就标好了要确认。端侧这边也不再靠写死的按钮,加一个能力、改一个能力的描述或风险等级,页面重新拉一次 /tools 就跟着变,不用动 ArkTS 代码。
这份注册表是本机自建的,不是什么正式的能力发布流程。但它把加能力时最容易漏的几件事凑齐了:能力怎么描述、什么时候该触发、要哪些参数、风险多高、返回什么。少任何一样,端侧要么得靠写死的按钮硬对接,要么退回到"模型随口说个接口名、服务端临时补救"。把这些信息集中进注册表、让端侧去读,加能力和改能力这件事,才从"改两处代码"变成"改一处配置"。