Переведено с китайского оригинала. При расхождении приоритет у оригинала.

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 корня инсталляции:

Ротация ключей

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"
  }
}

Поля:

Протокол A2A

Слой протокола

Весь A2A-обмен идёт по HTTPS POST/GET в кодировке JSON. Под /a2a/v1 сосуществуют две привязки:

Обе используют одни и те же заслоны — проверку подписи, заслон согласия, решение политики, принадлежность треда, — и все они лежат в a2a/inbound.ts; различается только формат на проводе. Написать эти заслоны по разу на привязку — ровно та причина, по которой баг с межарендной подстановкой треда был написан четыре раза; больше их не разделяйте.

Главное: используются HTTP Message Signatures (RFC 9421), а не bearer-токен. Почему:

Подписью покрываются @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"
    }
  }
}

Порядок проверки (на приёмной стороне)

  1. Разобрать заголовки Signature-Input и Signature.
  2. Извлечь DID из параметра keyid в Signature-Input.
  3. Получить DID-документ (с кешем: ETag + TTL 60 с).
  4. Взять открытый ключ, пересобрать базовую строку подписи по RFC 9421 §2.5 и проверить подпись.
  5. Убедиться, что Content-Digest совпадает с хешем тела.
  6. Убедиться, что Date укладывается в 5 минут (защита от повтора).
  7. Проверить capability-токен (в стиле macaroon, подробности ниже).
  8. Заслон согласия на связь: отправитель уже в контактах получателя? Если нет → LLM не запускается, запрос откладывается как заявка на связь (см. ниже).
  9. Если да → дальше решает движок политик: отвечать или нет.

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
  }
}

Потоковый ответ

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 получателя. Чтобы незнакомый Агент не мог слать сообщения без удержу и жечь токены владельца без его ведома, связь — предварительное условие этой траты:

Модель похожа на LinkedIn или на корпоративную федерацию: слой обнаружения открыт (любой может прочитать agents.json и AgentFacts), слой взаимодействия требует согласия (чужие вычисления тратятся только после установления связи).

Стать «на связи» можно двумя путями:

  1. Получатель сам добавляет пира: POST /contacts/lookup → POST /contacts.
  2. Пир проявляет инициативу, а получатель одобряет его заявку из ящика.

Привязка треда (область действия thread_id)

thread_id во входящем сообщении — это просьба пира, а не властное указание. Gateway переиспользует его в исходном виде только при выполнении двух условий сразу:

  1. Этот пир уже участник этого разговора.
  2. Этот разговор принадлежит владельцу того Агента, которому адресовано сообщение (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 — это идентификатор разговора у каждой стороны свой, и они не совпадают. Отсюда два непропускаемых правила:

Не смочь ответить — тоже ответ

Когда адресованный Агент не может отработать ход (модель не настроена, поставщик неизвестен, поставщик задан, но ключа нет, вызов модели упал), он отправляет 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):

На что способен 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, клиент:

  1. Забирает https://acme.com/.well-known/did.json, чтобы узнать основной DID.
  2. Забирает https://acme.com/.well-known/agents.json, чтобы перечислить всех публичных Агентов этого домена.
  3. Выбирает одного и добавляет в контакты.

Разрешение пользовательского DID

Имея DID с подидентификатором Агента пользователя, его DID-документ разрешается по спецификации did:web:

Проверка подписи входящего A2A идёт ровно этим путём: извлечь DID подписавшего из keyid в Signature-Input → разрешить в указанный URL → взять из verificationMethod открытый ключ, совпадающий с keyid, и проверить. Этот документ раскрывает только материал открытого ключа, а verificationMethod[*].id — это хранимый key_id (он не пересобирается из Host запроса), поэтому идентификатор, полученный при разрешении из другой инсталляции, и полученный локально всегда совпадают.

Публичный реестр (v2 и далее)

Подключение к NANDA Index или подобному публичному реестру с поддержкой:

Граф доверия (v2 и далее)

Защита от спама

Стратегия перевода

Стратегия развития протокола

← Вернуться в Confer A2A · DID:web · RFC 9421 · NANDA