Код управляет кодинг-агентом по ACP
Код управляет кодинг-агентом по ACP

Claude Code, Codex, Gemini CLI — мощные штуки. Но живут они в терминале: вы открываете окно, печатаете задачу, смотрите, как агент думает, лезет в файлы, гоняет тесты. Для человека — отлично. А теперь представьте, что в это кресло перед терминалом нужно посадить не человека, а вашу программу: чтобы она сама ставила агенту задачу, видела каждый его шаг и могла остановить опасное действие до того, как оно случится.

Вот тут и начинается ACP — протокол, который позволяет любому коду управлять CLI-агентом.


TL;DR

Проблема: у CLI-кодинг-агентов интерфейс для человека, а не для программы. Хотите управлять ими из кода — извольте парсить stdout и отвечать на вопросы «выполнить команду?», на которые некому ответить.

Решение: ACP (Agent Client Protocol) — открытый стандарт «хост ↔ кодинг-агент». В AG2 любой CLI-агент поднимается обычным Agent в пять строк, а всё, что он делает, прилетает к вам событиями.


Всем привет! Я Семен Фролов — контрибьютор AG2; недавно я и принёс в него поддержку ACP, модуль ag2.acp. В статье разберём ACP простыми словами: как он устроен, почему это удобно и как за пять строк поднять Claude Code управляемым агентом. А под конец заставим его слушаться удалённо — голосом, с другого хоста.

Сразу про путаницу. Аббревиатуру «ACP» носят два разных протокола. Есть Agent Communication Protocol от IBM (для BeeAI, март 2025) — но его передали в Linux Foundation и объединили с A2A, отдельную разработку сворачивают. В этой статье ACP — это всегда Agent Client Protocol от Zed (agentclientprotocol.com), тот, что живёт и развивается.

Как вы обычно говорите с Claude Code

Стандартный сценарий: терминал, интерактивный чат, вы внутри цикла. Напечатали «почини падающий тест» — агент пошёл читать файлы, запускать pytest, править код, и в какой-то момент просит разрешение: выполнить вот эту команду в шелле или применить правку к файлу? Вы смотрите глазами, отвечаете «да/нет», и так до конца.

Пока за рулём человек — это удобно. Человек видит вывод, человек принимает решения, человек ловит момент, когда агент собрался сделать глупость.

А теперь уберите человека и посадите за руль программу. Сразу всё ломается:

- Нет структурированного потока. Агент сыплет текст в stdout вперемешку: где тут «мысль», где «вызов инструмента», где «финальный ответ»? Парсить терминальный вывод регулярками — тупиковый путь.

- Некуда вклиниться. Агент спрашивает разрешение на опасную команду — а отвечать-то некому. Программе нужен способ перехватить этот вопрос и ответить по своей логике.

- Не собрать конвейер. Хочется, чтобы один агент написал код, а другой его отревьюил — но два процесса в терминале друг о друге ничего не знают.

Человек уходит от терминала, за руль садится программа
Человек уходит от терминала, за руль садится программа

Получается, терминал — это интерфейс для человека, а программе нужен интерфейс для программы. Контракт. Им и стал ACP.

Что такое ACP, если в двух словах

Лучшая аналогия — LSP, Language Server Protocol. До LSP каждый редактор тащил свою поддержку каждого языка: автодополнение Python в Vim, в Emacs, в Sublime — три разные реализации одного и того же. LSP это развязал: язык реализует один сервер, редактор реализует одного клиента, и N редакторов × M языков превращаются из N×M костылей в N+M.

ACP делает ровно то же — но для кодинг-агентов. По одну сторону хост (редактор или ваш код), по другую — агент, а между ними единый контракт. Написали агента один раз — и он работает в любом ACP-хосте, без отдельного переходника под каждый.

Один протокол вместо N×M интеграций
Один протокол вместо N×M интеграций

Несколько фактов, чтобы прикинуть зрелость:

- Август 2025 — Zed выкатывает ACP. Толчком стал Google: пришёл со своим Gemini CLI, и обеим командам захотелось интеграции глубже, чем «запустить тулзу в терминале».

- Протокол открытый (Apache-2.0), вынесен в отдельную организацию github.com/agentclientprotocol.

- Поддержка со стороны редакторов — Zed, JetBrains, VS Code, Neovim, Emacs; в реестре уже почти полсотни агентов: Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode и другие.

