A2A (Agent2Agent) — открытый протокол, который сейчас ведёт Linux Foundation: через него агенты на разных фреймворках вызывают друг друга. Версия 1.0.0 вышла 12 марта 2026 года. Официальные SDK есть для Python, JavaScript, Java, Go, .NET и Rust. PHP в этом списке нет, а PHP-пакеты, которые я нашёл на Packagist в сентябре 2026 года, поддерживают протокол 0.3.0.

Поэтому я написал a2a-php-sdk и a2a-symfony-bundle. SDK протокола чего-то стоит, только если разговаривает с реализациями, написанными другими людьми, поэтому статья не про устройство SDK. Она про один воспроизводимый прогон против эталонной реализации:

НаправлениеКлиентСервер
PHP → Pythona2a-php-sdkофициальный пример hello-world на a2a-sdk
Python → Symfonyофициальный клиент a2a-sdkприложение Symfony 8 с бандлом

Всё ниже взято из a2a-php-interop-demo. make demo собирает его, прогоняет оба направления и завершается с кодом 0. Полный вывод прогона лежит в репозитории: docs/run-2026-09-15.log.

Версии

КомпонентВерсия
PHP8.4.23
vbcherepanov/a2a-php-sdk1.0.0
vbcherepanov/a2a-symfony-bundle1.0.2
symfony/framework-bundle8.0.15
Python3.13.15
a2a-sdk1.1.0
a2a-samplesкоммит 6603ba3

Python-агент — не копия. Dockerfile скачивает __main__.py, agent_executor.py и requirements.txt из этого самого коммита a2a-samples и запускает их без изменений.

Одно ограничение определило всю схему

Официальный пример слушает 127.0.0.1:9999 и тот же адрес записывает в свою Agent Card. В Docker этот адрес виден только внутри контейнера. Вместо того чтобы править эталонную реализацию, все сервисы в compose.yaml подключены к сетевому пространству Python-агента:

symfony-agent:
  build: ./symfony-agent
  network_mode: "service:python-agent"

Теперь для любого контейнера 127.0.0.1:9999 — это Python-агент, а 127.0.0.1:8000 — агент на Symfony.

Направление 1: PHP вызывает официального Python-агента

Клиент находит агента, берёт из карточки JSON-RPC-интерфейс и отправляет одно и то же сообщение дважды — блокирующим вызовом и потоковым:

$http = HttpClient::create();
$options = new CallOptions(timeoutSeconds: 30.0);

$card = (new Discovery($http, allowPrivateNetwork: true))->discover($baseUrl, $options);

$client = new Client(new HttpTransport($http, $endpoint, jsonRpc: true), $options);
echo $client->sendMessage($request())->serializeToJsonString();

foreach ($client->sendStreamingMessage($request()) as $event) {
    echo $event->serializeToJsonString(), "\n";
}

allowPrivateNetwork: true — сознательный выбор. По умолчанию обнаружение в SDK отказывается ходить на адреса частных сетей: Agent Card — это чужие данные. Для локального демо защиту приходится выключать явно, а в боевом коде она остаётся включённой.

Потоковый вызов дал четыре события, по порядку:

task          TASK_STATE_SUBMITTED
statusUpdate  TASK_STATE_WORKING    "Processing request..."
artifactUpdate                      "Hello, World! I have received your request (Hi from a2a-php-sdk)"
statusUpdate  TASK_STATE_COMPLETED  "Request is completed!"

Эти строки пишет executor из Python-примера. PHP-сторона разобрала каждое событие в сгенерированные protobuf-классы без единого ручного маппинга.

Направление 2: официальный Python-клиент вызывает Symfony

На стороне Symfony агент — это четыре файла поверх symfony/skeleton и composer require vbcherepanov/a2a-symfony-bundle.

Executor — один класс, который отдаёт артефакт и финальный статус:

final class EchoExecutor implements Executor
{
    public function execute(SendMessageRequest $request, Task $task, CallContext $context): iterable
    {
        // собрать текстовые части входящего сообщения в $text
        yield (new StreamResponse())->setArtifactUpdate(/* "Hello from a Symfony agent. You said: $text" */);
        yield (new StreamResponse())->setStatusUpdate(/* TASK_STATE_COMPLETED */);
    }
}

Конфиг бандла и импорт маршрутов:

# config/packages/a2a.yaml
a2a:
    executor: App\Agent\EchoExecutor
    card_file: '%kernel.project_dir%/config/agent-card.json'
    public_url: '%env(A2A_PUBLIC_URL)%'
    auth:
        tokens:
            python-client: '%env(A2A_DEMO_TOKEN)%'

# config/routes/a2a.yaml
a2a:
    resource: .
    type: a2a

Agent Card содержит только имя, возможности и навыки. Интерфейсы бандл выставляет сам — из public_url и включённых транспортов.

Python-клиент — официальный API a2a-sdk, Bearer-токен передаётся через httpx:

async with httpx.AsyncClient(headers={'Authorization': f'Bearer {TOKEN}'}) as http:
    client = await create_client(
        agent=card,
        client_config=ClientConfig(streaming=True, httpx_client=http),
    )
    async for chunk in client.send_message(request):
        print(chunk)

Вот что официальный клиент напечатал про агента на Symfony и его ответ:

Name        : Symfony Echo Agent
  [0] http://127.0.0.1:8000/a2a/rpc  (JSONRPC 1.0)
  [1] http://127.0.0.1:8000/a2a  (HTTP+JSON 1.0)
Streaming           : True

artifact_update  "Hello from a Symfony agent. You said: Hi from the official A2A Python SDK"
status_update    TASK_STATE_COMPLETED

Затем тот же клиент запустился с неверным токеном:

a2a.client.errors.A2AClientError: HTTP Error 401: Client error '401 Unauthorized'
for url 'http://127.0.0.1:8000/a2a/rpc'

Анонимного режима у бандла нет. A2A-эндпоинт выполняет работу от имени вызывающего, поэтому контейнер не соберётся, пока не настроена карта токенов, access-token handler Symfony или собственный аутентификатор.

Два бага, которые нашла только настоящая установка

Готовя демо, я поставил бандл так, как поставил бы посторонний человек, — composer require в свежий symfony/skeleton, — и он сломался дважды.

1.0.0: Symfony Flex регистрирует бандл автоматически, даже без рецепта, а затем запускает cache:clear. В конфигурации бандла card_file, executor и public_url были обязательными, поэтому установка падала с The child config “card_file” under “a2a” must be configured раньше, чем кто-то успевал что-то настроить. CI бандла и раньше ставил пакет в свежий проект, но эта проверка не проходила через Symfony Flex. Исправлено в 1.0.1: без секции a2a бандл ничего не регистрирует, а CI теперь ставит пакет через skeleton.

1.0.1: если файл config/routes/a2a.yaml уже есть, а конфигурации ещё нет, маршрутизация падала с Cannot load resource ”.” Make sure there is a loader supporting the “a2a” type. Это важно, потому что Flex-рецепт кладёт routes-файл прямо при установке. Исправлено в 1.0.2: загрузчик маршрутов регистрируется всегда и отдаёт пустую коллекцию, пока бандл не настроен.

Ни один из багов не был виден модульным тестам. Оба видны в первую же минуту установки пакета по его собственной документации.

Что этот прогон доказывает, а что нет

Он доказывает, что для указанных версий PHP-SDK и Symfony-бандл совместимы с эталонной Python-реализацией по JSON-RPC: обнаружение Agent Card, блокирующий SendMessage и потоковый SendMessage, включая Bearer-аутентификацию на стороне Symfony.

Он не покрывает биндинги REST и gRPC, push-уведомления и расширенную Agent Card против Python — у SDK на это есть свои тесты, но не в этом демо. Встроенный сервер PHP — только для демо.

Соответствие спецификации — отдельный вопрос, и отвечает на него официальный набор тестов, а не один пример. SDK прогоняет официальный a2a-tck 1.0.0 на всех трёх транспортах: 244 passed, 3 failed, 18 skipped. Все три падения — одно требование, CORE-SEND-003, которое засчитывает правильный ответ с ошибкой как провал, потому что в его определении не указана ожидаемая ошибка. Дефект заведён как a2a-tck#202, и с исправлением из PR #203 прогон даёт 247 passed, 18 skipped. Это не сертификация, и я её так не подаю.

Попробовать

git clone https://github.com/vbcherepanov/a2a-php-interop-demo.git
cd a2a-php-interop-demo
make demo
make down

Если на вашей машине не заработает — откройте issue в демо-репозитории с выводом make demo. Упавший прогон совместимости — ровно тот отчёт, который этим пакетам нужен.