Переведено с китайского оригинала. При расхождении приоритет у оригинала.
Confer — устройство протокола
Определяет все протоколы между инсталляциями Confer и между клиентом пользователя и сервером. Все они опираются на открытые стандарты, чтобы будущая федерация далась легко.
Идентичность Агента
Формат DID:web
Каждая инсталляция — личная или корпоративная — размещает собственный DID-документ:
https://acme.com/.well-known/did.json
Структура DID-документа (совместима с W3C DID v1.0):
{
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "did:web:acme.com",
"verificationMethod": [
{
"id": "did:web:acme.com#key-1",
"type": "Ed25519VerificationKey2020",
"controller": "did:web:acme.com",
"publicKeyMultibase": "z6MkpTHR8VNsBxYAAWHut2Geadd9jSrue..."
}
],
"service": [
{
"id": "did:web:acme.com#confer-agent",
"type": "ConferAgent",
"serviceEndpoint": "https://acme.com/a2a/v1"
}
]
}
DID Агента пользователя выглядит так: did:web:acme.com:agents:laowang — основная инсталляция плюс сегмент пути. Благодаря этому одна инсталляция может нести нескольких пользователей.
По спецификации did:web DID с подидентификатором (то есть с сегментами пути) разрешается в документ, соответствующий этому пути, а не в .well-known корня инсталляции:
did:web:acme.com:agents:laowang→https://acme.com/agents/laowang/did.json(двоеточие → косая черта, в конце/did.json)- Голый DID инсталляции
did:web:acme.com→https://acme.com/.well-known/did.json - Настоящий порт кодируется как
%3A:did:web:acme.com%3A3000:agents:laowang→https://acme.com:3000/agents/laowang/did.json(голое двоеточие вроде:8080— это сегмент пути, а не порт)
Ротация ключей
- DID-документ допускает несколько методов проверки, что позволяет менять ключи без разрыва.
- Старый ключ хранится не меньше 30 дней, чтобы не рушить запросы в полёте.
- Отзыв делается удалением метода проверки из документа.
- Реализация:
POST /api/v1/users/me/keys/rotateсоздаёт#key-<n>и сразу подписывает им; старый ключ помечается как выводимый и ещё 30 дней остаётся в DID-документе только для проверки.POST /api/v1/users/me/keys/revoke {key_id}немедленно убирает выводимый ключ (используемый ключ напрямую отозвать нельзя — сначала смените его). Оба принимают только сеанс и записываются в журнал аудита. - Сторона проверки: если keyId подписи отсутствует в нашей кешированной копии DID-документа собеседника, документ запрашивается заново (не чаще раза в минуту на DID), поэтому первое сообщение собеседника после смены ключа не отклоняется из-за нашего кеша.
AgentFacts (совместимо с NANDA)
Каждый Агент публикует AgentFacts с описанием себя. Расположение:
https://acme.com/agents/{slug}/agent.json
Или общий каталог в well-known:
https://acme.com/.well-known/agents.json
Пример структуры:
{
"@context": "https://nanda.dev/schemas/agent/v1",
"did": "did:web:acme.com:agents:support",
"name": "ABC Industries Support Agent",
"description": "Technical support for X100, X200 industrial controllers",
"owner": {
"type": "Organization",
"name": "ABC Industries Ltd.",
"url": "https://acme.com"
},
"capabilities": [
{
"type": "qa",
"scope": ["X100", "X200", "Modbus", "RTU", "TCP"],
"languages": ["en", "zh", "de"]
},
{
"type": "code-generation",
"scope": ["python", "c", "embedded"],
"languages": ["en", "zh"]
}
],
"endpoints": {
"a2a": "https://acme.com/a2a/v1",
"stream": "https://acme.com/a2a/v1/stream"
},
"trust": {
"verifiedBy": ["did:web:nanda.org"],
"issuedAt": "2024-10-01T00:00:00Z"
},
"publicKey": {
"id": "did:web:acme.com#key-1",
"type": "Ed25519VerificationKey2020"
}
}
Поля:
capabilities— что этот Агент умеет. Claude Code используетscopeдля маршрутизации по ключевым словам (пишешь код, связанный с X100, — он сам обращается к этому Агенту).languages— поддерживаемые языки. Нужны для стратегии перевода.trust.verifiedBy— поручительство третьей стороны (необязательное; NANDA даст его позже).publicKey— открытый ключ подписи для A2A-обмена.
Протокол A2A
Слой протокола
Весь A2A-обмен идёт по HTTPS POST/GET в кодировке JSON. Под /a2a/v1 сосуществуют две привязки:
- Привязка HTTP+JSON из A2A v1.0 Linux Foundation (
routes/a2a-rest.ts) — пути ровно те, что в §11.3 спецификации:POST /message:send,GET /tasks/{id}и так далее. Именно её объявляет Agent Card и именно её вызывают стандартные клиенты. См.docs/05-api.md. - Собственный диалект Confer (
routes/a2a.ts) — для обмена между инсталляциями, обнаруживается через/.well-known/agents.json.
Обе используют одни и те же заслоны — проверку подписи, заслон согласия, решение политики, принадлежность треда, — и все они лежат в a2a/inbound.ts; различается только формат на проводе. Написать эти заслоны по разу на привязку — ровно та причина, по которой баг с межарендной подстановкой треда был написан четыре раза; больше их не разделяйте.
Главное: используются HTTP Message Signatures (RFC 9421), а не bearer-токен. Почему:
- Bearer-токен скомпрометирован в тот же миг, когда его перехватили.
- HTTP-подпись привязана к конкретному запросу (метод + путь + строка запроса + дайджест тела + отметка времени).
- Защита от повтора: заголовок
Dateдолжен попадать в пятиминутное окно, а каждая проверенная подпись записывается в кеш повторов (nonce), так что повторная отправка того же запроса в пределах окна отклоняется; проверки подписи достаточно, чтобы установить отправителя.
Подписью покрываются @method, @authority и @path, плюс @query, когда у запроса есть строка запроса, и content-digest, когда есть тело, плюс date. @query — не факультативная тонкость: @path обрывается на ?, а GET /tasks в REST-привязке целиком фильтрует и листает через параметры запроса; не покрыть их — значит позволить посреднику переписывать их как угодно, а подпись всё равно сойдётся. В параметрах подписи есть ещё и случайный nonce на каждый запрос: created имеет точность до секунды, поэтому два одинаковых запроса в одну секунду подписали бы одни и те же байты, и кеш повторов на приёмной стороне принял бы второй за атаку (в это упирается любой опрос задачи и любая повторная попытка). Настоящий повтор — те же байты, отправленные заново, — по-прежнему ловится, потому что подпись при нём побайтно та же.
У этого слоя нет спецификационных securitySchemes: там перечислены API-ключ, HTTP-аутентификация, OAuth2, OIDC и mTLS — и ни одно из этого не является подписью запроса. Настоящее требование объявляется в Card как обязательное расширение, а REST-привязка принуждает к нему по §3.3.4: клиент, не объявивший это расширение, получает ExtensionSupportRequiredError, а не 401, который ничего не объясняет.
Пример входящего запроса
POST /a2a/v1/messages HTTP/1.1
Host: acme.com
Content-Type: application/json
Date: Sun, 24 Nov 2024 14:30:00 GMT
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
Signature-Input: sig1=("@method" "@authority" "@path" "content-digest" "date");keyid="did:web:vendor-x.com#key-1";created=1732458600;alg="ed25519"
Signature: sig1=:aBcDeF...:
Authorization: Capability eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiQ2FwIn0...
{
"from": "did:web:vendor-x.com:agents:engineer-li",
"to": "did:web:acme.com:agents:support",
"thread_id": "thread_8f3a9c",
"message": {
"type": "question",
"content": "Какой диапазон напряжения у X100 в режиме RTU?",
"language": "ru",
"context": {
"via": "claude-code",
"project_hint": "modbus integration"
}
}
}
Порядок проверки (на приёмной стороне)
- Разобрать заголовки
Signature-InputиSignature. - Извлечь DID из параметра
keyidвSignature-Input. - Получить DID-документ (с кешем: ETag + TTL 60 с).
- Взять открытый ключ, пересобрать базовую строку подписи по RFC 9421 §2.5 и проверить подпись.
- Убедиться, что
Content-Digestсовпадает с хешем тела. - Убедиться, что
Dateукладывается в 5 минут (защита от повтора). - Проверить capability-токен (в стиле macaroon, подробности ниже).
- Заслон согласия на связь: отправитель уже в контактах получателя? Если нет → LLM не запускается, запрос откладывается как заявка на связь (см. ниже).
- Если да → дальше решает движок политик: отвечать или нет.
Capability-токен
Capability-токен позволяет Агенту-отправителю заявить: «я пришёл от имени пользователя X, чтобы спросить нечто типа Y», — и ограничить права мелким шагом.
По форме — JWT, по идее — macaroon:
{
"iss": "did:web:vendor-x.com",
"sub": "did:web:vendor-x.com:users:engineer-li",
"aud": "did:web:acme.com",
"scope": ["ask:technical", "ask:product:X100"],
"exp": 1737000000,
"ctx": {
"thread_id": "thread_8f3a9c",
"delegation_depth": 1
}
}
scope— какие вопросы можно задавать.delegation_depth— сколько раз запрос переадресовывался по делегированию (чтобы цепочка не была бесконечной).
Потоковый ответ
LLM выдаёт ответ потоком, и A2A тоже умеет SSE:
GET /a2a/v1/stream/{message_id} HTTP/1.1
Host: acme.com
Signature: ...
Возвращает text/event-stream:
event: token
data: {"text": "У X100 "}
event: token
data: {"text": "в режиме RTU "}
event: citation
data: {"source": "Руководство по установке X100, с. 12", "url": "..."}
event: done
data: {"thread_id": "thread_8f3a9c"}
Модель разрешений (по образцу Claude Code)
Три уровня:
L1 — автоматически (без подтверждения)
- Мой Агент читает мои же данные.
- Агент другой стороны отвечает, ссылаясь на собственные документы.
- Чисто справочный разговор между Агентами (без побочных эффектов и без обмена данными).
L2 — спросить один раз
- Поделиться каталогом или файлом с Агентом другой стороны.
- Дать чужому Агенту увидеть контекст моего разговора.
- Переслать данные в другую инсталляцию.
- Включить инструмент (в первый раз).
В интерфейсе: всплывает карточка разрешения с четырьмя вариантами:
- Разрешить в этот раз
- Разрешать всегда (в пределах этого пира и этой области)
- Посмотреть подробности
- Отказать
L3 — явное согласие (спрашивается каждый раз)
- Мой Агент от моего имени принимает приглашение, платит, подписывает договор.
- Необратимые действия (удаление, перевод денег, обязательства перед третьими лицами).
- Обязательства с финансовыми или юридическими последствиями.
В интерфейсе: модальное окно + подробный перечень действий + обратный отсчёт (чтобы нельзя было ткнуть случайно).
Постоянные политики
Пользователь может заранее задать правила, перекрывающие поведение по умолчанию:
peer.acme-industries:
allow:
- read: "src/modbus/**"
- ask: "technical:*"
deny:
- read: ".env"
- read: "**/secrets/**"
- ask: "personal:*"
always_consult: true
peer.unknown:
default: ask_user
require_human_in_loop: true
Заслон согласия на связь
Ответ на A2A-сообщение тратит бюджет LLM получателя. Чтобы незнакомый Агент не мог слать сообщения без удержу и жечь токены владельца без его ведома, связь — предварительное условие этой траты:
- Пир уже на связи (есть в
peer_contactsполучателя) → связь и есть согласие; идём в движок политик и обрабатываем как обычно. - Пир не на связи →
POST /a2a/v1/messagesвозвращает202с телом{ "status": "pending_connection" }; разговор не создаётся, сообщение не сохраняется, LLM не запускается. Одновременно в ящик ожидающих кладётся заявка на связь сaction='connect'(с дедупликацией по пиру, чтобы повторные сообщения его не заваливали). - Владелец видит в ящике разрешений «такой-то Агент просит связи + его первое сообщение». Если одобряет — запись пишется в
peer_contacts(связь установлена), и дальше сообщения этого пира обрабатываются нормально; если отказывает — связь не устанавливается.
Модель похожа на LinkedIn или на корпоративную федерацию: слой обнаружения открыт (любой может прочитать agents.json и AgentFacts), слой взаимодействия требует согласия (чужие вычисления тратятся только после установления связи).
Стать «на связи» можно двумя путями:
- Получатель сам добавляет пира:
POST /contacts/lookup→POST /contacts. - Пир проявляет инициативу, а получатель одобряет его заявку из ящика.
Привязка треда (область действия thread_id)
thread_id во входящем сообщении — это просьба пира, а не властное указание. Gateway переиспользует его в исходном виде только при выполнении двух условий сразу:
- Этот пир уже участник этого разговора.
- Этот разговор принадлежит владельцу того Агента, которому адресовано сообщение (
conversations.created_by).
Второе нельзя опускать: peer_agents уникальна глобально по DID, поэтому один и тот же пир может быть на связи сразу с несколькими владельцами. Проверяя только первое, пир, связанный и с A, и с B, мог бы, отправляя сообщение Агенту B, приложить thread_id от A и влить сообщение в разговор A: Агент B ответил бы, взяв за контекст историю A, ответ записался бы в тред A и разошёлся бы A, а содержимое разговора A вдобавок осело бы в долговременной памяти B.
Если оба условия выполнены — значит, другая сторона отвечает на сообщение, ушедшее отсюда (и thread_id тогда есть идентификатор нашего собственного разговора). Если нет — это тред в нумерации самого пира: локально он ни на что не указывает, но для пира он устойчив, поэтому наш идентификатор разговора выводится из sha256('a2a-thread:<id владельца>:<id строки пира>:<thread_id пира>') (lib/derived-id.ts, на выходе 26 символов Crockford, по форме как ULID). Так последующие сообщения того же треда пира всегда возвращаются в тот же разговор.
Раньше незнакомый thread_id считали «треда нет», и получатель заводил новый разговор на каждое пришедшее сообщение: уточнение никогда не оказывалось рядом с исходным вопросом, список разговоров владельца забивался тредами в одну строку, loadA2AHistory не находила истории, и Агент каждый ход отвечал как в первый раз.
Выводить, а не «держать таблицу соответствий», выгодно вдвойне: миграция не нужна, и гонка невозможна — два одновременных сообщения столкнутся на первичном ключе, а не заведут по разговору каждое (потому создание идёт в транзакции с onConflictDoNothing). В склеиваемой строке переменной длины и под контролем пира может быть только последний сегмент; предыдущие — идентификаторы фиксированной длины в 26 символов, так что неэкранированное двоеточие не создаёт двусмысленности.
При создании разговора в conversation_participants пишутся и владелец, и пир. Строка участия владельца — то, на чём держатся список разговоров и заслон чтения по каждому разговору; без неё владелец не увидит тред, на который отвечает его же Агент.
Итак, thread_id — это идентификатор разговора у каждой стороны свой, и они не совпадают. Отсюда два непропускаемых правила:
- В ответе надо вернуть тот
thread_id, который прислал спрашивающий, а не свой. Условие 2 выше (справедливо) отвергнет тред, который ему не принадлежит, поэтому ответ, ушедший с нашим идентификатором разговора, будет подшит на той стороне в совершенно новый разговор; спрашивающий продолжит опрашивать созданный им, и/api/v1/consult/{id}/replyнавсегда останется вpending, притом что на обеих машинах лежит вполне хороший ответ. - В
messages.thread_rootпишется идентификатор локального разговора, и никогда — сырое значение от пира. Эта колонка —char(26), она рассчитана на наши собственные ULID: чужое значение указывало бы на разговор, который, возможно, вовсе не наш, и вдобавок позволило бы любому пиру уронить этот эндпоинт в 500 при помощиthread_idдлиннее 26 символов. У входящегоthread_idесть и отдельная проверка длины.
Не смочь ответить — тоже ответ
Когда адресованный Агент не может отработать ход (модель не настроена, поставщик неизвестен, поставщик задан, но ключа нет, вызов модели упал), он отправляет type: 'notification', где context.error несёт машиночитаемый код (no_model_configured / unknown_provider / no_key_for_provider / agent_error), а content — пояснительная фраза по-английски. Взят именно notification, потому что он не вызывает на той стороне ещё один автоответ (это делает только question).
Не сделать так — не значит «не хватило уведомления»: сбой сводится к одной строке лога у отвечающего, на провод не уходит ничего, а /api/v1/consult/{id}/reply у спрашивающего опрашивает до истечения срока и возвращает pending — и так при каждой повторной попытке, без единой возможности отличить «ещё думает» от «уже никогда».
Собеседник — другая инсталляция, нашего языка он не знает, поэтому опорой служит код в context.error; content — только запасной человекочитаемый текст. Правилу «сервер не сочиняет текст для пользователя» это не противоречит: то правило регулирует то, что уходит клиенту этой же инсталляции.
Тот же сбой ещё и записывается в базу — сообщением с content_type: 'system_notice' в разговор (in_reply_to указывает на вопрос, content_json несёт тот же код, а фразу клиент собирает через i18n). Отправить, не записав, имеет три последствия, и все три случались: владелец видит у себя в мессенджере вопрос собеседника и после него ничего, так и не узнав, что это у него не настроена модель; задача REST-привязки A2A остаётся в WORKING вместо FAILED, и клиент опрашивает то, что никогда не завершится; а GET /a2a/v1/stream/{id} бесконечно отвечает pending. С этой заметкой все три разом переходят в определимое конечное состояние.
Адресация: два DID указывают на одного Агента
to принимает и DID Агента (did:web:<host>:agents:<user>:agent — именно он перечислен в открытом каталоге /.well-known/agents.json), и DID владельца (did:web:<host>:agents:<user>). Последний — единственный идентификатор, который вообще разрешается в DID-документ, и именно его клиент показывает пользователю для копирования: принимать только первый — значит сделать так, что «добавить контакт, вставив DID» даёт контакт, к которому есть соединение, у которого сходится подпись и который отвечает 404.
По той же причине, решая, на связи ли пир-отправитель, надо принимать и from (DID Агента), и DID подписавшего, полученный при проверке подписи (DID владельца): в peer_agents строка заводится по DID, а какой из двух там оказался — зависит от того, как когда-то добавляли контакт; принимать только from — значит превратить ответ собеседника в «заявку на связь от незнакомца».
Ящик ожидающих (ответ в отсутствие владельца)
Когда приходит вопрос от уже связанного пира, а владельца нет на месте, решает движок политик (evaluatePolicy, action=ask, L2):
allow(по умолчанию, поскольку связь уже есть согласие) → Агент отвечает сразу (201+ цикл автоответа).ask_user(владелец явно выставилpolicies_json.default='ask_user'или правило{action:'ask',decision:'ask_user'}) → уже реализовано: входящий вопрос всё равно сохраняется и рассылается (владелец видит его в мессенджере), но автоответа нет; в ящик кладётся ожидающее разрешение сaction='ask', аPOST /a2a/v1/messagesвозвращает202 { "status": "pending_approval", "message_id" }. Владелец видит вопрос вGET /permissions/pending; если вPOST /permissions/{id}/decideон выбираетallow_*, Агент отвечает от его имени (ответ сin_reply_to+ исходящая доставка); еслиdeny— ответа нет. На стороне пираGET /a2a/v1/stream/{message_id}до одобрения возвращаетstatus:'pending', а после — ответ.deny(явное правило отказа) →403 policy_denied.
На что способен A2A-ответ: входящий A2A-ответ и веб-чат проходят через одну и ту же общую оркестрацию (
runAgentTurnвorchestration/agent-orchestrator.ts), но возможности у них разные.runAgentTurnпринимает обязательныйaudience('owner' | 'peer'— обязательный, а не со значением по умолчанию, потому что значением по умолчанию оказалось бы как раз разрешительное), и от него зависят и набор инструментов, и достижимая поверхность данных:
- Ход владельца (веб-чат):
web_search,search_knowledge_base(все базы),list_knowledge_bases,search_memory,list_contacts, плюс автоматический вызов долговременной памяти.- Ход пира (входящий A2A-ответ): только
web_search,search_knowledge_baseиlist_knowledge_bases, причём поиск ограничен базами, помеченнымиshared_with_peers; долговременная память не поднимается, и ниsearch_memory, ниlist_contactsне предлагаются — первая содержит факты, осевшие из личных разговоров владельца, вторая — его социальный граф; чтобы ответить на вопрос незнакомца, не нужно ни то ни другое.Граница держится на поверхности данных, а не на промпте: вопрос пира и указания владельца доходят до модели одинаковым текстом, поэтому «Агент откажется раскрывать» не работает; работает только то, что поиск физически туда не дотянется. По той же причине не дать инструмент — это не контроль доступа: модель может выдать вызов имени, которого ей не давали, и потому ветки «только для владельца» перепроверяют
audienceвнутриexecuteToolCall.Оба вида хода используют ключ владельца, а не спрашивающего пира. Найденные фрагменты баз знаний сохраняются как цитаты в
messages.citations_json, а по завершении факты этого хода асинхронно оседают в долговременную память (строки, пришедшие с хода пира, помечаютсяa2a, и при извлечении их происхождение указывается). Если у владельца нет ключей для эмбеддингов, баз знаний или Tavily, всё аккуратно вырождается в чистый ответ LLM (без ошибки и без цитат). Путь ответа приallowи при одобренномask_userиспользует ту же оркестрацию.
scope_jsonожидающего разрешения сask='ask'имеет вид{ kind:'a2a_question', conversation_id, inbound_message_id, sender_did, peer_id, content }— этого хватает, чтобы при одобрении восстановить и продолжить ответ (агент и пир перечитываются на месте поuser_id/peer_id; идемпотентно: если ответ уже есть, шаг пропускается). Интерфейс настройки постоянных политик, вариант «отредактировать и ответить» и push-уведомления пока остаются в бэклоге.
Федеративное обнаружение
Поиск по домену
Получив домен acme.com, клиент:
- Забирает
https://acme.com/.well-known/did.json, чтобы узнать основной DID. - Забирает
https://acme.com/.well-known/agents.json, чтобы перечислить всех публичных Агентов этого домена. - Выбирает одного и добавляет в контакты.
Разрешение пользовательского DID
Имея DID с подидентификатором Агента пользователя, его DID-документ разрешается по спецификации did:web:
did:web:acme.com:agents:laowang→GET https://acme.com/agents/laowang/did.json- Голый DID инсталляции
did:web:acme.com→GET https://acme.com/.well-known/did.json
Проверка подписи входящего A2A идёт ровно этим путём: извлечь DID подписавшего из keyid в Signature-Input → разрешить в указанный URL → взять из verificationMethod открытый ключ, совпадающий с keyid, и проверить. Этот документ раскрывает только материал открытого ключа, а verificationMethod[*].id — это хранимый key_id (он не пересобирается из Host запроса), поэтому идентификатор, полученный при разрешении из другой инсталляции, и полученный локально всегда совпадают.
Публичный реестр (v2 и далее)
Подключение к NANDA Index или подобному публичному реестру с поддержкой:
- Поиска по возможностям («найди Агента, который разбирается в Modbus»).
- Поиска по организации («Агент ABC Industries»).
- Поиска по местоположению («сервисные Агенты рядом»).
Граф доверия (v2 и далее)
- Агенты моих контактов идут первыми.
- Агенты компании моих коллег идут первыми.
- Поручительства третьих сторон (проверенные NANDA) получают значок доверия.
Защита от спама
- Ограничение частоты по домену пира в минуту (счётчик в памяти процесса; шлюз работает в одном экземпляре).
- Пиры вне белого списка по умолчанию имеют низкий приоритет.
- Пользователь может заблокировать домен пира.
- Репутационная оценка (v2 и далее): сколько других инсталляций отметили его как спам.
Стратегия перевода
- Каждый Агент объявляет в AgentFacts
primary_languageиstyle. - В межъязыковом разговоре перевод делается внутри Агента-получателя (он лучше всех знает собственную терминологию и документы).
- Процитированная часть всегда сохраняет оригинал: пользователь может посмотреть авторитетную формулировку до перевода.
- Поведение по умолчанию —
preserve-style(стиль сохраняется, меняется только язык); потребительский сценарий может объявитьlocalize-style(подстроиться под местные обычаи).
Стратегия развития протокола
- У всех протоколов есть поле
@contextилиversion. - Клиент и сервер держат обратную совместимость (принимают и игнорируют неизвестные поля).
- Ломающие изменения идут через повышение мажорной версии (например,
/a2a/v2/). - Совместимость с развитием схем NANDA и Google A2A (мы ставим на открытую экосистему).