Технически всё просто: JSON-RPC 2.0 поверх stdio. Хост запускает агента дочерним процессом и общается с ним через stdin/stdout. Никакого сетевого слоя, никаких портов — просто два процесса и один канал.

        ваш код (ACP-клиент / хост)
                  │
                  │  спавнит subprocess
                  ▼
        ┌───────────────────────┐
        │  claude-agent-acp     │   ← ACP-агент
        │  (Claude Code внутри) │
        └───────────────────────┘
                  ▲
                  │  JSON-RPC 2.0 поверх stdin/stdout
                  ▼
        промпты  →   ← поток событий (мысли, tool-calls, план…)

Запомните роли: клиент — это хост, тот, кто запускает и управляет. Агент — дочерний процесс, который ходит в LLM и делает работу. Дальше всё вокруг этой пары.

Тут внимательный читатель спросит: а зачем протокол, если у Claude Code есть официальный Claude Agent SDK и headless-режим, у Codex — свой SDK? Ответ тот же, что и у LSP. Напишете интеграцию на Claude Agent SDK — получите интеграцию с Claude Code, и только с ним. Захотите завтра прогнать ту же задачу через Codex или OpenCode — переписывайте под второй SDK, потом под третий. ACP — это один контракт на всех: код хоста не меняется, меняется команда запуска агента. Плюс SDK решает задачу «мой код дёргает агента», а ACP заодно стандартизирует то, что обычно приходится изобретать самому: запросы разрешений, файловые операции через хост, формат потока событий.

Минимальный пример: Claude Code как обычный агент

Теперь — как это выглядит из кода. Вот как в AG2 поднять Claude Code и дать ему задачу:

from ag2 import Agent
from ag2
.acp import ClaudeCodeConfig

agent = Agent("coder", config=ClaudeCodeConfig(cwd="/path/to/repo"))

reply = await agent.ask("Отрефактори модуль auth и добавь тесты")
print(reply.body)

Пять содержательных строк. Ни слова про subprocess, JSON-RPC или stdio. А под капотом случилось всё, что мы разбирали: ClaudeCodeConfig запустил адаптер claude-agent-acp дочерним процессом, сделал рукопожатие, создал сессию, отправил ваш промпт и дождался ответа.

Один ask() (или run()) — это один prompt-turn в терминах ACP. Внутри него у агента крутится его собственный цикл «подумал → вызвал инструмент → почитал результат → пошёл дальше», сколько нужно. Вам наружу возвращается финальный текст, а весь процесс по дороге — потоком событий (об этом — ниже).

Сменить движок — это сменить config, код вокруг не меняется:

from ag2.acp import CodexConfig, OpenCodeConfig

agent = Agent("coder", config=CodexConfig(cwd="/path/to/repo"))     # Codex
agent = Agent("coder", config=OpenCodeConfig(cwd="/path/to/repo"))  # OpenCode

Тут стоит сказать важное: «заговорить по ACP» агенты умеют по-разному. OpenCode и Gemini CLI поддерживают ACP сами — у них есть встроенный режим (opencode acp, флаг --acp). А вот Claude Code и Codex напрямую ACP не говорят — для них есть отдельные адаптеры-обёртки (claude-agent-acp, codex-acp): снаружи они принимают ACP, а внутрь дёргают агента на его «родном» языке. На стороне вашего кода разницы никакой — готовый конфиг в AG2 просто знает, какую команду запустить; всплывёт она только при установке.

Поэтому ставим две вещи: сам интеграционный модуль через extra и нужный CLI-агент или его адаптер (так, чтобы исполняемый файл лежал на PATH):

pip install "ag2[acp]"                                 # модуль AG2

npm install -g @agentclientprotocol/claude-agent-acp   # адаптер для Claude Code
npm install -g @agentclientprotocol/codex-acp          # адаптер для Codex

Видно каждый шаг: типизированный поток событий

В терминале работа агента — это бегущий текст. Через ACP она превращается в типизированный поток событий, на который можно подписаться.

Работа агента становится наблюдаемым потоком типизированных событий
Работа агента становится наблюдаемым потоком типизированных событий

Подписываемся на стрим прогона и разбираем события по типам:

from ag2 import Agent
from ag2.acp import ClaudeCodeConfig
from ag2.events import ModelMessageChunk, ModelReasoning
from ag2.events.tool_events import BuiltinToolCallEvent
from ag2.acp.events import ACPPlan

agent = Agent("coder", config=ClaudeCodeConfig(cwd="/path/to/repo"))

def observe(event):
    if isinstance(event, ModelReasoning):
        print("?", event.content)              # агент думает
    elif isinstance(event, ModelMessageChunk):
        print(event.content, end="")            # текст ответа по кускам
    elif isinstance(event, BuiltinToolCallEvent):
        print(f"? {event.name}({event.arguments})") # пошёл в инструмент провайдера
    elif isinstance(event, ACPPlan):
        for step in event.entries:
            print(f"  [{step.status}] {step.content}")  # план работы

