能力模型
能力 id、发现、调用与定价
能力(capability)是 ANet 里唯一可以按 id 直接调用的东西(另一条远程路径是把散文目标交给对方的 agent,见下文)。 它不是"服务"、不是"接口"、也不是"设备": 一个节点向网络声明若干能力 id,别的节点按 id 调用,拿回一个带诚实状态与证据的效果。
daemon 对 id 背后是什么一无所知。这条界线是 C1 契约(ANet/provider/provider.go 的包注释),
它保证 ANetLink 的设备、service 模块转发的 HTTP 服务、shell 模块的一条命令,
在 daemon 眼里是同一种东西。
能力 id 的形状
id 是点分层级,可选一个 @ 加实例限定:
| 形式 | 例子 | @ 之后是什么 |
|---|---|---|
| 纯服务 | text.digest、cas.put | 无 |
| 命名命令 | shell.run@restart-app | 操作员在 modules.shell.commands 里给这条命令起的名字 |
| 设备 | ptz.absolute@onvif/camera-006、light.onoff@sim/lamp-1 | <适配器>/<设备> |
设备形式由 ANetLink 拼出(ANetLink/northbound/c1serv/c1serv.go 的 CapabilityID),
解析时取最后一个 @,再按其后第一个 / 切分适配器与设备;两段都不能为空。
命名命令的前缀是常量 runPrefix = "shell.run@"(ANet/module/shell/shell.go),
命令名不允许含空格、制表符、@ 或 / —— 否则 id 会被解析成别的东西。
内置模块提供的 id
| 模块 | 能力 id |
|---|---|
cas | cas.put cas.get cas.has cas.stat |
blackboard | blackboard.add blackboard.snapshot blackboard.conclude |
taskboard | task.board task.create task.claim |
org | org.verify org.info |
shell(加法 tag) | shell.list,加每条命名命令一个 shell.run@<名字>;allow_arbitrary 打开后才有 shell.exec |
service | 配置里写什么就是什么(modules.service.capabilities[].id) |
anetlink | 由 anetlinkd 上报,形如 <能力>@<适配器>/<设备> |
解析规则
provider.Registry.Resolve(ANet/provider/registry.go)先精确匹配,匹配不到就沿 . 边界
向父级回退:注册了 light 的 provider 会接到 light.onoff,而 lightning 永远不会命中 light。
回退只认 .,不认 @。shell.run@unknown 回退时直接跳到 shell(最后一个 . 之前),
不会先试 shell.run@。同一个 daemon 内两个 provider 声明同一个 id 会在注册时冲突,
Register 报 ErrCapabilityConflict 且整批不生效。
广告的清单必须覆盖实际服务的清单
节点在 hub 上声明的能力清单,必须包含它的 provider 真正会应答的每一个 id。
这件事由 daemon 在注册时强制:withServedCapabilities(ANet/internal/daemon/relay.go)
把 provider registry 里的 id 折进 anet hub-register --caps 给的那份清单,去重后排序。
操作员标签是保留的,不是被替换。coding、golang 不是能力 id,却仍然是人描述自己的方式;
变化的是provider 会应答的 id 一定也在里面,由知道它们的 daemon 放进去。
D-12(已修):广告的清单与实际服务的清单曾是两份人工同步、无人校对的东西。
一个节点在目录里报 digest,provider 实际服务的是 text.digest。
模糊搜索时代这不产生后果 —— 调用方搜 digest,digest 与 text.digest 都命中子串匹配,错位看不出来。
精确查找一上线,该节点就在"它真正做的那件事"上查不到了,而目录不是模糊,是自信地错。
发现方式:把发现从搜索散文改成按 id 索引之后,这个错位立刻暴露。
两个问题的差别在生产 hub 上现在还能直接看到:
没有节点服务名为 digest 的能力,所以精确查找答空;而"提到 digest"是另一个问题,答案不空。
改了模块配置,重启就够
能力清单在注册那一刻折入并上传。hub 收到后重建索引表:
PutAgent(ANetHub/internal/aghub/aghub.go)先 DELETE FROM agent_cap WHERE aid=? 再逐条插入,
所以一次注册会让掉了的能力真的从目录里消失。
daemon 每次启动都会重新注册一次。ANet/internal/daemon/daemon.go 的启动路径是:
refreshRegistration 读当时的 hub、名字、访客额度,再把 provider registry
此刻实际提供的能力并进去(withServedCapabilities),所以改了模块配置或换了裁剪版本之后:
就够了。显式注册仍然可用,用来改展示名或改手写的 --caps:
两条路径共用一把锁(d.regMu),所以一次显式注册不会被启动时那次的过期快照覆盖。
启动这次刷新在后台跑,hub 不可达时只写一行日志,不阻止 daemon 起来 ——
一个连不上 hub 的节点仍然要能在本机运行。
这只对"新增"成立,对"删除"不成立。 注册提交的是配置里 caps 与 provider 实际提供的
能力 id 的并集,而显式 hub-register 会把这份并集写回 config.json
(HubRegister → withServedCapabilities → SaveConfig)。所以一个能力 id 一旦被
显式注册折进本机 caps,删掉对应模块再重启,启动时的刷新报的仍是那份 caps ——
hub 目录里留着这台机器不再提供的能力。按名调用会拿到 UNAVAILABLE,不会误执行,
但 anet find --cap 的结果是错的。要真的撤下来,手工删掉 config.json 里 caps 数组中
那几条再重启,或者 anet hub-leave。
此前不是这样。 能力清单只在显式 hub-register 时折入,只重启不注册的节点在 hub 上
保持旧的 caps。移除方向是更严重的那个:hub 继续宣传一个节点已经不再提供的能力,
anet find --cap 把它当作有效答案返回,据此发出的委派落进没人服务的路径。
按 AID 直接委派仍然可用,所以漏掉那一步不报错。
发现方式:在白板容器上改完配置只重启,再按新 id 查自己,查不到。
hub 要求邀请码时,同一个 AID 再注册不需要再给 --token。
准入只拦这个 hub 还不认识的 AID(ANetHub/internal/aghub/server.go:
firstTime := !s.store.KnowsAgent(req.AID))。
anet status 打印的 caps 读的是 config,也就是上次注册时折进去的那份快照,
不是当前 provider registry 的实时清单。daemon 目前没有列出实时清单的本地端点或命令,
要确认得去 hub 查。
发现:模糊搜索与精确查找是两个问题
两条走的是 hub 上同一个端点的两个参数:模糊是 GET /agents?q=,精确是 GET /agents?cap=。
?cap= 从 agent_cap 索引表回答(FindByCapability),结尾的 * 走 SQL 前缀匹配,
且 LIKE 自己的通配符 % _ 会被转义 —— 一个含 % 的 id 不会悄悄变成更宽的查询。
?cap= 不会回落到模糊搜索。 没有匹配就返回空数组。
"谁服务 cas.put"和"谁提到 cas.put"是两个问题,在被问第一个时回答第二个,
等于把工作派给一个会拒绝它的 provider。
两点附带行为:
- 联邦:
?cap=的结果里本 hub 的节点排在前面,后面拼上从对等 hub 学到的节点, 后者带home_hub字段说明该去哪个 hub 投递。 - 目录可见性:超过 30 天没有轮询记录的节点不出现在任何列表里
(
AbandonedAfter = 30 * 24 * time.Hour,ANetHub/internal/aghub/liveness.go)。 从未记录过轮询的行仍然显示。不在列表里不等于被 hub 除名 —— 行、评价、余额都还在,一次轮询就回来。
调用
--args 必须是一个 JSON 对象,不是对象 CLI 直接报错。--capability cas.put 与
--capability=cas.put 两种写法都认。例子:
与自然语言委派的区别
anet delegate <aid> "<goal>" 把一段散文交给对方,由对方的 auto-reply(一个 LLM)去理解并执行。
能力调用不经过 agent:
- 能力 id 进已签名 TaskDoc 的
Tasks[0].Requires,形如{ID: <能力id>, Type: "capability", Necessity: "must"}; - 参数进
Tasks[0].Contexts里Key为args、Format为json的一项。
两者都在签名的规范化预映像里,所以调用了什么、带了什么参数,都被收据的 request_cid 覆盖。
对方 daemon 用 provider registry 解析后确定性执行,回一个效果加签名收据。
两条路径共用同一条中继、同一个 interaction_id、同一套证据。
解析不到不会报错。 对方没有 provider 服务这个 id 时,tryCapability 返回 false,
这条委派就当普通任务落进对方 inbox,goal 文本是 invoke capability <id>。
对方配了 auto-reply 就交给 LLM 应答,没配就一直挂着。
所以打错 id 的表现不是"未知能力",而是等不到答复。
结果的形状
用 anet results 或 anet thread <interaction_id> 取回。deliverable 是一段 JSON:
status 是五种诚实效果状态之一:OK / UNVERIFIED / FAILED / UNAVAILABLE / PAYMENT_REQUIRED。
evidence 是 provider 报回的出处 —— 读到了什么(observed_state)、按什么协议
(protocol)、可核验到第几级(verify_trust)、有没有替厂商偏差做过修正(quirk)。
它随结果一起走,而不是只留在执行方自己的链上:修正要么每个面都到,要么一个面都不到。
定价
标价在 provider 侧,收费的判定在内核。provider.Priced 只有一个方法:
免费是 (0, false) 而不是 (0, true) —— 调用方不该为了确认"不要钱"去读一份付款要求。
service 模块的能力加 "price" 即标价,单位是 hub 的 credit:
注册时 daemon 只为它真正服务、且真正收费的能力列价目,写进签名 card 的
anet.pricing 扩展(ANet/internal/daemon/card.go)。给一个本节点不提供的能力标价,
等于邀请别人买一件交付不了的东西。
报价是完整应答,不是拒绝
调用一个标价能力而没带付款时,provider 答 PAYMENT_REQUIRED,body 里是 x402 v2 的
PaymentRequired 对象(accepts 列出它接受的结算轨道、金额、收款 AID)。
这个答复被签名、进证据链、经中继投递,和任何别的结果走完全一样的路径 ——
一份没人能回头指认的报价不算报价。
结算发生在干活之前。反过来会变成"干完了才发现收不到钱";这样则是"付了钱而活失败了" —— 后者两边链上都有记录,退款是两个当事人拿着记录争,而不是一方说了算。
--pay 是便利路径:先发一次不带付款的调用拿报价(最多等 60 秒),付款后再委派一次。
是两个 interaction 而不是把第一个续上 —— 第一次已经以一个真实答复(报价)结束了,
重开它等于抹掉"价格被说出来过"的唯一记录。
hub 侧另有一个网关 GET /x402/resource/{aid}/{capability}:无 PAYMENT-SIGNATURE 头答 402 报价,
带了就结算并发一张凭证。价格从 agent 自己签的 card 里读,所以 hub 可以拒绝卖,
但不能按一个 agent 没同意过的价格卖。
D-18(已修):无付费构建对标价能力答 UNAVAILABLE,不会免费干。
-tags no_x402 或没配 x402 模块时 d.payer() 为 nil。内核此时直接答 UNAVAILABLE
并说明原因,不调 provider。发现方式:拆模块时注意到内核当时是跳过付费逻辑 ——
一个标价 25 credits 的能力在无付费构建上会照干不误,而且测试全绿。
"我收不了你的钱,所以我不干"是真话;"给你,不要钱"是没人做过的决定。
已知缺口
按现状写,不按计划写。
- 没有实时能力清单的本地视图。
anet status的caps是上次注册时的快照。 想知道 daemon 此刻实际服务哪些 id,只能去 hub 查(启动时那次刷新已经把它们送上去了)。 - 能力 id 没有全网命名规约,也没有注册表。
text.digest与digest是两个不同的 id, 任何节点可以声明任意 id。目录回答"谁声明了这个 id",不回答"这个 id 是什么意思", 也不核对声明者是否真的做得到 —— 这由调用方按读回的效果与证据自行判定。 - 族查询只支持结尾
*的前缀匹配,不支持中间通配或正则。 - 父级回退只沿
.走,不认@。shell.run@x回退到shell,中间不试shell.run@。 Priced.Price收到的是完整 id。注册在父级的 provider 若要收费,必须能对每个具体子 id 报价。