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 → Python | a2a-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.
Версии
| Компонент | Версия |
|---|---|
| PHP | 8.4.23 |
| vbcherepanov/a2a-php-sdk | 1.0.0 |
| vbcherepanov/a2a-symfony-bundle | 1.0.2 |
| symfony/framework-bundle | 8.0.15 |
| Python | 3.13.15 |
| a2a-sdk | 1.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
- SDK: github.com/vbcherepanov/a2a-php-sdk · Packagist
- Бандл для Symfony: github.com/vbcherepanov/a2a-symfony-bundle · Packagist
- Протокол: a2a-protocol.org
Если на вашей машине не заработает — откройте issue в демо-репозитории с выводом make demo. Упавший прогон совместимости — ровно тот отчёт, который этим пакетам нужен.