async with agent.run("Добавь healthcheck-эндпоинт") as run:
    run.stream.subscribe(observe)   # подписаться до старта
    reply = await run.result()

Каждый тип обновления в ACP ложится на событие AG2:

Обновление ACP

Событие AG2

текст агента

ModelMessageChunk → итоговый ModelResponse

размышления

ModelReasoning

вызов / результат инструмента

BuiltinToolCallEvent / BuiltinToolResultEvent

план работы

ACPPlan

смена режима

ACPModeChange

доступные команды

ACPAvailableCommands

«Чёрный ящик, который что-то печатает в терминал» превращается в наблюдаемый процесс: можно логировать каждое действие, строить из него UI, считать токены, ловить момент, когда агент полез не туда. Программа наконец видит, что делает агент, — а не парсит его вывод.

Под капотом: рукопожатие и жизнь сессии

Если развернуть «магию» из примера, обмен по ACP выглядит так:

┌─ Хост (ваш код) ─┐                          ┌─ Агент (subprocess) ─┐
│                  │   initialize             │                      │
│                  │ ───────────────────────► │  согласовали         │
│                  │ ◄───── capabilities ──── │  возможности         │
│                  │                          │                      │
│                  │   session/new (cwd, …)   │                      │
│                  │ ───────────────────────► │  завели сессию       │
│                  │ ◄──── session_id ─────── │                      │
│                  │                          │                      │
│                  │   session/prompt         │                      │
│                  │ ───────────────────────► │ ┌──────────────────┐ │
│                  │                          │ │ внутренний цикл  │ │
│                  │ ◄═ session/update ══════ │ │ агента: думает,  │ │
│                  │ ◄═ session/update ══════ │ │ зовёт tools,     │ │
│                  │ ◄═ session/update ══════ │ │ пишет файлы…     │ │
│                  │                          │ └──────────────────┘ │
│                  │ ◄──── stop_reason ────── │  turn завершён       │
└──────────────────┘                          └──────────────────────┘

Сначала initialize — стороны сверяют, кто что умеет (это и есть согласование возможностей). Потом session/new заводит сессию с рабочей директорией. Дальше каждый ваш ask() — это session/prompt, в ответ на который сыплются session/update (те самые события из прошлой секции), пока turn не дойдёт до stop_reason.

И ещё одна приятная деталь: subprocess спавнится лениво, на первом turn, и переиспользуется на весь прогон — сессия живёт, контекст копится, заново процесс не поднимается.

Разрешения и human-in-the-loop

Вернёмся к тому самому «выполнить эту команду?». Когда агент собирается сделать что-то чувствительное — запустить команду в шелле, переписать файл — по ACP он шлёт хосту запрос разрешения. И вот тут программа получает ровно ту власть, которой не было в голом терминале: решить, что ответить.

Хост решает: ask, auto или deny
Хост решает: ask, auto или deny

За это отвечает permission_policy:

Политика

Что делает

"ask" (по умолчанию)

спросить человека — через hitl_hook / context.input

"auto"

разрешать автоматически (автономный прогон)

"deny"

запрещать автоматически

agent = Agent(
    "coder",
    config=ClaudeCodeConfig(
        cwd="/repo", 
        permission_policy="auto",  # полностью автономно
    ),
)

Песочница: файлы и терминал под контролем хоста

Ещё один нюанс, который в терминале незаметен, а из кода критичен: операции с файлами и запуск команд агент делает не сам напрямую, а просит об этом хост. То есть чтение/запись файла и выполнение команды проходят через ваш код — и AG2 ограничивает их корнем fs_root (по умолчанию это рабочая директория cwd).

Агент работает только внутри выданной ему песочницы fs_root
Агент работает только внутри выданной ему песочницы fs_root

Практический смысл простой: автономный агент с permission_policy="auto" не уедет писать за пределы выданной ему папки. Попытка вылезти из fs_root упирается в ошибку, а не в испорченную файловую систему. Когда за рулём программа, а не человек, такая песочница — не роскошь, а условие, при котором это вообще можно запускать без присмотра.

Оркестрация: несколько агентов вместе

Раз каждый CLI-агент — это обычный Agent, их можно собирать между собой. Например, сделать одного агента инструментом другого через .as_tool():

from ag2 import Agent
from ag2.acp import ClaudeCodeConfig

reviewer = Agent("reviewer", config=ClaudeCodeConfig(cwd="/repo"))
manager = Agent(
    "manager",
    config=ClaudeCodeConfig(cwd="/repo"),
    tools=[reviewer.as_tool(description="Отревьюить изменения на корректность")],
)

