本页是原文。其他语言的版本由此翻译而来。

Confer — API 规范

定义客户端 ↔ 服务器、服务器 ↔ A2A peer 的所有 API。

通用约定

{
  "error": {
    "code": "invalid_request",
    "message": "Human-readable message",
    "details": { /* optional */ }
  }
}

认证

客户端 API(用户客户端使用)

认证

POST   /api/v1/auth/register
POST   /api/v1/auth/login
POST   /api/v1/auth/refresh
POST   /api/v1/auth/logout
POST   /api/v1/auth/password        # {current_password, new_password}
GET    /api/v1/auth/2fa             # {enabled, recovery_codes_left}
POST   /api/v1/auth/2fa/setup       # → {secret, otpauth_uri}
POST   /api/v1/auth/2fa/enable      # {code} → {recovery_codes}
POST   /api/v1/auth/2fa/disable     # {password, totp_code | recovery_code}
POST   /api/v1/auth/oauth/{provider}    # OAuth callback

POST /api/v1/auth/login 请求:

{
  "username": "laowang",
  "password": "...",
  "device_id": "ios-abc123",
  "device_info": { "platform": "ios", "model": "iPhone 15", "os": "17.1" }
}

响应:

{
  "access_token": "eyJ...",
  "refresh_token": "...",
  "expires_in": 900,
  "user": { /* User object */ }
}

两步登录(TOTP)。 开启后,login 在密码正确之后还要求 totp_code(身份验证器 App 的 6 位码)或 recovery_code;两者都没带时返回 401 totp_required,客户端据此显示第二步,码不对返回 401 invalid_second_factor。密码错误时永远先返回 invalid_credentials,不会透露账号是否开了两步登录。每个验证码只能用一次(记录已用的时间步),每个恢复码用后作废;两者都用一条带条件的 UPDATE 消费,并发登录不能重复使用。setup 生成的密钥用 AES-256-GCM 加密保存,在 enable 用 App 里的码确认之前不生效;enable 只返回这一次恢复码(10 个),服务端只存哈希。disable 需要密码加第二因素。password 修改密码需要当前密码,并注销该用户的所有其他会话。这些操作都写审计日志,且按账号每分钟限 10 次。 enable 同样需要 password,这样仅凭盗来的 token 不能把攻击者的身份验证器装进别人的账号;开启两步登录后,password 修改密码也需要第二因素。同一账号连续 5 个错误的码会锁定该账号的验证码尝试 15 分钟(429 too_many_attempts),与按 IP 的登录限流叠加。恢复码用服务端密钥做 HMAC 保存。用户同时丢失身份验证器和恢复码时,管理员可用 POST /api/v1/admin/users/{id}/reset-2fa 为其关闭两步登录(会写审计日志,不能对自己使用)。

GET    /api/v1/tokens              # [{id, name, prefix, created_at, last_used_at, expires_at}]
POST   /api/v1/tokens              # {name, expires_in_days?} → {token: {..., token}}
DELETE /api/v1/tokens/{id}

个人 API 令牌。 给以主人身份调用 API 的脚本和工具使用:Authorization: Bearer cfr_…,与会话的 access token 用同一个请求头。令牌明文只在创建时返回一次,服务端只存 SHA-256 和用于辨认的前缀;可选 1–365 天有效期,每个用户最多 20 个;令牌所属账号被停用后立即失效。令牌能做主人在聊天里能做的事,但下列操作只接受登录会话,令牌调用会得到 403 session_required:登出、改密码和两步登录、管理令牌本身、审批权限请求和 errand 卡片、修改 LLM 密钥、所有 admin 接口。泄露的令牌可以替主人跑腿,但不能替主人做决定或把主人锁在外面。管理令牌本身也只接受会话。创建和吊销都写审计日志。

用户和 Agent 配置

GET    /api/v1/users/me
PATCH  /api/v1/users/me
GET    /api/v1/users/me/keys          # signing keys: current / retiring / revoked
POST   /api/v1/users/me/keys/rotate   # session only
POST   /api/v1/users/me/keys/revoke   # {key_id}, session only
GET    /api/v1/agents/me
PATCH  /api/v1/agents/me
PUT    /api/v1/agents/me/policies
GET    /api/v1/agents/me/llm-keys      # 每个 provider 是否已配置(只返回布尔,不返回密钥)
PUT    /api/v1/agents/me/llm-keys      # 加密存储 LLM API keys
DELETE /api/v1/agents/me/llm-keys/{provider}
GET    /api/v1/agents/me/llm-keys/{provider}/models   # 向厂商实时查询可用模型

