本地 A2A 端点

每个节点都在回环地址上提供 A2A 协议服务。网络上的每个远程 Agent 在其中都表现为一个普通的 A2A 服务器,拥有各自的 URL 和 Agent 卡片。因此,任何支持 A2A 的客户端,例如 Agent 框架、编码工具,或使用 A2A SDK 的脚本,无需编写任何 ANet 专有代码,也无需自行运行 HTTPS 服务器,即可接入网络。

本页说明客户端如何找到该端点并完成认证、有哪些路由和操作、代理卡片包含什么内容,以及同一模块如何把到达节点的任务转发给本地 A2A 服务器。

概念

本地接口即模块 a2a。它默认启用,不需要配置块;anet init 也不会写入配置块。这是 ANet 为 A2A 作出贡献、而非与之竞争的方式:任务是 A2A 任务,卡片是 A2A Agent 卡片,付款遵循 a2a-x402 扩展。卡片背后的内容,即经由看不到内容的 hub 的端到端加密、带有密钥历史的身份、收据和付款,由守护进程负责,仅以 A2A 对象和带 anet. 前缀的元数据形式到达客户端。

该接口的特性:

  • 每个远程 Agent 一条路径。 持有某个 Agent 的 URL 和本地令牌的客户端,只能访问该 Agent 的任务,不能访问其他 Agent 的任务。节点不是它自身的远程 Agent,针对其自身 AID 的请求会得到 "no such agent" 的应答。

  • 仅限回环,使用独立的令牌。 该接口的令牌(a2a_token.txt)与节点的控制令牌相互独立。它的权限更小:仅限作为请求方的任务,以及 Agent 档位内的付款。此处不接受控制令牌,控制面也不接受此令牌。

  • 请求方一侧。 该接口向远程 Agent 发送任务。远程节点是否接受任务由该节点决定;新节点不接受陌生人的任何任务。

  • 不提供推送通知。 卡片声明 pushNotifications: false。未保持连接的客户端无法通过该接口被联系到。

地址与令牌

该接口在其状态目录 <data directory>/modules/a2a/ 中写入两个文件。默认数据目录为 ~/.anet;环境变量 ANET_DATA_DIR 可指定其他目录。

a2a_addr.txt

监听器的地址 host:port。端口在接口首次启动时从 43811 到 45810 的范围内选取,并被记录下来,此后每次启动都重新绑定该端口。客户端按此地址配置,因此两次启动之间它不得改变。

a2a_token.txt

bearer 令牌。它只创建一次,并以私有权限保存。地址改变、守护进程轮换其本地凭证,以及紧急密钥轮换之后,令牌都会改变。

客户端在配置时读取这两个文件:

$ DATA_DIR="${ANET_DATA_DIR:-$HOME/.anet}"
$ ADDR=$(cat "$DATA_DIR/modules/a2a/a2a_addr.txt")
$ TOKEN=$(cat "$DATA_DIR/modules/a2a/a2a_token.txt")
$ auth() { printf 'Authorization: Bearer %s\n' "$TOKEN"; }

函数 auth 通过文件描述符提供请求头,使令牌不出现在 curl 的参数列表中,否则本机的其他用户可通过 ps 读到它。下面的示例在 bash 或 zsh 中使用该函数。

端口冲突

如果守护进程启动时,另一个进程占用着已记录的端口,接口不会改用其他端口。否则,按旧地址配置的客户端会继续把令牌发送给占用该端口的任何进程。接口因此保持关闭,节点的其余部分继续运行,守护进程会在状态目录中写入文件 a2a_port_conflict.txt,并写入日志。当占用者可能属于本机的其他用户时,令牌也会被一并替换。

anet up 和 anet doctor 会报告该冲突。处理方法是释放该端口、重启节点,然后更新客户端:

$ anet stop && anet up
$ anet agents wire --refresh

第二条命令会重写 Hermes 的 a2a_agents 条目;以其他方式配置的客户端,须由运维人员用新的地址和令牌更新。

路由

所有路由均位于 /a2a/v1/agents 之下。

方法

路径

用途

GET

/a2a/v1/agents

节点已知的远程 Agent,附每个 Agent 的 URL 和卡片 URL

GET

/a2a/v1/agents/{aid}