manager пишет код, а в нужный момент сам дёргает reviewer как инструмент — и вы видите оба прогона одним потоком событий. Никто не мешает посадить их на разные движки: один на Claude Code, другой на Codex.

Бонус: командуем Claude Code голосом — с другого хоста (ACP × A2A)

Раз уж дочитали — самое вкусное. ACP управляет агентом локально, через stdio. Но раз ACP-агент — это обычный Agent, ничто не мешает отдать его и по сети.

За сеть в AG2 отвечает модуль ag2.a2a: он публикует любого Agent наружу по протоколу [A2A](https://a2a-protocol.org) («агент ↔ агент через сеть»). Выставляем Claude Code как сетевой сервис:

import uvicorn
from ag2 import Agent
from ag2.acp import ClaudeCodeConfig
from ag2.a2a import A2AServer, build_card

# тот же агент на Claude Code — теперь публикуем его по сети
coder = Agent("coder", config=ClaudeCodeConfig(cwd="/repo", permission_policy="auto"))

server = A2AServer(coder)
card = build_card(coder, url="http://0.0.0.0:8000")
asgi = server.build_jsonrpc(url="http://0.0.0.0:8000", card=card)

uvicorn.run(asgi, host="0.0.0.0", port=8000)

Теперь на стороне вызывающего этот удалённый Claude Code выглядит просто как адрес — обычный Agent с A2AConfig:

from ag2 import Agent
from ag2.a2a import A2AConfig

# Claude Code живёт на другом хосте — для нас это просто card_url
remote_coder = Agent("coder", config=A2AConfig(card_url="http://coder-host:8000"))
reply = await remote_coder.ask("Почини тест в core/")

А дальше — кульминация. В AG2 есть ещё и ag2.liveголосовые агенты (LiveAgent) поверх realtime-API OpenAI и Gemini: они слушают вашу речь (live STT), отвечают голосом и умеют вызывать инструменты. Отдайте такому агенту remote_coder как инструмент — и Claude Code на другом хосте становится тем, что голосовой агент дёргает по ходу разговора. Получается цепочка из трёх стандартных кусков:

   Вы          LiveAgent               A2A-сервер           Claude Code
 (голос)   (OpenAI/Gemini live)        (другой хост)        (subprocess)
    │             │                          │                   │
    │  речь (STT) │   задача по A2A          │                   │
    │ ──────────► │ ─── по сети ───────────► │   prompt по ACP   │
    │             │                          │ ──── по stdio ──► │
    │             │                          │ ◄══ шаги ═════════│
    │ ◄─ голосом ─│ ◄──── результат ─────────│                   │

Вы проговариваете задачу вслух → голосовой агент на realtime-API OpenAI/Gemini разбирает её и по A2A передаёт на другой хост → там Claude Code за рулём ACP пишет код, а его шаги (мысли, правки, запуски тестов) идут обратно по цепочке. По сути — управление кодинг-агентом голосом, через сеть: говорите здесь, код пишется там.

И вот это — вся прелесть стандартов. ACP закрывает «последнюю милю» до локального CLI-агента, A2A — связь по сети, realtime-слой — голос. Три независимых куска, а стыкуются без единой строчки самописного клея: каждый говорит на своём стандартном контракте, и их можно собирать как кубики.

Что в итоге

ACP сводится к одной идее: дать любому хосту — редактору или вашей программе — единый способ запускать кодинг-агента и видеть каждый его шаг. Под капотом всё скромно: JSON-RPC поверх stdio, initialize → session/new → session/prompt, поток session/update обратно. А наверху из этого получается то, чего у терминала нет: структурированный поток событий, контроль над разрешениями, песочница и композиция агентов — хоть в одном процессе, хоть по сети в связке с A2A.

В AG2 это спрятано за обычным Agent с другим конфигом — берёте ClaudeCodeConfig, и Claude Code из CLI превращается в управляемого агента у вас в коде.

А теперь вопрос, на который у меня самого нет однозначного ответа: доверили бы вы автономному агенту с permission_policy="auto" свой рабочий репозиторий — и где для вас проходит граница, за которой человек обязан остаться в цикле? Интересно почитать в комментариях, кто где её проводит.

Ссылки:

- Сайт и спека: agentclientprotocol.com

- ACP у Zed: zed.dev/acp

- Реализация в AG2: модуль ag2/acp

- Реестр агентов и SDK: agentclientprotocol.com/get-started/registry

Комментарии (1)


  1. dkeiz
    15.07.2026 05:57

    что я не поняло, так зачем плодить сущности, и делать отдельный ACP и A2G если все равно оно работает через A2A.
    Сам все это гоняю, и в отсутствие единого стандарта аля MCP получается какая каша.