provider 取值来自 @confer/shared 的 provider 目录(packages/shared/src/llm/catalog.ts), 外加工具服务 tavily。目录同时被 gateway、agent-runtime 和客户端读取——base URL、模型列表 路径、默认模型都只写在那一处,新增厂商只改目录。

/models 直接转发厂商自己的模型清单,永远不返回本地维护的名单:

{ "models": [{ "id": "gpt-4o" }] }
// 空列表必定带上原因,四种互不相同,各自对应不同的补救动作
{ "models": [], "error": "no_key" }        // 该 provider 还没配置密钥
{ "models": [], "error": "unauthorized" }  // 厂商拒绝了这个密钥(401/403)
{ "models": [], "error": "unreachable" }   // 连不上厂商,或它返回了其他错误
{ "models": [], "error": "unsupported" }   // 该厂商不提供模型清单接口

联系人 / Peer Agents

GET    /api/v1/contacts                     # 列出联系人。分页:?limit=&offset=
POST   /api/v1/contacts                     # 添加联系人
GET    /api/v1/contacts/{contact_id}        # 单个联系人详情(带 peer)
DELETE /api/v1/contacts/{contact_id}
PATCH  /api/v1/contacts/{contact_id}        # 局部修改 alias / tags / pinned / muted(未传字段不清空)

POST   /api/v1/contacts/lookup              # 按 DID / 域名 / username 查找

POST /api/v1/contacts/lookup 请求:

{
  "method": "domain",          // domain | did | username | qr_code | phone
  "value": "abc-industries.com"
}

GET /api/v1/contacts 返回 { contacts, total }。limit 默认 50、上限 100,offset 默认 0;按 id(ULID)倒序,即最新在前——排序是唯一且确定的,offset 窗口才不会漏行或重行。total 是全量计数而非本页条数,客户端据此判断”已到底”。无法解析的 limit/offset 取默认值而非报错。

响应:返回找到的候选 Agent 列表。lookup 会把发现到的 peer 落库到 peer_agents 并在每个候选里带上本地 id(peer_id)——POST /api/v1/contacts 正是用这个 id 添加联系人。POST /contacts 幂等:重复添加同一 peer 返回已存在的联系人(200)而非报错。

添加联系人是接收方授予对方”可消费我的 Agent”的同意:被加为联系人的 peer 才能触发我的 Agent 回答(消耗我的 LLM 预算)。未连接 peer 发来的 A2A 消息会被挂起为待批连接请求,见 03-protocol.md 的「连接同意闸门」。

POST   /api/v1/contacts/{contact_id}/policies   # 设置 standing policies(整体替换,PUT 语义)

POST /contacts/{id}/policies 的 body 是 runtime 形 { default?: 'allow'|'ask_user'|'deny', rules?: [{ action, peer_did?, decision }] },整体写入 peer_contacts.policy_overrides_json。Merge 语义:入站 A2A 决策时,该 per-contact 覆盖叠加在 agent 级 policy 之上——contact.default 在场则覆盖 agent 默认,contact.rules 前置于 agent rules 故先命中(per-contact 精确规则优先于 agent 通用规则);空覆盖 {} 为恒等,与无覆盖时的决策逐字节一致。

对话

GET    /api/v1/conversations                       # 列出我的对话(首页用)
POST   /api/v1/conversations                       # 创建新对话
GET    /api/v1/conversations/{id}
PATCH  /api/v1/conversations/{id}
DELETE /api/v1/conversations/{id}

GET    /api/v1/conversations/{id}/messages         # 分页:?before=&limit=
POST   /api/v1/conversations/{id}/messages         # 发消息
GET    /api/v1/conversations/{id}/messages/{msg_id}/stream    # SSE 流式接收 LLM 回复

POST   /api/v1/conversations/{id}/participants     # 加入 participant
DELETE /api/v1/conversations/{id}/participants/{p_id}

