- 后端
- 物联网
- 消息队列
- 通信
【免费下载链接】emqx
The most scalable and reliable MQTT broker for AI, IoT, IIoT and connected vehicles
A2A(Agent-to-Agent)Registry 是 EMQX 中用于注册、发现和检索 AI Agent 卡片的命名空间化存储能力。本文围绕变更记录 fix-17936.en.md 展开:修复了 HTTP API 中属于全局命名空间的 A2A 卡片在返回时namespace字段被格式化为字符串"global"的问题,现在统一以 JSON 的null呈现,从而与具体命名空间明确区分。读完本文,你将理解该修复的成因、card_out/1格式化函数的实现原理、相关 HTTP API 与 CLI 的完整行为,以及测试用例如何固化这一契约。
一、变更背景:A2A Registry 与命名空间模型
A2A Registry 是 EMQX 中面向 AI Agent 场景的注册表功能,用于让 Agent 通过标准化的"Agent Card"(代理卡片)发布自身能力,供其他 Agent 或上层编排系统发现和调用。在 emqx_a2a_registry 应用中可以找到其完整实现:
- emqx_a2a_registry.erl — 核心存储与查询逻辑,基于 EMQX 内置的 Retainer(保留消息)实现卡片的持久化;
- emqx_a2a_registry_api.erl — 基于
minirest_api的 HTTP 接口层; - emqx_a2a_registry_adapter.erl — 卡片输出格式化与注册错误归一化;
- emqx_a2a_registry_cli.erl —
emqx_ctl命令行入口。
A2A 卡片通过**发现主题(discovery topic)**存储。从 emqx_a2a_registry_internal.hrl 可知:
-define(A2A_TOPIC_NS, <<"$a2a">>). -define(A2A_TOPIC_V1, <<"v1">>). -define(A2A_TOPIC_DISCOVERY, <<"discovery">>).emqx_a2a_registry.erl 中discovery_topic/4展示了命名空间对主题的两种构造方式:
%% 全局命名空间:$a2a/v1/discovery/{org_id}/{unit_id}/{agent_id} discovery_topic(?global_ns, OrgId, UnitId, AgentId) -> emqx_topic:join([?A2A_TOPIC_NS, ?A2A_TOPIC_V1, ?A2A_TOPIC_DISCOVERY, OrgId, UnitId, AgentId]); %% 特定命名空间:{namespace}/$a2a/v1/discovery/{org_id}/{unit_id}/{agent_id} discovery_topic(Namespace, OrgId, UnitId, AgentId) when is_binary(Namespace) -> emqx_topic:join([Namespace, ?A2A_TOPIC_NS, ?A2A_TOPIC_V1, ?A2A_TOPIC_DISCOVERY, OrgId, UnitId, AgentId]).也就是说,"全局命名空间"是一个特殊的系统级命名空间,它不参与主题前缀,而任何具体命名空间都会作为主题前缀出现。在 EMQX 的配置体系中,全局命名空间常量定义在 emqx_config.hrl:
-define(global_ns, global).即global_ns就是原子global——这正是问题产生的地基。
二、问题描述:字符串"global"带来的歧义
变更记录原文指出:
Fixed the formatting of A2A cards belonging to the global namespace in the HTTP API. Previously, they would show as the string
"global". Now, they are formatted asnullto distinguish them from specific namespaces.
(修复了 HTTP API 中属于全局命名空间的 A2A 卡片格式化问题。此前它们会显示为字符串"global",现在格式化为null,以便与具体命名空间区分。)
为什么字符串"global"是有问题的?从 emqx.erl 的注释可以印证:global原子作为?global_ns使用时会"产生歧义"(produce ambiguous)。具体来说:
- JSON 语义歧义:客户端无法从
"namespace": "global"判断这究竟表示"全局命名空间"这一特殊标记,还是表示一个恰好名为global的具体命名空间; - 数据建模问题:全局命名空间并不是一个真实存在的命名空间名称,将其序列化为普通字符串会污染数据的语义边界;
- 过滤与匹配歧义:调用方若基于
namespace字段做筛选或去重,字符串"global"与真实命名空间"global"无法区分。
因此,修复的目标是:全局命名空间的卡片在 API 输出中namespace字段必须为null,而具体命名空间仍输出其名称字符串。
三、修复实现:card_out/1格式化函数的源码级解析
修复的核心落在 emqx_a2a_registry_adapter.erl 的card_out/1:
card_out(Card) -> emqx_utils_maps:update_if_present( <<"namespace">>, fun (?global_ns) -> null; (Ns) -> Ns end, Card ).其逻辑非常清晰:
- 仅当卡片 map 中存在
<<"namespace">>字段时(update_if_present)才进行转换; - 若该字段的值等于
?global_ns(即原子global),则替换为null; - 其他任何值(具体命名空间的二进制字符串)原样保留。
这里null是 Erlang 的原子,在 JSON 编码时会被序列化为 JSON 的null,而非字符串。这正是与"global"字符串在 JSON 层面的本质区别。
3.1 调用链:API 响应如何走到card_out
card_out/1在 HTTP API 的每个读取路径上都被调用,见 emqx_a2a_registry_api.erl:
- 列表接口
handle_list_cards/2(第 269-277 行):?OK(lists:map(fun card_out/1, Cards)),对每张卡片逐一格式化; - 单卡查询
handle_get_card/2(第 279-289 行):命中后?OK(card_out(Card))。
API 输出字段定义在fields(card_out)(第 150-159 行),包括namespace、id、name、version、description、status(online/offline)和raw(原始卡片 JSON)。其中status由 emqx_a2a_registry.erl 的lookup_agent_status/1根据客户端在线状态动态计算。
3.2 CLI 同样受益
修复并不仅限于 HTTP API。emqx_ctl命令a2a_registry list/get同样通过emqx_a2a_registry_adapter:card_out/1输出结果,见 emqx_a2a_registry_cli.erl 与 第 109 行。因此通过 CLI 查询全局命名空间卡片时,namespace也会呈现为null,保证两种管理入口的语义一致。
四、行为验证:测试用例如何固化契约
该修复的行为被 emqx_a2a_registry_api_SUITE.erl 的 CRUD 冒烟测试显式断言。在非命名空间(即全局)场景下:
?assertMatch({200, #{<<"namespace">> := _}}, get_card(?ORG_ID, ?UNIT_ID, ?AGENT_ID, TCConfig)), maybe false ?= get_config(namespaced, TCConfig, false), ?assertMatch({200, [#{<<"namespace">> := null}]}, list_cards(#{}, TCConfig)), ?assertMatch( {200, #{<<"namespace">> := null}}, get_card(?ORG_ID, ?UNIT_ID, ?AGENT_ID, TCConfig) ) end,两个关键断言:
list_cards(#{})(不带命名空间时等价于全局命名空间)返回的列表元素中<<"namespace">> := null;get_card(...)单卡查询返回<<"namespace">> := null。
同时测试矩阵t_crud/1覆盖了?no_namespace与?namespaced两种配置(第 193-194 行),确保命名空间化场景下namespace仍能正确输出具体值(第 206 行仅断言<<"namespace">> := _存在)。这组用例从正反两个方向锁定了"全局为null、具体命名空间为字符串"的行为契约。
五、实际使用:HTTP API 与命名空间解析
5.1 接口总览
API 由 emqx_a2a_registry_api.erl 的paths/0声明,命名空间前缀为a2a:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /a2a/cards/list | 列出卡片,支持org_id、unit_id、agent_id、ns、only_global查询参数 |
| GET | /a2a/cards/card/:org_id/:unit_id/:agent_id | 查询单张卡片 |
| POST | /a2a/cards/card/:org_id/:unit_id/:agent_id | 注册/更新卡片,请求体为{"card": {...}} |
| DELETE | /a2a/cards/card/:org_id/:unit_id/:agent_id | 删除卡片 |
其中:org_id、:unit_id、:agent_id三个路径段均须匹配段 ID 正则^[A-Za-z0-9._-]+$(见 emqx_a2a_registry_cli.erl),类型定义见 emqx_a2a_registry_types.erl。
5.2ns查询参数与全局过滤
命名空间通过ns查询参数传入。在/a2a/cards/list的处理逻辑(第 243-250 行)中:
'/a2a/cards/list'(get, #{query_string := QueryParams} = Req) -> Namespace0 = get_namespace(Req), Namespace = case maps:get(<<"only_global">>, QueryParams, false) of false when Namespace0 == ?global_ns -> all; _ -> Namespace0 end, handle_list_cards(Namespace, QueryParams).值得注意的细节:
- 请求方未指定命名空间(
Namespace0 == ?global_ns)且未设置only_global=true时,Namespace被置为all,此时 emqx_a2a_registry.erl 会同时用全局与通配命名空间两条主题过滤匹配,即默认列出全部命名空间的卡片; - 请求方显式设置
only_global=true时,仅列出全局命名空间的卡片——这类卡片的namespace字段在响应中即为null; - 传入具体
ns时,仅匹配该命名空间下的卡片,其namespace字段输出为对应字符串。
5.3 命名空间权限校验与审计
在 filter/2 中,每个请求都会先经resolve_namespace/2解析目标命名空间:
- 若请求方自身处于具体命名空间(
ActorNamespace /= ?global_ns),却试图用ns参数操作其他命名空间,则返回 403not_authorized(第 426-442 行); - 若是受管命名空间,还需通过
namespace.resource_pre_create钩子确认其存在(第 454-465 行),否则返回 400 "Managed namespace not found"。
修复还联动优化了审计日志:log_ns/1(第 447-452 行)在请求全局命名空间卡片时,向minirest_handler的日志元数据写入namespace => <<"global">>,弥补了"仅凭 URL 无法区分全局卡片与命名空间卡片"的审计盲区(代码注释中明确关联了同类问题 emqx/emqx#18653)。
5.4 响应示例
修复后,全局命名空间卡片的典型响应如下(namespace为null):
{ "namespace": null, "id": "my.org:my.unit:my.agent", "name": "some_agent", "version": "1", "description": "description", "status": "online", "raw": "{...}" }而具体命名空间(如my-ns)下卡片的响应则为"namespace": "my-ns"。
六、前置条件与配置
A2A Registry 依赖 EMQX 的 Retainer(保留消息)功能,因为卡片正是以保留消息形式存储在$a2a/v1/discovery/...主题上。若 Retainer 未启用,所有接口会返回 404 并附带提示信息(见 emqx_a2a_registry_api.erl):
A2A registry requires the retainer feature. Enable retainer under 'Retained Messages' in the Dashboard, or set
retainer.enable = truein the configuration.
相关开关配置路径由 emqx_a2a_registry_config.erl 定义:
is_enabled() -> emqx_config:get([a2a_registry, enable], false). is_schema_validation_enabled() -> emqx_config:get([a2a_registry, validate_schema], true).a2a_registry.enable:功能总开关,默认false。关闭时 HTTP 接口统一返回 503 "Not enabled"(见 filter/2),CLI 命令同样拒绝执行(见 emqx_a2a_registry_cli.erl);a2a_registry.validate_schema:注册卡片时是否按 agent_card_schema.json 做 schema 校验,默认true。
卡片注册时的错误信息也由 emqx_a2a_registry_adapter.erl 统一格式化,例如Bad org_id id: xxx、Card does not conform to schema、Namespace not found: xxx等,分别对应 400 或 500 响应。
七、总结
本次修复(对应 fix-17936.en.md)从数据语义层面消除了 A2A Registry HTTP API 的一个歧义点:
- 行为变化:全局命名空间卡片的
namespace字段由字符串"global"改为 JSONnull,具体命名空间仍输出字符串; - 实现落点:
card_out/1(emqx_a2a_registry_adapter.erl)作为唯一格式化出口,同时作用于 HTTP API 与 CLI 查询路径,保证语义一致; - 契约固化:emqx_a2a_registry_api_SUITE.erl 以断言形式锁定了全局返回
null的行为,防止回归。
对于基于 EMQX 构建 Agent 生态的开发者而言,理解这一约定至关重要:在消费/a2a/cards/list或/a2a/cards/card/:org_id/:unit_id/:agent_id的响应时,应以namespace == null判定全局命名空间卡片,而非字符串"global",从而避免与真实命名为global的命名空间混淆。
- 后端
- 物联网
- 消息队列
- 通信
【免费下载链接】emqx
The most scalable and reliable MQTT broker for AI, IoT, IIoT and connected vehicles
相关推荐
EMQX A2A Registry HTTP API 命名空间隔离机制深度解析
EMQX A2A Registry HTTP API 命名空间隔离机制深度解析 A2A(Agent to Agent)Registry 是 EMQX 提供的智能
后端物联网消息队列通信TypeSpec 全局命名空间场景解析:@typespec/http-client-js 如何为无顶层命名空间的规范生成 TypeScript 客户端
TypeSpec 全局命名空间场景解析:@typespec/http client js 如何为无顶层命名空间的规范生成 TypeScript 客户端 本文基于
编程语言编译器后端Slate v2 API Helper 命名空间重命名:以 `*Api` 后缀消除 DOM 全局变量遮蔽
Slate v2 API Helper 命名空间重命名:以 Api 后缀消除 DOM 全局变量遮蔽 导读 本文基于仓库中的 Slate v2 Api Helpe
前端富文本UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考