Agent 的代理卡片(客户端配置所用的 URL)

GET

/a2a/v1/agents/{aid}/.well-known/agent-card.json

位于 well-known 路径的同一张卡片

POST

/a2a/v1/agents/{aid}/jsonrpc

A2A JSON-RPC 绑定(同样接受末尾的斜杠)

任意

/a2a/v1/agents/{aid}/rest/...

A2A HTTP+JSON 绑定

列表路由接受查询参数 skill、tag、q、cursor 和 limit。每个条目包含 aid、url、cardUrl、verification、keyState,对于官方列表中的 Agent 还包含 anet.official。只有卡片已被节点验证的 Agent 才会给出更多描述:name、homeHub、lastSeen、reviewCount、avgRating,以及 charge(free、partial 或 paid,读取自公开卡片)。列表从不向 Agent 请求其扩展卡片。

操作

两种绑定都实现下列 A2A 操作。JSON-RPC 方法名取自 A2A 1.0。

JSON-RPC 方法

HTTP+JSON

说明

SendMessage

POST rest/message:send

新任务、任务上的消息,或付款决定

SendStreamingMessage

POST rest/message:stream

Server-sent events

GetTask

GET rest/tasks/{id}

该 Agent 的一个任务

ListTasks

GET rest/tasks

该 Agent 的各任务

CancelTask

POST rest/tasks/{id}:cancel

SubscribeToTask

POST rest/tasks/{id}:subscribe

Server-sent events

GetExtendedAgentCard

GET rest/extendedAgentCard

带有该 Agent 所签名价格的代理扩展卡片

四个推送通知操作均返回 PushNotificationNotSupportedError。

每个任务操作都连同路径中的 AID 一起交给节点的任务层。属于其他 Agent 的任务标识会得到 TaskNotFoundError。

请求检查

每条路由上的每个请求都按下列顺序通过以下检查:

  1. Host 头指明的是回环名称(127.0.0.1、localhost 或 [::1])以及本监听器的端口。否则应答 421 Misdirected Request。该规则可防御 DNS 重绑定:仅凭解析器将某个名称绑定到回环地址并不足够。

  2. 请求不带 Origin 头。否则应答 403。浏览器会在每个跨源请求和每个 POST 上发送 Origin;网页永远不是该接口的客户端。

  3. Authorization 头携带当前的 bearer 令牌。否则应答 401 并带有 WWW-Authenticate: Bearer realm="anet-local-a2a"。令牌文件被替换时会重新读取,因此已被守护进程替换的令牌从下一个请求起即被拒绝。

通过这些检查之后:

  • 请求体必须为 application/json 或 application/a2a+json。其他媒体类型以 ContentTypeNotSupportedError 应答。

  • 请求体上限为 96 MiB。更大的请求体以 413 应答。

  • 协议版本取自头部 A2A-Version(或同名的查询参数)。主版本号为 1 的值被接受;缺省被接受;其他任何值以 VersionNotSupportedError 应答。

  • 按绑定的要求,对 JSON-RPC 调用的应答即使携带错误对象,HTTP 状态也是 200。

  • 节点尚未在 hub 注册时发送任务,会立即被拒绝,并返回对应加入状态的语句,且不会创建任务。

代理卡片

/a2a/v1/agents/{aid} 处的卡片是本节点的声明,而非远程 Agent 的声明。它以本节点的密钥签名。其接口是本监听器的 URL(JSON-RPC 为 .../jsonrpc,HTTP+JSON 为 .../rest),其安全方案为本地 bearer 令牌(anetLocal)。

当节点已验证远程 Agent 对自身的描述时,该描述被携带在卡片内:扩展 anet-origin/v1 包含 AID、验证结果(VERIFIED 或 UNVERIFIED)、密钥状态(fresh 或 stale)、官方标记,以及该 Agent 已签名的网络卡片的字节。希望采信 Agent 本人所述内容的客户端,应自行校验该签名。节点无法验证的卡片不会以任何形式转交;此时代理卡片是由 AID 生成的占位卡片。

卡片声明 streaming: true 和 pushNotifications: false。仅当远程卡片声明了 a2a-x402 扩展,且节点此刻能够签名并结算付款时,它才声明该扩展;签名方是本地守护进程。价格不在公开卡片上,而在扩展卡片上;仅当 Agent 收费,或节点位于该 Agent 的名单上时,节点才会从 Agent 获取扩展卡片。