POST   /api/v1/conversations/{id}/read             # 标记已读

GET 默认只列未归档的对话,?archived=1 列已归档的;每条带 unread_count:自上次标记已读(从未标记则从加入时起)以来别人写的消息数,自己发的不算。PATCH 接受 { name?, archived? },只有对话创建者能改。POST .../read 把当前用户的 last_read_at 设为现在。联系人在已归档的对话里写新消息时,对话会自动取消归档并排到最前。

GET /api/v1/search/messages?q=&limit= 在当前用户参与的所有对话(含已归档)里按子串搜索消息正文,最新的在前;q 为 1–200 个字符,limit 默认 20、最大 50。返回 { results: [{ message_id, conversation_id, conversation_name, sender_type, content, created_at }] }。用子串匹配而不是 Postgres 全文检索,因为内置分词不切中文。

GET /api/v1/conversations/{id}/export 返回整段对话(最早的在前,最多 5000 条),只给参与者:{ conversation, messages, truncated },每条消息带 sender_type、sender_did、对方 agent 的 sender_name、content、content_json 和 citations_json。只返回事实,Markdown 由客户端按读者的语言生成(谁是”我”、谁是”我的 Agent”)。

POST /api/v1/conversations/{id}/messages 请求:

{
  "content_type": "text",
  "content": "X100 寄存器 0x40 用什么功能码?",
  "in_reply_to": null,
  "via": "web"
}

响应:

{
  "id": "01HXKQ...",
  "delivery_status": "queued",
  "stream_url": "/api/v1/conversations/01HX.../messages/01HXK.../stream"
}

权限管理

GET    /api/v1/permissions/pending               # 待处理的 L2/L3 请求
POST   /api/v1/permissions/{id}/decide           # 批准/拒绝
GET    /api/v1/permissions/history               # 历史记录

POST /api/v1/permissions/{id}/decide 请求:

{
  "decision": "allow_always",       // allow_once | allow_always | deny | deny_always
  "scope": "peer_action"            // 限定范围
}

待批请求里 action='connect' 的是连接请求(陌生 peer 首次接触时由 A2A 入站生成)。批准(allow_*)会把该 peer 写入 peer_contacts,建立连接;拒绝则不建立。

action='ask' 的是已连接 peer 的待批提问——当主人的 Agent 策略对该提问判为 ask_user 时由 A2A 入站生成(见 03-protocol.md 的「Pending inbox(离线代答)」)。批准(allow_*)触发 Agent 代答这条挂起的提问;拒绝则不回答。

allow_always / deny_always 是长期决定。对挂起的提问,它们会在该联系人的策略覆盖里写入(或替换)一条 { action: 'ask', decision: 'allow' | 'deny' } 规则,排在最前面,所以这个 peer 之后的提问直接代答或直接拒绝,不再挂起;主人可以在联系人详情的策略里看到并撤销。对连接请求,deny_always 会让这个 peer 之后的连接请求被静默丢弃,不再进收件箱;主人手动按 DID 添加它仍然有效。

GET /pending 为每条请求附带 description(连接请求含发起方与首条留言;提问含发起方与问题正文)便于主人判断。

项目记忆(Claude Code 集成相关)

GET    /api/v1/projects/{project_id}/peers              # 该项目下有记忆的 peer(join 出 name/did)  ✅ 已实现
POST   /api/v1/projects/{project_id}/peers              # 显式注册 peer 到项目                       🔜 backlog
GET    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ 已实现
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ 已实现
GET    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ 已实现
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ 已实现

语义说明(v0.1):

知识库(RAG)

GET    /api/v1/knowledge-bases                                  # 列出我的知识库
POST   /api/v1/knowledge-bases                                  # 新建
PATCH  /api/v1/knowledge-bases/{kb_id}                          # 改名/描述,以及是否对外部 Agent 开放
DELETE /api/v1/knowledge-bases/{kb_id}                          # 连同其全部文档与向量一并删除

GET    /api/v1/knowledge-bases/{kb_id}/documents                # 分页:?limit=&offset=
POST   /api/v1/knowledge-bases/{kb_id}/documents                # multipart upload,字段名 file
DELETE /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}
POST   /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}/retry # 重新入库

POST /knowledge-bases 的 body 是 { name, description? }(name 1–255 字符),返回 201 + { knowledge_base }。

PATCH /knowledge-bases/{kb_id} 的 body 是 { name?, description?, shared_with_peers? },返回 { knowledge_base }。shared_with_peers 只能在这里改,建库时不接受:知识库一律以「仅自己」诞生,对外开放是第二个有意为之的动作。

shared_with_peers 决定的是入站 A2A 提问能不能搜到这个库,默认 false。owner 自己在网页里对话时不受它影响,永远搜得到全部。这个边界必须落在检索的作用域上、而不是提示词里:对端的问题和 owner 的指令一样只是模型眼里的文本,「让 Agent 自己判断该不该说」根本不构成边界。同理,入站 A2A 提问不召回任何长期记忆——长期记忆是从 owner 自己的对话里蒸馏出来的,没有任何一条被标记为可以离开本实例。

GET /knowledge-bases 返回 { knowledge_bases },不分页:一个用户的知识库是手工建的,数量有界。

GET /{kb_id}/documents 返回 { documents, total }。limit 默认 50、上限 100,offset 默认 0;按 id(ULID)倒序,即最新在前——排序唯一且确定,offset 窗口才不会漏行或重行。total 是全量计数而非本页条数。无法解析的 limit/offset 取默认值而非报错。这是本节唯一会无界增长的列表,因为知识库正是上传目标。

上传走 multipart/form-data,文件字段名固定为 file,单个文件上限 10 MB(超出返回 400 bad_request)。Content-Type 优先取表单里带的,缺失时按扩展名推断。响应 201 + { document },此时 status 已是 processing:切分、向量化、写入 Qdrant 是响应之后异步跑的,上传接口不等它完成。客户端据此轮询文档列表直到 status 变化。

status 取值:

值 含义
processing 已入库、正在切分/向量化。上传与 retry 后的初始态
ready 可被检索。chunk_count 为该文档的分片数
failed 入库失败(解析、embedding key 缺失或向量库写入失败)

POST /{doc_id}/retry 从对象存储取回原文件重新入库,先清掉该文档已有的向量再重跑,因此不会产生重复分片。原文件已不在(storage_key 为空)或文档仍在 processing 时返回 400。响应 { document },status 复位为 processing、chunk_count 归零。

删除知识库会级联删除其全部文档行与 Qdrant 中的向量;删除单个文档同时清理向量与对象存储中的原文件。向量/对象存储的清理失败不会阻断数据库删除——留下孤儿对象好过留下指向已删数据的行。

所有端点都 scope 到 user.sub:访问他人的 kb 或文档返回 404(而非 403,不泄露其存在性)。

反向代理需放行 10 MB 请求体。infra/nginx.conf 在 /api/ 上设了 client_max_body_size 10m;用 nginx 默认的 1 MB 时,1–10 MB 的文件根本到不了 gateway,浏览器拿到的是 nginx 自己的 413 页面。

文件附件

POST   /api/v1/attachments                       # multipart upload
GET    /api/v1/attachments/{id}/content          # 文件本身:上传者或该消息所在对话的参与者

上传后附件处于未绑定状态,发送消息时在 POST /conversations/{id}/messages 里用 attachment_ids(最多 5 个)认领,只能认领自己上传且还没被发送过的;只有附件没有正文的消息也合法。图片按文件内容识别(PNG / JPEG / GIF / WebP,每张最多 5 MB),以多模态图片块交给模型;PDF / DOCX / XLSX / 文本在上传时读出文字(最多 10 MB),每轮按 <attachment> 附在该消息后面。SVG 只当文本处理,绝不作为图片内联。content 只对识别出的图片内联返回,其它一律以 application/octet-stream 下载。消息列表里每条消息带 attachments。删除对话会一并删除其附件。

用量

GET /api/v1/usage?month=YYYY-MM    # 当月 Agent 回合的 token 用量,缺省为当前月

返回 { month, rows },每行是一个「provider × model × audience」分组:turns、unreported(厂商没有报告用量的回合数)、failed、input_tokens、output_tokens、cache_read_tokens、cache_write_tokens。月份按 UTC 切分;month 不是 YYYY-MM 返回 400。只返回调用者自己的数据。

WebSocket

端点

WSS  /ws?token=<access_token>&device_id=<device_id>