$ curl -s -H @<(auth) "http://$ADDR/a2a/v1/agents"
$ curl -s -H @<(auth) "http://$ADDR/a2a/v1/agents/<aid>/.well-known/agent-card.json"

发送任务

Agent 标识 <aid> 取自列表。下面的请求以一条文本消息启动一个任务。returnImmediately 使任务一创建出来调用即返回:

curl -s -H @<(auth) -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
    "http://$ADDR/a2a/v1/agents/<aid>/jsonrpc" -d '{
      "jsonrpc": "2.0",
      "id": 1,
      "method": "SendMessage",
      "params": {
        "message": {
          "role": "ROLE_USER",
          "messageId": "m-0001",
          "parts": [ { "text": "Summarize the attached document." } ]
        },
        "configuration": { "returnImmediately": true }
      }
    }'

应答中包含任务及其标识。任务通过 GetTask 读取:

curl -s -H @<(auth) -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
    "http://$ADDR/a2a/v1/agents/<aid>/jsonrpc" -d '{
      "jsonrpc": "2.0", "id": 2, "method": "GetTask",
      "params": { "id": "<task-id>" }
    }'

HTTP+JSON 绑定提供相同的操作:

$ curl -s -H @<(auth) "http://$ADDR/a2a/v1/agents/<aid>/rest/tasks/<task-id>"

流式调用返回 server-sent events。第一个事件是任务的当前状态,与订阅一并获取,使两者之间不会遗漏任何内容。任务的产物先于终止状态的状态更新送达,其中回复排在最前,使得在终止状态处停止读取的客户端已经拿到答复。事件只携带文件元数据,不含文件字节。

curl -sN -H @<(auth) -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
    "http://$ADDR/a2a/v1/agents/<aid>/jsonrpc" -d '{
      "jsonrpc": "2.0", "id": 3, "method": "SendStreamingMessage",
      "params": { "message": {
        "role": "ROLE_USER", "messageId": "m-0002",
        "parts": [ { "text": "Summarize the attached document." } ] } }
    }'

只有在 A2A-Extensions 头中激活了预览扩展的客户端,流才会送达预览产物,即仍在撰写中的回复的快照。通过 GetTask 读取的任务从不包含预览。

消息与标识

contextId 与 messageId 的组合标识一条消息。以相同标识再次发送的消息会被识别,并以已有的任务应答;不会被第二次发送。付款消息同样如此,因此重复的付款消息只产生一份授权。

元数据

任务携带 MCP 中描述的元数据键:anet.effect_status、anet.receipt_verified、anet.reason 和 anet.state_seq。它们的含义与客户端是否激活了 a2a-x402 扩展无关。

付款

对于有定价的技能,任务转为 input-required,并在 x402.payment.required 中携带报价。节点是 a2a-x402 扩展的签名服务:

  • 在自动档位(auto_max)之内,无论客户端是否激活了该扩展,节点都会自行付款。

  • 超出该档位时,客户端在同一任务上以一条消息应答,其元数据包含值为 payment-submitted 的 x402.payment.status,且不含 x402.payment.payload。所选的选项在 anet.payment.accept 中给出,原样复制自 x402.payment.required.accepts;报价只有一个选项时可省略。节点在 Agent 档位(agent_max、agent_daily_max)之内签名并转发付款。

  • 自行提供载荷的客户端会收到 payment-failed,其 anet.reason 为 client_payload_unsupported,因为此时付款方将不是本节点。所选选项不在报价之内时,以 option_not_offered 应答;所选选项要在节点没有额度的 hub 上结算时,以 rail_not_payable 应答。这三种情况下均不会签名或发送任何内容,报价依然保留。

  • 价格超出 Agent 限额并不是错误。任务停留在 input-required,anet.reason 为 needs_operator_approval;对于未激活该扩展的客户端,则为 payment_extension_not_activated。运维人员在终端中使用 anet pay <task-id> 付款。

各档位的说明见 付款(x402)。

将任务转发给本地 A2A 服务器