握手的鉴权与 REST 完全一致,不是”验个签就放行”:typ 必须是 access、sid 必须指向一条仍然存在的 session、账号状态不能是 disabled。三条缺一不可——缺了 它们,被封禁的账号只要 token 没过期就能一直重连收消息,而封禁本身(删光 session) 在这条路径上什么都没撤销。封禁同时会关掉该用户已经打开的 socket:nginx 给 /ws 的 proxy_read_timeout 是一天,只拦下一次握手拦不住已连上的那条。

握手时验过的 token 也不会一直替这条 socket 担保下去:access token 到期时服务端以 4001 关闭连接,客户端先刷新 token 再重连。登出、或 refresh token 复用检测删除某个 session 时,该 session 下已经打开的 socket 会以 1008 关闭。

消息格式

所有 WS 消息都是 JSON,含 type 字段:

{ "type": "message.new", "data": { /* ... */ } }

客户端 → 服务器

ping                          // 心跳
subscribe.conversation        // 订阅某个对话(服务端校验参与者身份)
unsubscribe.conversation
typing.start                  // 仅对已订阅的会话生效
typing.stop
read.ack                      // 已读确认

typing.* 的广播以该 socket 的订阅集为准。订阅有闸门而打字事件没有的时候,只要 知道一个会话 id 就能往任意会话里注入”某某正在输入”,还带着自己的用户名。

服务器 → 客户端

pong
message.new                   // 新消息
message.updated               // 预留,尚未发送
message.deleted               // 预留,尚未发送
typing.update                 // 谁在打字
presence.update               // 联系人上下线
permission.request            // 需要用户决定的权限请求
agent.status                  // 我的 Agent 正在回答联系人:{conversation_id, state: thinking|tool|idle, tool?},文案由客户端生成
conversation.updated          // 联系人在 {conversation_id} 里发了消息(可能是新会话),客户端据此刷新列表

message.new 示例:

{
  "type": "message.new",
  "data": {
    "id": "01HXKQ...",
    "conversation_id": "01HX...",
    "sender_type": "peer_agent",
    "sender_id": "01HY...",
    "sender_did": "did:web:acme.com:agents:support",
    "content_type": "text",
    "content": "用 0x03 Read Holding Registers...",
    "citations": [
      {
        "source": "X100 通信手册 v3.2",
        "page": 87,
        "url": "https://acme.com/manuals/x100-v3.2.pdf#page=87",
        "trust_level": "authoritative"
      }
    ],
    "language": "zh",
    "created_at": "2024-11-15T14:30:00Z"
  }
}

permission.request 示例:

{
  "type": "permission.request",
  "data": {
    "id": "01HXP...",
    "level": "L2",
    "action": "share_files",
    "scope": {
      "peer": "did:web:acme.com:agents:support",
      "paths": ["src/modbus/"],
      "exclude": [".env", "secrets/"]
    },
    "peer_name": "ABC Agent",
    "peer_did": "did:web:acme.com:agents:support",
    "requested_at": "2024-11-15T14:30:00Z"
  }
}

载荷里没有 description,这是有意的。 服务端不知道读者用什么语言,所以只发结构化事实 (action + peer 身份 + 已存储的 scope),供审批阅读的句子由客户端按 i18n 拼装 (packages/client/src/lib/permission-text.ts)。这条契约由 @confer/shared 的 permissionRequestEventSchema 单独拥有:gateway 出站前用它 parse,客户端入站后用它 parse。

GET /api/v1/permissions/pending 的每一行是同一个形状(额外带一个 decision 字段), 由同一个构造器生成,所以轮询到的行和 socket 推来的行逐字节一致。

SSE(LLM streaming)

GET  /api/v1/conversations/{id}/messages/{msg_id}/stream
Accept: text/event-stream

事件类型:

event: token
data: {"text":"用 "}

event: token
data: {"text":"0x03 "}

event: tool_call
data: {"tool":"agent_network.ask_peer","args":{...}}

event: tool_result
data: {"result":"..."}

event: citation
data: {"source":"X100 通信手册 v3.2","page":87}

event: done
data: {"finish_reason":"stop","tokens_used":523}

A2A API(对外,供其他 Confer 实例调用)

详见 docs/03-protocol.md。这里只列 endpoint。

两套绑定并存于同一前缀,共用同一套闸门(a2a/inbound.ts),只是线格式不同。

A2A 标准 HTTP+JSON 绑定(spec §11.3 的路径原样照抄,Agent Card 宣称的就是这一套):

POST   /a2a/v1/message:send              # SendMessage → Task
GET    /a2a/v1/tasks/{id}                # GetTask
GET    /a2a/v1/tasks                     # ListTasks(游标分页)
POST   /a2a/v1/tasks/{id}:cancel         # CancelTask → TaskNotCancelable
POST   /a2a/v1/message:stream            # SendStreamingMessage → SSE: Task, then statusUpdate
GET|POST /a2a/v1/tasks/{id}:subscribe    # SubscribeToTask → SSE
POST   /a2a/v1/tasks/{id}/pushNotificationConfigs            # → TaskPushNotificationConfig
GET    /a2a/v1/tasks/{id}/pushNotificationConfigs            # → {configs, nextPageToken}
GET    /a2a/v1/tasks/{id}/pushNotificationConfigs/{configId}
DELETE /a2a/v1/tasks/{id}/pushNotificationConfigs/{configId} # idempotent
GET    /a2a/v1/extendedAgentCard         # 未实现 → UnsupportedOperation

流式与推送。 message:stream 与 message:send 走同一套校验和闸门,回答为 text/event-stream,每个事件是一行 data:,内容为一个 StreamResponse:先是 {task},任务结束(或进入需要人批准的 AUTH_REQUIRED)时再发一个 {statusUpdate},然后关闭;回答在这条路径上是一次完整的 LLM 调用,没有逐字输出,所以是任务事件粒度。:subscribe 同时接受 GET 和 POST(proto 写 GET,spec 表格写 POST),对已结束的任务返回 UnsupportedOperation。推送:对方在任务上登记 webhook(也可在 message:send 的 configuration.taskPushNotificationConfig 里带上),任务状态每变一次就 POST 一次 {task},同一状态只发一次;带 Authorization: {scheme} {credentials},token 放在 X-A2A-Notification-Token。webhook 必须是 https 且解析到公网地址(与出站 A2A 同一条 SSRF 规则,投递时再查一遍,不跟随重定向);凭据密封保存,读取时只回显 scheme。每个任务最多 5 个,只有登记它的那个 peer 能看到或删除。

Confer 自己的方言(实例之间用,经 /.well-known/agents.json 发现):

POST   /a2a/v1/messages                  # 接收外部 Agent 消息
GET    /a2a/v1/stream/{message_id}       # 流式拉回答(SSE)
GET    /a2a/v1/agent-facts/{agent_did}   # 公开 AgentFacts

所有 A2A 端点都要 HTTP Message Signature 验证,只有 agent-facts 例外:它和 /.well-known/agents.json 一样是公开的发现文档,可见性也一致——非公开或已停用的 Agent 一律 404。它的 capabilities 只列出主人保存过、且符合 NANDA 形态({type, scope, languages})的能力。

.well-known endpoints

GET    /.well-known/did.json                # 主 DID document
GET    /.well-known/agents.json             # 本实例所有公开 Agent 列表
GET    /.well-known/agent-card.json         # A2A 标准 Agent Card(仅当实例只有一个公开 Agent)
GET    /.well-known/openid-configuration    # 未来:OIDC 兼容(v2)

A2A 标准 Agent Card(互操作发现层)

GET    /agents/{username}/agent-card.json   # 该 Agent 的 A2A 标准 Card
GET    /.well-known/agent-card.json         # 同上,仅当本实例只有一个公开 Agent

按 Linux Foundation Agent2Agent v1.0 的 AgentCard(字段取自 a2aproject/A2A 的 specification/a2a.proto @ v1.0.1,走 proto3 JSON 映射,故为 camelCase)。目的是让 A2A 生态发现本实例的 Agent —— 名字撞了但协议不通:对方的发现文档在 /.well-known/agent-card.json,本实例原本只有 /.well-known/agents.json。

几个刻意的取舍:

消息层(Task 语义)

POST /a2a/v1/message:send 收 spec 的 SendMessageRequest,返回 Task。一个 task 就是一次入站提问:它的 id 是那条消息的 id,contextId 是归档它的会话,状态由后续发生的事推出来——不另立一张 tasks 表去影子同一个事实。