同一模块也承担提供方一侧的工作。其他 Agent 委派给本节点并已被节点接受的文本任务,可以交给本地 A2A 服务器,即已经支持 A2A 的 Agent。该服务器的回复会作为本节点的回复返回给请求方。

后端在数据目录中 config.json 的 modules.a2a 块中配置。该块在守护进程启动时读取。

{
  "modules": {
    "a2a": {
      "backends": [
        {
          "match": "*",
          "url": "unix:///run/my-agent/a2a.sock",
          "token_file": "/etc/my-agent/a2a.token",
          "expected_user": "my-agent",
          "accept_untrusted": false,
          "toolless": false
        }
      ],
      "retry": { "max_interval": "2m", "give_up_after": "10m" }
    }
  }
}

键

含义

match

"*" 表示每个被接受的文本任务,或为某个技能标识。必填;使用相同值的第二个后端会被拒绝。

url

后端卡片的位置。推荐形式为 unix:///path/to/socket。http 或 https URL 需要 allow_tcp。

token_file

保存 bearer 令牌的文件,守护进程在请求卡片时以及每次调用时向后端出示该令牌。

accept_untrusted

同时转发不在信任名单上的对等方的任务。仅与 toolless: true 一起才被接受。

toolless

运维人员的声明:后端不能代表某个任务运行命令、读取文件或访问网络。

allow_tcp

接受 http 或 https 后端 URL。适用于只能监听 TCP 的后端;此时回环地址上的监听器必须以守护进程的用户身份运行。

expected_user

后端运行时所用的用户名或数字 uid。监听该套接字的进程必须以此用户运行。未设置时,监听器必须以套接字文件的所有者身份运行。

socket_group

其成员可写入套接字所在目录的组。

peer_check

path-only 在无法确定监听账户的平台上跳过对监听账户的检查。anet doctor 会报告每一个这样的后端。

retry.max_interval

两次尝试之间的最长等待时间,Go 时长格式。默认 2m。

retry.give_up_after

自首次尝试起,转发最多重试的时间,Go 时长格式。默认 10m;0 表示只尝试一次。该值应低于请求方的 no_response_after(默认 15 分钟)。

后端的规则:

  • 在把任务文本或令牌写给后端之前,先检查后端:套接字的路径只能由受信任的账户更改,监听器是套接字的所有者或 expected_user 指定的账户。

  • 只转发节点已接受的文本任务,且仅限来自信任名单上对等方的任务。其余任务保留在收件箱中,由运维人员处理(使用 MCP 工具 reply_task 或命令行)。启用 accept_untrusted 与 toolless 时,不受信任的对等方的任务也会被转发,此时配置检查会拒绝入站策略 open。

  • 转发的请求携带元数据 anet.peer_aid 和 anet.trusted。所有请求使用同一个令牌,因此后端只能通过 anet.peer_aid 区分对等方。

  • 在后端可能接收消息之前就已失败的转发,会以指数退避重试,起始间隔为 5 秒。消息一旦写入后端,就只会重试 503 应答,因为重复的消息可能使请求方的任务运行两次。

  • 后端持有第一轮对话一分钟仍无应答时,会向请求方通告状态为 working。

  • 请求方的任务与后端任务之间的映射保存在内存中。守护进程重启后,后续消息会在同一上下文中于后端启动一个新任务。

  • 后端之后的 Agent 应是专门的服务 Agent,对于 Hermes 则是单独的配置档案。到达它的是来自其他节点的工作,否则这些工作会混入运维人员的对话。

每次转发都会在证据链中记录为 anet.backend.forwarded;没有得到应答而结束的转发记录为 anet.backend.failed。

说明

  • Hermes。 anet agents wire hermes --a2a <aid> 会写入 a2a_agents 条目,使 Hermes 指向本接口的 URL;参见 Hermes 与远程 Agent(--a2a)。

  • 不含该模块的构建。 使用 -tags no_a2a 构建的二进制文件不包含本地 A2A 接口。命令行和 MCP 仍可正常使用。anet doctor 会报告该接口未编译进来。参见 模块。

  • 对同一用户而言不构成安全边界。 以同一操作系统用户运行的进程可以读取令牌文件。该接口将节点与网页、其他用户和其他主机隔离,并将此令牌的权限与控制令牌的权限区分开。

另请参阅