Confer 的异步 + 同意闸门模型正好落在 spec 的状态机上:

情况 状态
Agent 正在作答 TASK_STATE_WORKING
答完 TASK_STATE_COMPLETED
这一轮跑不起来(没配模型 / provider 报错) TASK_STATE_FAILED
ask_user 策略挂起,等主人批 TASK_STATE_AUTH_REQUIRED(中断态,非终态)
主人拒了 TASK_STATE_REJECTED

两处没有 task 可返回,因为压根没建行:陌生 peer(挂起为待批连接请求)和策略直接拒绝。两者都回 403 PERMISSION_DENIED,用 ErrorInfo.metadata.confer_status 区分——凭空造一个下一次调用就 404 的 task id 更糟。

其余按 spec 逐条对齐的行为:错误体是 google.rpc.Status 形状并必带 ErrorInfo.reason(多个 A2A 错误共用同一个 HTTP 状态码,reason 是唯一能分辨的字段);未声明必需扩展的客户端按 §3.3.4 回 ExtensionSupportRequiredError,而不是一个不解释任何事的 401;historyLength=0 是整个字段省略而不是空数组;nextPageToken 恒在,没有下一页时为空串。

两处刻意的偏离,都写在代码注释里:阻塞式 message:send 的等待有上限(55s,之后返回仍为 WORKING 的 task 让客户端轮询)——spec §3.2.2 没给超时出口,而一次 LLM 调用没有上界;messageId 幂等(§3.3.1 的 MAY)没做,因为租户安全的唯一键需要 owner 作用域,而首条消息的线格式里拿不到。

Webhooks(可选,v1.5+)

让外部系统订阅事件:

POST   /api/v1/webhooks
GET    /api/v1/webhooks
DELETE /api/v1/webhooks/{id}

支持的事件:message.new.peer、permission.granted、thread.archived。

限流策略

路由 限制
/api/v1/auth/login 10/分钟 per IP
/api/v1/auth/register 3/小时 per IP
/api/v1/conversations/*/messages POST 60/分钟 per user
/a2a/v1/* 60/分钟 per IP + 路径;整个 /a2a/v1 合计 300/分钟 per IP
A2A 提问(两套绑定共用) 每个 peer DID 对每个主人 20/分钟、300/天;同一域名合计 60/分钟。超出 → 429 / RESOURCE_EXHAUSTED,带 Retry-After
/api/v1/consult/{peer} POST 30/分钟 per user
WSS 单用户最多 10 个并发连接

限流响应:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{ "error": { "code": "rate_limited", "message": "Too many requests" } }

咨询 API(用户发起的 A2A 出站)

让用户(或代表用户的 MCP server)主动向已是联系人的 peer agent 发问并取回异步回复。签名与投递全在 gateway 内完成,私钥不出 gateway。

与”会话 API”的区别:/api/v1/conversations + /api/v1/stream 是与自己的本地 LLM 助手对话;/api/v1/consult 才是经 A2A 发给别人的 agent。

POST /api/v1/consult/:peerId

发起或续聊一个 type='consult' 会话(每个 peer 复用同一会话),签名并投递 message.type='question'。

// 请求体(consultRequestSchema)
{ "question": "如何轮换密钥?", "code_context": "...可选代码...", "language": "zh" }
响应 含义
201 { conversation_id, message_id, status: "sent" } 已签名投递
502 { ..., status: "failed", error } 投递失败(peer 离线 / 无 endpoint / 验签问题)
403 not_a_contact peer 不是当前用户的联系人

GET /api/v1/consult/:conversationId/reply?after=:messageId&wait=:seconds

长轮询等待 peer 的异步回复(peer 经入站 /a2a/v1/messages 携 thread_id 返回,gateway 按 thread_id 挂回本线程)。wait 上限 55s。

GET /api/v1/consult/:conversationId

返回该咨询线程的完整消息历史(最多 200 条)。

契约:入站 A2A 仅对 message.type==='question' 触发本地 agent 自动回复;answer/notification 只落库 + 广播,避免咨询回复触发无限对答。

← 返回 Confer A2A · DID:web · RFC 9421 · NANDA